diff --git a/.claude/skills/code-organization/SKILL.md b/.claude/skills/code-organization/SKILL.md new file mode 100644 index 0000000..77e7b7d --- /dev/null +++ b/.claude/skills/code-organization/SKILL.md @@ -0,0 +1,201 @@ +--- +name: code-organization +description: Organize Swift code with MARK comments and consistent file structure. Use when refactoring, creating new files, or improving code navigability. +--- + +# BetterFit Code Organization Skill + +Use this skill when organizing Swift files, adding MARK comments, or improving code navigability. + +## When to activate this skill + +- Creating a new SwiftUI view or screen +- Refactoring existing code for better organization +- File has grown large and needs section markers +- User asks about code style or file structure +- Code review feedback mentions organization +- Want to improve Xcode minimap/navigability + +## MARK comment conventions + +Use `// MARK: - ...` to group related code into navigable sections. + +### Rules + +- Use `// MARK:` **above declarations** (computed properties, functions, nested types) +- **Don't** put MARKs inside view builder closures +- Keep marks short and consistent +- Add them when a file has multiple logical blocks + +### SwiftUI view ordering + +```swift +struct WorkoutHomeView: View { + + // MARK: - State + @State private var workouts: [Workout] = [] + @State private var searchText = "" + @StateObject private var viewModel = WorkoutViewModel() + + // MARK: - Dependencies + let betterFit: BetterFit + let theme: AppTheme + + // MARK: - Init + init(betterFit: BetterFit, theme: AppTheme) { + self.betterFit = betterFit + self.theme = theme + } + + // MARK: - View + var body: some View { + ScrollView { + welcomeSection + suggestedWorkoutsSection + recentActivitySection + } + .navigationTitle("Workouts") + } + + // MARK: - Welcome Section + private var welcomeSection: some View { + VStack(alignment: .leading) { + Text("Good morning,") + .font(theme.subheadline) + Text("Ready to train?") + .font(theme.headline) + } + } + + // MARK: - Suggested Workouts Section + private var suggestedWorkoutsSection: some View { + VStack(alignment: .leading) { + Text("Suggested for You") + .font(theme.sectionHeader) + + ForEach(viewModel.suggestedWorkouts) { workout in + WorkoutCard(workout: workout, theme: theme) + } + } + } + + // MARK: - Recent Activity Section + private var recentActivitySection: some View { + VStack(alignment: .leading) { + Text("Recent") + .font(theme.sectionHeader) + + ForEach(viewModel.recentWorkouts.prefix(3)) { workout in + WorkoutRow(workout: workout, theme: theme) + } + } + } + + // MARK: - Data + private var filteredWorkouts: [Workout] { + guard !searchText.isEmpty else { return workouts } + return workouts.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + + // MARK: - Actions + private func startWorkout(_ workout: Workout) { + betterFit.startWorkout(workout) + } + + // MARK: - Supporting Types + private struct WorkoutCard: View { + let workout: Workout + let theme: AppTheme + + var body: some View { + // ... + } + } +} +``` + +### Manager/Service ordering + +```swift +final class WorkoutManager: ObservableObject { + + // MARK: - Published State + @Published var activeWorkout: Workout? + @Published var history: [Workout] = [] + + // MARK: - Dependencies + private let apiClient: APIClient + private let cache: Cache + + // MARK: - Init + init(apiClient: APIClient, cache: Cache) { + self.apiClient = apiClient + self.cache = cache + } + + // MARK: - Public API + func startWorkout(_ workout: Workout) { ... } + func completeWorkout(_ workout: Workout) { ... } + func deleteWorkout(_ workout: Workout) { ... } + + // MARK: - Private Helpers + private func saveToCache(_ workout: Workout) { ... } + private func syncWithServer(_ workout: Workout) { ... } + + // MARK: - Computed Properties + var totalWorkoutsCompleted: Int { history.count } + var currentStreak: Int { calculateStreak() } +} +``` + +## Section naming conventions + +| Section | Use for | Example | +|---------|---------|---------| +| `// MARK: - State` | `@State`, `@StateObject`, `@ObservedObject` | `@State private var isLoading` | +| `// MARK: - Dependencies` | Injected services, managers | `let apiClient: APIClient` | +| `// MARK: - Init` | Initializers | `init(...)` | +| `// MARK: - View` | `body` and main view structure | `var body: some View` | +| `// MARK: - [Name] Section` | Cohesive UI section | `welcomeSection`, `statsSection` | +| `// MARK: - Sections` | Multiple related sections | List sections, cards | +| `// MARK: - Data` | Computed properties, static data | `filteredWorkouts`, `sampleData` | +| `// MARK: - Actions` | User action handlers | `startWorkout`, `handleTap` | +| `// MARK: - Public API` | Exposed methods (managers) | `func save()`, `func load()` | +| `// MARK: - Private Helpers` | Internal implementation | `private func validate()` | +| `// MARK: - Supporting Types` | Nested structs/enums | `private struct Row: View` | + +## File organization principles + +### Within a file (top to bottom) + +1. **Imports** +2. **Type declaration** +3. **State/Properties** (published first, then private) +4. **Dependencies** +5. **Init** +6. **Main API/View** (`body`, key methods) +7. **Sections/Subviews** (computed properties) +8. **Data/Helpers** (computed properties, private methods) +9. **Actions** (event handlers) +10. **Supporting Types** (nested structs/enums) + +### Across files + +See `swift-file-splitting` skill for when/how to split large files. + +## Xcode benefits + +Proper MARK organization enables: +- **Minimap** shows clear section boundaries +- **Jump bar** (Ctrl+6) lists all sections +- **Code folding** works at section level +- **Quick navigation** in large files + +## Quick checklist + +- [ ] Add MARKs when file has 3+ logical sections +- [ ] Use `// MARK: - Name` (with dash for separator line) +- [ ] Place MARKs above declarations, not inside closures +- [ ] Follow consistent ordering: State → Dependencies → Init → View → Sections → Data → Actions → Types +- [ ] Keep section names short and consistent +- [ ] Don't over-MARK: simple files (<100 lines) may not need any diff --git a/.claude/skills/mise-workflows/SKILL.md b/.claude/skills/mise-workflows/SKILL.md new file mode 100644 index 0000000..c676af7 --- /dev/null +++ b/.claude/skills/mise-workflows/SKILL.md @@ -0,0 +1,139 @@ +--- +name: mise-workflows +description: Fast development workflows for the BetterFit iOS project using mise. Use when building, testing, opening Xcode, or running lint. +--- + +# BetterFit Mise Workflows Skill + +Use this skill when performing any build, test, lint, or Xcode operations on the BetterFit project. + +## When to activate this skill + +- User wants to build the project +- User wants to run tests +- User wants to open Xcode +- User wants to lint code +- User wants to build for iOS or watchOS +- User wants to create or render app showcase videos +- User asks about project commands or workflows +- User encounters build errors and needs to rebuild + +## Core Commands + +### Essential workflow (run these in order) + +```bash +# 1. Install tools (first time or after tool updates) +mise install + +# 2. Lint (fast feedback before building) +mise run lint + +# 3. Build SwiftPM package +mise run build # Runs swift build + +# 4. Run unit + integration tests +mise run test # Runs swift test +``` + +### iOS host app commands + +```bash +# Open Xcode project (generates + opens) +mise run ios:open + +# Build iOS for development +mise run ios:build:dev + +# Build iOS for production +mise run ios:build:prod + +# Generate Xcode project only +mise run ios:gen + +# Boot iOS 26 simulator +mise run ios:sim:boot26 + +# Run UI tests +mise run ios:test:ui +``` + +### watchOS app commands + +```bash +# Open watchOS Xcode project +mise run watch:open + +# Build watchOS +mise run watch:build +``` + +### Video showcase commands (Remotion + Bun) + +```bash +# Install video dependencies (uses Bun) +mise run video:install + +# Preview in Remotion Studio +mise run video:dev + +# Render the main showcase video +mise run video:render:showcase + +# Render any composition +mise run video:render -- + +# Render a single frame for QA +mise run video:still -- --frame=N +``` + +**Important:** Video tasks run in the `videos/` directory using Bun (not npm). See `remotion-app-showcase` skill for video creation guidance. + +### Important rules + +- **Never run `xcodegen generate` directly** — always use `mise run ios:gen` or `mise run ios:open` to ensure Supabase credentials are injected from `.env` +- After making changes, run `mise run lint` first (quicker feedback than building) +- Use `mise run ios:build:dev` for quick iteration; `mise run ios:build:prod` for release testing + +## Workflow patterns + +### Development loop +``` +Make changes → mise run lint → mise run build → mise run test +``` + +### UI iteration loop +``` +Make changes → mise run ios:build:dev → test in simulator +``` + +### Pre-commit checklist +``` +mise run lint +mise run test +``` + +### When build fails +1. Run `mise run lint` to catch syntax/style issues first +2. Check if Supabase credentials are configured (see supabase-setup skill) +3. Try clean build: `mise run ios:build:dev` (rebuilds from scratch) +4. For SwiftPM issues: `swift package resolve` then retry + +## Project structure context + +- **SwiftPM library**: `Sources/BetterFit/` — core business logic +- **iOS host app**: `Apps/iOS/BetterFitApp/` — demo UI, depends on SwiftPM package +- **watchOS app**: `Apps/iOS/BetterFitWatchApp/` — watch app +- **XcodeGen config**: `Apps/iOS/project.yml` — don't hand-edit `.xcodeproj` + +## Common issues + +### "No such module 'BetterFit'" +- Run `mise run ios:gen` to regenerate Xcode project with proper SPM linking + +### Xcode project out of sync +- Don't edit `.xcodeproj` directly +- Run `mise run ios:open` to regenerate from `project.yml` + +### Build fails after git clean +- `.env` may be deleted — check supabase-setup skill for reconfiguration diff --git a/.claude/skills/remotion-app-showcase/SKILL.md b/.claude/skills/remotion-app-showcase/SKILL.md new file mode 100644 index 0000000..27978ed --- /dev/null +++ b/.claude/skills/remotion-app-showcase/SKILL.md @@ -0,0 +1,387 @@ +--- +name: remotion-app-showcase +description: Create cinematic product showcase videos for iOS apps using Remotion. Use when building app demos, App Store previews, feature highlights, or marketing videos with real app recordings. +--- + +# Remotion App Showcase Skill + +Use this skill to create professional, cinematic product showcase videos for iOS apps using Remotion and real simulator recordings. + +## When to activate this skill + +- User wants to create an app demo or showcase video +- User wants an App Store preview video +- User wants to highlight app features in a marketing video +- User has app screen recordings and wants to present them professionally +- User asks about Remotion, video creation, or app presentations +- User wants to use `mise run video:*` tasks + +## Philosophy + +**Show pieces, not full screens.** Focus on the specific UI element that proves the feature works — a button tap, a card swipe, a stat updating. Crop/mask to isolate the focal component rather than showing the entire screen. + +**Spring physics > linear easing.** Use `spring()` for all animations — it feels more cinematic and natural. + +**One comprehensive event per operation.** Structure the video with a clear narrative arc: Hook → Problem/Solution → Feature Demo → CTA. + +## Prerequisites + +1. **Video project** at `videos/` using Bun (not npm) +2. **Real app recordings** in `public/` directory (use simulator + `mobilecli screencapture`) +3. **Brand assets**: logo, colors, app icon in `public/` + +**Dependencies are managed via mise + Bun:** +```bash +mise run video:install # Installs Bun deps in videos/ directory +``` + +### Project structure + +``` +videos/ +├── package.json # Bun deps (remotion, react, etc.) +├── bun.lock # Bun lockfile +├── remotion.config.ts # Remotion CLI config +├── tsconfig.json # TypeScript config +├── src/ +│ ├── index.tsx # Remotion root + compositions +│ ├── showcase/ # Showcase components +│ │ ├── constants.ts # Colors, fonts, timing +│ │ ├── PhoneFrame.tsx # iPhone device frame +│ │ ├── AnimatedCursor.tsx # Tap cursor with ripple +│ │ ├── TextOverlay.tsx # Spring-animated headlines +│ │ ├── IntroScene.tsx # Logo reveal + tagline +│ │ ├── AppShowcaseScene.tsx # Feature with phone + video +│ │ ├── OutroScene.tsx # CTA + download button +│ │ └── ShowcaseVideo.tsx # Main composition +│ └── ExerciseDemo.tsx # Legacy exercise demos +├── public/ # Video assets + images +│ ├── app-demo.mp4 +│ ├── incline-dumbbell-press.mp4 +│ └── deadlift.mp4 +└── out/ # Rendered videos (gitignored) +``` + +## Workflow + +### 1. Record app footage + +Use `mobilecli` to capture real app interactions: + +```bash +# Boot simulator +mise run ios:sim:boot26 + +# Build and install app +mise run ios:build:dev + +# Record screen +mobilecli screencapture --device --format mjpeg | \ + ffmpeg -f mjpeg -i - -c:v libx264 -pix_fmt yuv420p -an recording.mp4 +``` + +Re-encode for Chrome compatibility: +```bash +ffmpeg -i recording.mp4 -c:v libx264 -pix_fmt yuv420p -profile:v main \ + -level 4.0 -movflags +faststart -an public/recording.mp4 +``` + +### 2. Set up project structure + +``` +videos/ +├── src/ +│ ├── showcase/ +│ │ ├── constants.ts # Colors, fonts, timing +│ │ ├── PhoneFrame.tsx # iPhone device frame +│ │ ├── AnimatedCursor.tsx # Tap cursor with ripple +│ │ ├── TextOverlay.tsx # Spring-animated headlines +│ │ ├── IntroScene.tsx # Logo reveal + tagline +│ │ ├── AppShowcaseScene.tsx # Feature with phone + video +│ │ ├── OutroScene.tsx # CTA + download button +│ │ └── ShowcaseVideo.tsx # Main composition +│ └── index.tsx # Remotion root +└── public/ + ├── app-demo.mp4 + ├── feature-1.mp4 + └── logo.png +``` + +### 3. Core components + +#### PhoneFrame + +```tsx +import React from 'react'; + +export const PhoneFrame: React.FC<{children: React.ReactNode}> = ({children}) => { + return ( +
+
+ {/* Dynamic Island */} +
+ {children} +
+
+ ); +}; +``` + +#### AnimatedCursor + +```tsx +import {interpolate, useCurrentFrame} from 'remotion'; + +export const AnimatedCursor: React.FC<{ + startX: number; startY: number; + endX: number; endY: number; + startFrame: number; duration: number; + clickFrame?: number; +}> = ({startX, startY, endX, endY, startFrame, duration, clickFrame}) => { + const frame = useCurrentFrame(); + + const progress = interpolate(frame, [startFrame, startFrame + duration], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + + const x = interpolate(progress, [0, 1], [startX, endX]); + const y = interpolate(progress, [0, 1], [startY, endY]); + + return ( +
+ + + +
+ ); +}; +``` + +### 4. Scene structure + +Follow this narrative arc: + +``` +Intro (3s) → Feature 1 (4s) → Feature 2 (4s) → Feature 3 (4s) → Outro (3s) +``` + +Use `TransitionSeries` for smooth transitions: + +```tsx +import {TransitionSeries} from '@remotion/transitions'; +import {fade} from '@remotion/transitions/fade'; +import {slide} from '@remotion/transitions/slide'; + + + + + + + t * t * (3 - 2 * t)}} + /> + + + + + + t * t * (3 - 2 * t)}} + /> + + {/* ... more scenes ... */} + +``` + +### 5. Animation best practices + +**Spring physics for all motion:** +```tsx +const scale = spring({frame, fps, config: {damping: 16, stiffness: 80}}); +const opacity = interpolate(frame, [0, 20], [0, 1]); +``` + +**Text entrances:** +```tsx +const translateY = interpolate(frame, [0, 20], [40, 0]); +const opacity = interpolate(frame, [0, 15], [0, 1]); +// Combine: transform: `translateY(${translateY}px)`, opacity +``` + +**Phone frame entrance:** +```tsx +const phoneEntrance = spring({ + frame: frame - 15, + fps, + config: {damping: 14, stiffness: 80}, +}); +// transform: `scale(${0.85 + entrance * 0.15}) translateY(${(1 - entrance) * 50}px)` +``` + +### 6. Design tokens + +Keep all visual identity in one place: + +```tsx +export const COLORS = { + background: '#000000', + surface: '#0A0A0A', + accent: '#FFD60A', + accentMuted: '#FFD60A80', + accentGlow: '#FFD60A40', + text: '#FFFFFF', + textSecondary: '#A0A0A0', +}; + +export const FONTS = { + display: "'Oswald', 'Bebas Neue', sans-serif", + body: "'Source Sans 3', 'Helvetica Neue', sans-serif", +}; + +export const DURATIONS = { + intro: 90, // 3s + feature: 120, // 4s + outro: 90, // 3s + transition: 30, // 1s +}; +``` + +### 7. Render and QA (via mise) + +```bash +# Preview in browser +mise run video:dev + +# Render the main showcase video +mise run video:render:showcase + +# Render any composition +mise run video:render -- BetterFitShowcase out/showcase.mp4 + +# Capture key frames for QA +mise run video:still -- BetterFitShowcase /tmp/frame-120.png --frame=120 +``` + +**Raw Bun commands** (if not using mise): +```bash +cd videos +bun run dev # Preview in browser +bun run render:showcase # Render showcase +bun run still # Render single frame +``` + +## Common patterns + +### Feature showcase scene + +```tsx +export const AppShowcaseScene: React.FC<{ + videoSrc: string; + featureTitle: string; + featureDescription: string; + featureNumber: number; +}> = ({videoSrc, featureTitle, featureDescription, featureNumber}) => { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + + const phoneEntrance = spring({frame: frame - 15, fps, config: {damping: 14, stiffness: 80}}); + const videoOpacity = interpolate(frame, [20, 40], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); + + return ( + +

{featureTitle}

+

{featureDescription}

+ +
+ + + +
+
+ ); +}; +``` + +### Text overlay with spring + +```tsx +export const TextOverlay: React.FC<{text: string; delay?: number}> = ({text, delay = 0}) => { + const frame = useCurrentFrame(); + const opacity = interpolate(frame, [delay, delay + 15], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); + const translateY = interpolate(frame, [delay, delay + 20], [40, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); + + return ( +
+

{text}

+
+ ); +}; +``` + +## Troubleshooting + +### "MEDIA_ELEMENT_ERROR: Format error" +Re-encode videos to Chrome-compatible H.264: +```bash +ffmpeg -i input.mp4 -c:v libx264 -pix_fmt yuv420p -profile:v main -level 4.0 -movflags +faststart -an output.mp4 +``` + +### "404 while downloading file" +Use `staticFile()` for video sources: +```tsx + +``` + +### Transitions not smooth +Ensure `TransitionSeries` wraps all scenes. Use `@remotion/transitions` package (installed via Bun). + +## Related skills + +- **mise-workflows**: Build/render commands via mise (includes `video:*` tasks) +- **testing**: Mobile MCP testing for capturing simulator footage + +## External resources + +- **remotion-saas-showcase**: PhoneFrame, Cursor, TextOverlay components +- **MATI Teaser**: Narrative arc structure, spring physics examples +- **Appshot**: App Store preview dimensions and store compliance +- **create-onboarding-video skill**: Isolated UI component approach + +## Quick checklist + +- [ ] Run `mise run video:install` to install deps +- [ ] Real app recordings captured and re-encoded in `public/` +- [ ] Brand colors/fonts defined in `constants.ts` +- [ ] PhoneFrame component with Dynamic Island +- [ ] Spring physics used for all animations (no linear easing) +- [ ] TransitionSeries with fade/slide transitions +- [ ] Text overlays with upward slide + fade entrance +- [ ] Cursor animation for tap interactions +- [ ] Key frames captured for QA (`mise run video:still`) +- [ ] Final render succeeds (`mise run video:render:showcase`) diff --git a/.claude/skills/supabase-setup/SKILL.md b/.claude/skills/supabase-setup/SKILL.md new file mode 100644 index 0000000..c6da0a6 --- /dev/null +++ b/.claude/skills/supabase-setup/SKILL.md @@ -0,0 +1,104 @@ +--- +name: supabase-setup +description: Local Supabase setup and configuration for BetterFit iOS app. Use when setting up the project for the first time, or when .env credentials are missing. +--- + +# BetterFit Supabase Setup Skill + +Use this skill when configuring local Supabase for the BetterFit iOS project, or when the `.env` file is missing/corrupted. + +## When to activate this skill + +- First time project setup on a new machine +- `.env` file is missing +- `mise run supabase:status` shows services not running +- After `git clean -fdx` (deletes `.env`) +- Build errors mentioning Supabase URL or anon key +- User asks about database setup or backend configuration + +## Prerequisites check + +Before running setup commands, verify current state: + +```bash +# Check if Supabase is running +mise run supabase:status + +# Check if .env exists with credentials +cat .env | grep -E "SUPABASE_URL|SUPABASE_ANON_KEY" +``` + +## When setup is NOT needed + +Skip setup if ALL of these are true: +- `.env` file exists with `SUPABASE_URL` and `SUPABASE_ANON_KEY` +- `mise run supabase:status` shows running services +- Recent code changes only (no infrastructure changes) + +Setup is NOT needed after: +- Making code changes (credentials persist) +- Running `mise build` / `mise test` +- Running `mise run ios:open` / `mise run ios:build:dev` (auto-injects from existing `.env`) + +## One-time setup + +If Supabase is not configured, run these in order: + +```bash +# 1. Start Supabase services +mise run supabase:start + +# 2. Apply database migrations (creates tables, functions, etc.) +mise run supabase:reset + +# 3. Configure for iOS (creates .env + injects credentials into Xcode project) +mise run supabase:configure +``` + +After step 3, verify `.env` was created: +```bash +cat .env +``` + +## After initial setup + +Once `.env` exists, use normal build commands (credentials auto-inject): + +```bash +mise run ios:open # Opens Xcode with credentials injected +mise run ios:build:dev # Builds with credentials injected +mise run ios:build:prod +``` + +## Important rules + +- **Never run `xcodegen generate` directly** — always use `mise run ios:gen` or `mise run ios:open` to ensure credentials are injected from `.env` +- Credentials are stored in `.env` (not committed to git) +- If `.env` is deleted, rerun `mise run supabase:configure` + +## Troubleshooting + +### "SUPABASE_URL not found" +- `.env` is missing → run `mise run supabase:configure` + +### Supabase services won't start +```bash +# Check Docker is running +docker ps + +# Restart Supabase +mise run supabase:stop +mise run supabase:start +``` + +### Credentials changed (rare) +```bash +# Reconfigure from scratch +mise run supabase:reset +mise run supabase:configure +``` + +### iOS build can't find Supabase credentials +- Ensure `.env` exists at project root +- Run `mise run ios:gen` to regenerate Xcode project with injected credentials +- Never edit `.xcodeproj` directly diff --git a/.claude/skills/swift-file-splitting/SKILL.md b/.claude/skills/swift-file-splitting/SKILL.md new file mode 100644 index 0000000..27b0724 --- /dev/null +++ b/.claude/skills/swift-file-splitting/SKILL.md @@ -0,0 +1,188 @@ +--- +name: swift-file-splitting +description: Split large SwiftUI screens into maintainable, scan-friendly files. Use when files exceed 300-500 lines or contain multiple "reasons to change". +--- + +# BetterFit Swift File Splitting Skill + +Use this skill when SwiftUI screen files become too large or contain too many concerns. + +## When to activate this skill + +- File exceeds 300–500+ lines +- SwiftUI type-checking or preview/build times degrade +- File contains multiple "reasons to change" (layout + data + formatting) +- Difficult to scan/navigate the file +- Want to reuse a section/component across screens +- User asks about file organization or refactoring large views + +## When to split + +Split when ANY of these are true: + +- **Size**: File is no longer scan-friendly (roughly 300–500+ lines) +- **Multiple concerns**: Layout, data shaping, components, formatting helpers all in one file +- **Build time**: SwiftUI type-checking or preview times start degrading +- **Reusability**: A section/component could be reused elsewhere + +## What to split into + +| File type | Contents | Access | +|-----------|----------|--------| +| **Main screen** | `struct ...: View`, `@State`, init, `body`, navigation, sheets | `public` or default | +| **Sections** | Cohesive view chunks: `welcomeSection`, `overviewSection`, `recapCard` | `fileprivate` or `internal` | +| **Components** | Small reusable view structs: pills, stat rows, gauges | `internal` | +| **Helpers** | Pure functions: formatting, date logic, aggregation | `fileprivate` or `internal` | +| **Models** | UI-only types: simple structs/enums used by screen | `internal` | + +## Suggested file structure + +For larger screens under `Apps/iOS/BetterFitApp`: + +``` +Apps/iOS/BetterFitApp/Features// +├── View.swift # Entry point: state/init/body +├── View+Sections.swift # Computed section views +├── View+Helpers.swift # Pure helper functions +├── Components.swift # Small reusable view structs +└── Models.swift # Optional: UI-only types +``` + +## Safe refactor process + +Follow this loop to avoid breaking things: + +1. **Keep entry point stable** + - Don't rename the screen type or initializer unless necessary + - Ensure existing call sites continue working + +2. **Extract one cohesive slice at a time** + - Start with a single section (e.g., `welcomeSection`) + - Move it to the appropriate file + - **Rebuild and test after each extraction** + +3. **Prefer `extension` splitting** + ```swift + // WorkoutHomeView.swift + struct WorkoutHomeView: View { ... } + + // WorkoutHomeView+Sections.swift + extension WorkoutHomeView { + var welcomeSection: some View { ... } + var suggestedWorkoutsSection: some View { ... } + } + + // WorkoutHomeView+Helpers.swift + extension WorkoutHomeView { + func formatElapsed(_ date: Date) -> String { ... } + } + ``` + +4. **Promote to reusable components** + - If a section becomes broadly reusable, extract to its own `View` type + - Pass only what it needs (don't thread bindings everywhere) + - Example: `WorkoutCard(workout:)` instead of `workoutCard(for: at:)` + +5. **Verify after each extraction** + ```bash + mise run ios:build:dev # Verify UI compiles + mise run test # Verify core behaviors + ``` + +## Access control guidance + +| Scope | Access | Use when | +|-------|--------|----------| +| Same file | `private` | Everything possible | +| Same feature folder | `fileprivate` | Helpers/components meant to stay screen-scoped | +| Module-wide | `internal` (default) | Reusable across app | +| Public API | `public` | Library/framework exports | + +Keep the public surface area small. Avoid making everything `public`/`open`. + +## Example: Before and after + +### Before (500+ lines) +```swift +struct WorkoutHomeView: View { + @State private var workouts: [Workout] = [] + @State private var searchText = "" + @State private var selectedFilter: Filter = .all + + var body: some View { + ScrollView { + welcomeSection + filterBar + workoutList + statsSection + } + } + + var welcomeSection: some View { ... } // 60 lines + var filterBar: some View { ... } // 40 lines + var workoutList: some View { ... } // 120 lines + var statsSection: some View { ... } // 80 lines + + func formatDate(_ date: Date) -> String { ... } + func filterWorkouts() -> [Workout] { ... } + func calculateStats() -> Stats { ... } +} + +struct WorkoutRow: View { ... } // 50 lines +struct StatCard: View { ... } // 40 lines +``` + +### After (split into 4 files) + +**WorkoutHomeView.swift** (120 lines) +```swift +struct WorkoutHomeView: View { + @State private var workouts: [Workout] = [] + @State private var searchText = "" + @State private var selectedFilter: Filter = .all + + var body: some View { + ScrollView { + WelcomeSection(user: currentUser) + FilterBar(selection: $selectedFilter) + WorkoutList(workouts: filteredWorkouts) + StatsSection(stats: calculateStats()) + } + } +} +``` + +**WorkoutHomeView+Sections.swift** +```swift +extension WorkoutHomeView { + var filteredWorkouts: [Workout] { ... } + func calculateStats() -> Stats { ... } +} +``` + +**WorkoutHomeView+Helpers.swift** +```swift +extension WorkoutHomeView { + func formatDate(_ date: Date) -> String { ... } +} +``` + +**WorkoutHomeComponents.swift** +```swift +struct WelcomeSection: View { ... } +struct FilterBar: View { ... } +struct WorkoutList: View { ... } +struct StatsSection: View { ... } +struct WorkoutRow: View { ... } +struct StatCard: View { ... } +``` + +## Quick decision checklist + +- [ ] File is >300 lines or hard to scan? +- [ ] Contains layout + data + formatting mixed together? +- [ ] Build/preview times degraded? +- [ ] Extract one section at a time, rebuild after each +- [ ] Use `extension ScreenName` across files to avoid threading bindings +- [ ] Run `mise run ios:build:dev` + `mise run test` after each extraction +- [ ] Keep entry point stable; don't rename unless necessary diff --git a/.claude/skills/swiftui-reactivity/SKILL.md b/.claude/skills/swiftui-reactivity/SKILL.md new file mode 100644 index 0000000..28bf916 --- /dev/null +++ b/.claude/skills/swiftui-reactivity/SKILL.md @@ -0,0 +1,171 @@ +--- +name: swiftui-reactivity +description: Prevent unwanted re-renders in SwiftUI by extracting high-frequency state into isolated child components. Use when views flicker, performance degrades, or @State updates cascade to siblings. +--- + +# BetterFit SwiftUI Reactivity Skill + +Use this skill when SwiftUI views re-render too frequently, causing flickering or performance issues. + +## When to activate this skill + +- Views flicker during animations or gestures +- Timer/animation loops cause cascading re-renders +- Drag gestures make unrelated UI elements update +- Performance profiling shows excessive body evaluations +- User mentions "flickering", "jitter", or "lag" in SwiftUI +- `@State` property changes trigger parent/sibling re-renders unnecessarily + +## The Problem + +Reading frequently-changing `@State` in a computed property causes SwiftUI to re-evaluate that property on every state change. This cascades to all sibling views in the same parent. + +**Example problems:** +- `elapsedTimeUpdateTrigger` toggled 100x/second → parent re-renders entire view hierarchy +- `cardSwipeOffset` updated every frame during drag → unrelated sections flicker +- Timer callbacks updating `@State` → entire list redraws + +## The Solution: Reactivity Boundaries + +Extract the view that reads the frequently-changing state into its own child component with `@Binding`. This creates a "reactivity boundary" — only the child re-renders, not the parent or siblings. + +### Pattern + +```swift +// Parent (WorkoutHomeView) +@State var elapsedTimeUpdateTrigger = false + +var compactWelcomeSection: some View { + // Isolated child—only this component re-renders on timer ticks + ElapsedTimeDisplay( + elapsedTimeUpdateTrigger: $elapsedTimeUpdateTrigger, + startTime: workoutStartTime + ) +} + +// Child (private struct) +private struct ElapsedTimeDisplay: View { + @Binding var elapsedTimeUpdateTrigger: Bool + let startTime: Date + + var body: some View { + let _ = elapsedTimeUpdateTrigger // Force read on every state change + // Only this view re-renders, not parent siblings + Text(formatElapsed(since: startTime)) + .font(.system(.title2, design: .rounded, weight: .bold)) + } +} +``` + +### Key rules + +1. **Move state to child**: Use `@State private` in the child component +2. **Pass binding from parent**: `$propertyName` creates the binding +3. **Child reads the binding**: The `let _ = trigger` pattern forces SwiftUI to track the dependency +4. **Only child re-renders**: Parent and siblings are isolated from the high-frequency updates + +## When to extract + +| Scenario | Extract? | Pattern | +|----------|----------|---------| +| Timer/animation >10x/sec | ✅ Yes | Extract to child with `@Binding` | +| Drag gesture updating every frame | ✅ Yes | Extract to child with `@Binding` | +| Stable state (changes on user tap) | ❌ No | Keep in parent as computed property | +| Text input with @State | ❌ No | SwiftUI handles this natively | +| Scroll position tracking | ⚠️ Consider | Use `onChange` or `@StateObject` | + +## Anti-patterns to avoid + +### ❌ Reading high-frequency state in parent +```swift +struct BadExample: View { + @State var timerTick = false + + var body: some View { + let _ = timerTick // DON'T: Entire body re-renders 100x/sec + VStack { + TimerDisplay() // Re-renders unnecessarily + TargetMuscles() // Re-renders unnecessarily + WorkoutList() // Re-renders unnecessarily + } + } +} +``` + +### ✅ Isolating in child component +```swift +struct GoodExample: View { + @State var timerTick = false + + var body: some View { + VStack { + TimerDisplay(trigger: $timerTick) // Only this re-renders + TargetMuscles() // Stable + WorkoutList() // Stable + } + } +} +``` + +## Common scenarios + +### Timer-triggered updates +```swift +// Parent +@State private var elapsedTimeTrigger = false + +private let timer = Timer.publish(every: 0.1, on: .main, in: .common) + .autoconnect() + +var body: some View { + ElapsedTimeView(trigger: $elapsedTimeTrigger) + .onReceive(timer) { _ in + elapsedTimeTrigger.toggle() + } +} + +// Child +private struct ElapsedTimeView: View { + @Binding var trigger: Bool + let startTime: Date + + var body: some View { + let _ = trigger + Text(formatElapsed(since: startTime)) + } +} +``` + +### Drag gesture offset +```swift +// Parent +@State private var cardOffset: CGSize = .zero + +var body: some View { + SwipeableCard(offset: $cardOffset) + RelatedInfo() // Won't re-render during swipe +} + +// Child +private struct SwipeableCard: View { + @Binding var offset: CGSize + + var body: some View { + CardContent() + .offset(offset) + .gesture( + DragGesture() + .onChanged { offset = $0.translation } + ) + } +} +``` + +## Performance checklist + +- [ ] Profile with SwiftUI Instruments to find excessive body evaluations +- [ ] Identify which `@State` properties change most frequently +- [ ] Check if parent computed properties read high-frequency state +- [ ] Extract high-frequency readers into child components +- [ ] Verify siblings no longer re-render (use `.printChanges()`) +- [ ] Don't over-extract: stable state can stay in parent diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md new file mode 100644 index 0000000..84a76c7 --- /dev/null +++ b/.claude/skills/testing/SKILL.md @@ -0,0 +1,231 @@ +--- +name: testing +description: Testing practices for BetterFit iOS project. Use when writing, running, or debugging unit tests, integration tests, UI tests, or Mobile MCP tests. +--- + +# BetterFit Testing Skill + +Use this skill when writing, running, or debugging any tests in the BetterFit project. + +## When to activate this skill + +- User wants to write a new test +- User wants to run tests +- User needs to debug a failing test +- User wants to test a specific feature or flow +- User asks about testing strategy or coverage +- User encountered a bug and wants to prevent regression +- User wants to use Mobile MCP for UI testing + +## Test pyramid + +| Layer | Location | Purpose | Command | +|-------|----------|---------|---------| +| **Unit tests** | `Tests/BetterFitTests/*.swift` | Individual functions, models, managers | `mise run test` | +| **Integration tests** | `Tests/BetterFitTests/IntegrationTests.swift` | Cross-feature flows via `BetterFit` facade | `mise run test` | +| **UI tests** | `Apps/iOS/BetterFitAppUITests/*.swift` | User-facing flows in simulator | `mise run ios:test:ui` | +| **Mobile MCP** | Interactive | Exploratory testing, visual debugging | Manual/MCP tools | + +## When to write each type + +### Unit tests +- New model or data structure +- Helper function with complex logic +- Manager method with business rules +- Equipment swap algorithm +- Date formatting or calculation logic + +**Keep fast (< 1s each)** — avoid network/disk I/O. + +### Integration tests +- Cross-feature flows touching multiple managers/services +- Example: `completeWorkout(_:)` updates history + recovery + streak +- Plan adaptation flows +- Social features (streaks, sharing) + +### UI tests (XCUITest) +- Critical user journeys that could regress +- Navigation flows (tab switching, drill-downs) +- Sheet presentation and dismissal +- Swipe actions and gestures +- Form controls and validation + +### Mobile MCP tests +- Exploratory testing of new UI +- Visual debugging (flickering, layout issues) +- Verifying behavior hard to capture in XCUITest +- Interactive simulator testing + +## Running tests + +```bash +# Unit + integration tests (SwiftPM) +mise run test + +# UI tests (requires iOS simulator) +mise run ios:test:ui + +# Boot simulator for manual/MCP testing +mise run ios:sim:boot26 +``` + +## Unit test example + +```swift +import XCTest +@testable import BetterFit + +final class WorkoutTests: XCTestCase { + func testWorkoutDurationCalculation() { + let workout = Workout( + name: "Push Day", + exercises: [ + Exercise(name: "Bench Press", sets: 3, reps: 10, restSeconds: 90), + Exercise(name: "Overhead Press", sets: 3, reps: 10, restSeconds: 90) + ] + ) + + XCTAssertEqual(workout.estimatedDuration, 720) // ~12 min + } + + func testEquipmentSwap() { + let exercise = Exercise(name: "Barbell Row", equipment: .barbell) + let swapped = exercise.swapEquipment(to: .dumbbell) + + XCTAssertEqual(swapped.equipment, .dumbbell) + XCTAssertEqual(swapped.name, "Dumbbell Row") + } +} +``` + +## Integration test example + +```swift +final class IntegrationTests: XCTestCase { + var betterFit: BetterFit! + + override func setUp() { + super.setUp() + betterFit = BetterFit() + } + + func testCompleteWorkoutUpdatesAllSystems() { + // Given + let workout = betterFit.recommendedWorkouts.first! + + // When + betterFit.startWorkout(workout) + betterFit.completeWorkout(workout) + + // Then + XCTAssertTrue(betterFit.history.contains(workout)) + XCTAssertGreaterThan(betterFit.bodyMap.recovery[.chest] ?? 0, 0) + XCTAssertEqual(betterFit.social.currentStreak, 1) + } +} +``` + +## UI test example + +```swift +final class WorkoutFlowUITests: XCTestCase { + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["UI_TESTING", "DEMO_MODE"] + app.launch() + } + + func testStartWorkoutFlow() throws { + // Tap Workout tab + app.tabBars.buttons["Workout"].tap() + + // Tap first recommended workout + app.cells.firstMatch.tap() + + // Verify workout detail appears + XCTAssertTrue(app.staticTexts["Start Workout"].waitForExistence(timeout: 3)) + + // Start workout + app.buttons["Start Workout"].tap() + + // Verify active workout screen + XCTAssertTrue(app.staticTexts["Current Exercise"].exists) + } +} +``` + +## Mobile MCP testing workflow + +1. **Boot simulator** + ```bash + mise run ios:sim:boot26 + ``` + +2. **Build and install** + ```bash + mise run ios:build:dev + ``` + +3. **Use MCP tools to interact** + - List elements: `mobile_list_elements_on_screen` + - Take screenshots: `mobile_take_screenshot` + - Tap: `mobile_tap_on_screen` + - Swipe: `mobile_swipe_on_screen` + - Type: `mobile_type_keys` + +4. **Document findings** + - Take screenshots of expected behavior + - Write UI tests for any bugs found + +## Accessibility identifiers + +Add identifiers for reliable UI test targeting: + +```swift +Text("Exercise Row") + .accessibilityIdentifier("exercise-timeline-row") + +Button("Start Workout") { + startWorkout() +} +.accessibilityIdentifier("start-workout-button") +``` + +## Test file organization + +``` +Tests/BetterFitTests/ +├── ModelTests.swift # Unit tests for models +├── EquipmentSwapTests.swift # Unit tests for feature managers +├── IntegrationTests.swift # Cross-feature flow tests +└── ... + +Apps/iOS/BetterFitAppUITests/ +├── WorkoutFlowUITests.swift # UI tests for workout features +├── SearchUITests.swift # UI tests for search +└── ... # Add new UI test files per feature +``` + +## Best practices + +- Use `DEMO_MODE` launch argument for consistent test data +- Keep unit tests fast — mock dependencies, avoid real network +- Use `waitForExistence(timeout:)` in UI tests for async content +- Run `mise run test` before committing to catch regressions +- Run `mise run ios:test:ui` after UI changes +- Add accessibility identifiers to custom components +- For Mobile MCP: take screenshots to document issues +- Write UI tests for bugs found during MCP exploration + +## Quick decision flow + +``` +New feature? +├── Pure logic (model, helper)? → Unit test +├── Cross-feature flow? → Integration test +├── User-facing behavior? → UI test +└── Visual/debug issue? → Mobile MCP exploratory test +``` diff --git a/.claude/skills/wide-events/SKILL.md b/.claude/skills/wide-events/SKILL.md new file mode 100644 index 0000000..40a81fd --- /dev/null +++ b/.claude/skills/wide-events/SKILL.md @@ -0,0 +1,213 @@ +--- +name: wide-events +description: Implement wide event (canonical log line) telemetry for the BetterFit app. Use when adding logging, observability, or telemetry to track user actions, errors, or performance. +--- + +# BetterFit Wide Event Logging Skill + +Use this skill when adding logging, observability, or telemetry to the BetterFit app. + +## When to activate this skill + +- User wants to add logging for debugging +- User wants to track user actions or feature usage +- User needs to monitor performance or errors +- User asks about analytics, telemetry, or observability +- User wants to understand app behavior in production +- Adding new features that need monitoring + +## Philosophy + +**Emit one comprehensive log event per request/operation** with full context (user, business, infrastructure, performance) rather than scattering multiple log statements. + +This enables effective debugging at scale — one query finds everything about an operation. + +## Wide event structure + +A wide event contains all dimensions of an operation in a single structured log line: + +```swift +struct WorkoutCompletedEvent: WideEvent { + // User context + let userId: String + let deviceId: String + + // Business context + let workoutId: String + let workoutName: String + let exercisesCompleted: Int + let totalSets: Int + let totalVolume: Double + let durationSeconds: Int + + // Infrastructure context + let appVersion: String + let osVersion: String + let deviceModel: String + + // Performance context + let startTime: Date + let endTime: Date + let networkRequests: Int + let cacheHits: Int +} +``` + +## Implementation pattern + +### 1. Define the event + +```swift +// Sources/BetterFit/Services/Logging/WideEvent.swift +protocol WideEvent: Encodable { + static var eventName: String { get } + var timestamp: Date { get } +} + +extension WideEvent { + func emit() { + let logger = Logger(subsystem: "dev.echohello.betterfit", category: Self.eventName) + + do { + let data = try JSONEncoder().encode(self) + if let json = String(data: data, encoding: .utf8) { + logger.info("\(json, privacy: .public)") + } + } catch { + logger.error("Failed to encode event: \(error.localizedDescription)") + } + } +} +``` + +### 2. Emit at operation boundaries + +```swift +// In BetterFit facade +func completeWorkout(_ workout: Workout) { + let startTime = Date() + + // ... business logic ... + + let event = WorkoutCompletedEvent( + userId: currentUser.id, + deviceId: deviceIdentifier, + workoutId: workout.id, + workoutName: workout.name, + exercisesCompleted: workout.exercises.count, + totalSets: workout.totalSets, + totalVolume: workout.totalVolume, + durationSeconds: Int(Date().timeIntervalSince(startTime)), + appVersion: appVersion, + osVersion: UIDevice.current.systemVersion, + deviceModel: UIDevice.current.model, + startTime: startTime, + endTime: Date(), + networkRequests: networkLayer.requestCount, + cacheHits: cacheLayer.hitCount + ) + + event.emit() +} +``` + +### 3. What NOT to do + +❌ **Don't scatter logs:** +```swift +// BAD: Multiple log statements for one operation +logger.info("Starting workout: \(workout.name)") +logger.info("User: \(userId)") +logger.info("Completed exercise: \(exercise.name)") +logger.info("Total sets: \(sets)") +logger.info("Workout complete") +``` + +✅ **Do emit one wide event:** +```swift +// GOOD: Single comprehensive event +WorkoutCompletedEvent(...).emit() +``` + +## Event categories + +### User action events +Track what users do: +```swift +struct WorkoutStartedEvent: WideEvent { ... } +struct ExerciseCompletedEvent: WideEvent { ... } +struct PlanChangedEvent: WideEvent { ... } +struct SearchPerformedEvent: WideEvent { ... } +``` + +### Performance events +Track timing and resources: +```swift +struct ApiRequestEvent: WideEvent { + let endpoint: String + let durationMs: Int + let statusCode: Int + let bytesTransferred: Int + let cacheHit: Bool +} + +struct ScreenLoadEvent: WideEvent { + let screenName: String + let durationMs: Int + let fromCache: Bool +} +``` + +### Error events +Track failures with context: +```swift +struct ErrorEvent: WideEvent { + let errorType: String + let errorMessage: String + let stackTrace: String? + let recoveryAction: String? + let userImpact: String +} +``` + +## Privacy considerations + +- Use `privacy: .private` for sensitive data (user IDs, health data) +- Use `privacy: .public` for non-sensitive structured data +- Consider local differential privacy for aggregate metrics +- Anonymize user identifiers in analytics pipelines + +## Testing events + +Verify events are emitted correctly: + +```swift +func testWorkoutCompletionEmitsEvent() { + // Given + let expectation = expectation(description: "Event emitted") + var capturedEvent: WorkoutCompletedEvent? + + EventLogger.shared.onEvent = { event in + capturedEvent = event as? WorkoutCompletedEvent + expectation.fulfill() + } + + // When + betterFit.completeWorkout(mockWorkout) + + // Then + wait(for: [expectation], timeout: 1) + XCTAssertEqual(capturedEvent?.workoutName, "Push Day") + XCTAssertEqual(capturedEvent?.exercisesCompleted, 4) +} +``` + +## Quick checklist + +- [ ] One event per operation (not scattered logs) +- [ ] Include user, business, infrastructure, and performance context +- [ ] Emit at operation boundaries (start/end or completion) +- [ ] Use structured encodable types +- [ ] Consider privacy for sensitive fields +- [ ] Test event emission in unit tests +- [ ] Don't over-log: focus on actionable events diff --git a/.gitignore b/.gitignore index 0d87b01..d4b31db 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,10 @@ supabase/.temp/ *.swp *.swo *~ +*.bak +*.orig +*.rej +*.tmp .tmp/ tmp/ artifacts/ diff --git a/Apps/iOS/BetterFitApp/AppTheme.swift b/Apps/iOS/BetterFitApp/AppTheme.swift index c4c67d0..a36e379 100644 --- a/Apps/iOS/BetterFitApp/AppTheme.swift +++ b/Apps/iOS/BetterFitApp/AppTheme.swift @@ -34,284 +34,77 @@ extension AppearancePreference { } // MARK: - AppTheme +// Yellow density variants only. No secondary brand hues. enum AppTheme: String, CaseIterable, Identifiable { + /// Balanced yellow — default product look. case fitbod + /// More yellow: headers can take the slab. case bold + /// Quieter yellow: accent lives on the docked action only. case classic - case midnight - case forest - case sunset var id: String { rawValue } var displayName: String { switch self { - case .fitbod: return "Fitbod" + case .fitbod: return "Balanced" case .bold: return "Bold" - case .classic: return "Classic" - case .midnight: return "Midnight" - case .forest: return "Forest" - case .sunset: return "Sunset" + case .classic: return "Restrained" } } - // MARK: Accent (same in light + dark) + /// Whether this theme spends yellow on full-bleed headers. + var prefersYellowHeaders: Bool { + self == .bold + } - var accent: Color { - switch self { - case .fitbod: return BFColors.brandAccent - case .bold: return Color(red: 1.00, green: 0.84, blue: 0.00) - case .classic: return Color(red: 0.0, green: 0.48, blue: 1.00) - case .midnight: return Color(red: 0.62, green: 0.45, blue: 1.0) - case .forest: return Color(red: 0.20, green: 0.78, blue: 0.47) - case .sunset: return Color(red: 1.0, green: 0.45, blue: 0.34) - } + /// Whether the docked primary action should stay quiet (header took the yellow). + var quietPrimaryAction: Bool { + prefersYellowHeaders } - // MARK: Semantic Color Tokens (light + dark) + // MARK: Accent (always yellow) + + var accent: Color { BFColors.accent } + + var accentInk: Color { BFColors.accentInk } + + // MARK: Semantic Color Tokens - /// Primary text — strongest contrast against the background. func textPrimary(for scheme: ColorScheme) -> Color { - switch self { - case .fitbod: - return BFColors.textPrimary(for: scheme) - case .bold: - return scheme == .dark - ? .white - : Color(red: 0.08, green: 0.08, blue: 0.08) - case .classic: - return scheme == .dark - ? .white - : Color(red: 0.07, green: 0.07, blue: 0.07) - case .midnight: - return scheme == .dark - ? .white - : Color(red: 0.10, green: 0.10, blue: 0.20) - case .forest: - return scheme == .dark - ? .white - : Color(red: 0.05, green: 0.15, blue: 0.10) - case .sunset: - return scheme == .dark - ? .white - : Color(red: 0.18, green: 0.10, blue: 0.08) - } + BFColors.textPrimary(for: scheme) } - /// Secondary text — labels, captions, supporting copy. - /// Uses a hand-picked contrast pair (not SwiftUI's .secondary) to guarantee readability. func textSecondary(for scheme: ColorScheme) -> Color { - switch self { - case .fitbod: - return BFColors.textSecondary(for: scheme) - case .bold: - return scheme == .dark - ? Color(white: 0.78) - : Color(red: 0.32, green: 0.32, blue: 0.32) - case .classic: - return scheme == .dark - ? Color(white: 0.75) - : Color(red: 0.30, green: 0.30, blue: 0.30) - case .midnight: - return scheme == .dark - ? Color(white: 0.80) - : Color(red: 0.32, green: 0.32, blue: 0.42) - case .forest: - return scheme == .dark - ? Color(white: 0.78) - : Color(red: 0.25, green: 0.38, blue: 0.30) - case .sunset: - return scheme == .dark - ? Color(white: 0.80) - : Color(red: 0.42, green: 0.30, blue: 0.28) - } + BFColors.textSecondary(for: scheme) } - /// Tertiary text — hints, placeholders, dimmed metadata. func textTertiary(for scheme: ColorScheme) -> Color { - switch self { - case .fitbod: - return BFColors.textTertiary(for: scheme) - case .bold: - return scheme == .dark - ? Color(white: 0.60) - : Color(red: 0.50, green: 0.50, blue: 0.50) - case .classic: - return scheme == .dark - ? Color(white: 0.58) - : Color(red: 0.48, green: 0.48, blue: 0.48) - case .midnight: - return scheme == .dark - ? Color(white: 0.62) - : Color(red: 0.50, green: 0.50, blue: 0.60) - case .forest: - return scheme == .dark - ? Color(white: 0.60) - : Color(red: 0.45, green: 0.55, blue: 0.48) - case .sunset: - return scheme == .dark - ? Color(white: 0.62) - : Color(red: 0.55, green: 0.45, blue: 0.42) - } + BFColors.textTertiary(for: scheme) } - /// Surface/card background fill (semi-transparent so gradient bleeds through). - /// Returns a `Color` (not `ShapeStyle`) so call sites can chain `.opacity()`. func cardBackground(for scheme: ColorScheme) -> Color { - switch self { - case .fitbod: - return BFColors.surface(for: scheme) - case .bold: - return scheme == .dark - ? Color.white.opacity(0.06) - : Color.white.opacity(0.45) - case .classic: - return scheme == .dark - ? Color.white.opacity(0.05) - : Color.white.opacity(0.55) - case .midnight, .forest, .sunset: - return scheme == .dark - ? Color.white.opacity(0.06) - : Color.white.opacity(0.55) - } + BFColors.surface(for: scheme) } - /// Card border / divider line. func cardStroke(for scheme: ColorScheme) -> Color { - switch self { - case .fitbod: - return BFColors.border(for: scheme) - case .bold: - return scheme == .dark - ? Color.white.opacity(0.10) - : Color.black.opacity(0.10) - case .classic: - return scheme == .dark - ? Color.white.opacity(0.10) - : Color.black.opacity(0.08) - case .midnight: - return scheme == .dark - ? Color.white.opacity(0.10) - : Color.black.opacity(0.10) - case .forest: - return scheme == .dark - ? Color.white.opacity(0.10) - : Color.black.opacity(0.10) - case .sunset: - return scheme == .dark - ? Color.white.opacity(0.10) - : Color.black.opacity(0.10) - } + BFColors.border(for: scheme) } - /// Solid background (used as the base under gradients in some places). func backgroundBase(for scheme: ColorScheme) -> Color { - switch self { - case .fitbod: - return BFColors.background(for: scheme) - case .bold: - return scheme == .dark - ? Color(red: 0.04, green: 0.04, blue: 0.04) - : Color(red: 0.96, green: 0.96, blue: 0.94) - case .classic: - return scheme == .dark - ? Color.black - : Color(red: 0.98, green: 0.98, blue: 1.00) - case .midnight: - return scheme == .dark - ? Color(red: 0.05, green: 0.06, blue: 0.10) - : Color(red: 0.94, green: 0.94, blue: 0.98) - case .forest: - return scheme == .dark - ? Color(red: 0.04, green: 0.09, blue: 0.07) - : Color(red: 0.94, green: 0.97, blue: 0.94) - case .sunset: - return scheme == .dark - ? Color(red: 0.10, green: 0.06, blue: 0.07) - : Color(red: 0.98, green: 0.94, blue: 0.92) - } + BFColors.background(for: scheme) } - /// Page-level background gradient (resolved against the current color scheme). func backgroundGradient(for scheme: ColorScheme) -> LinearGradient { - // Flat backgrounds — Fitbod-style pages are solid, not gradient. - if self == .fitbod { - let flat = BFColors.background(for: scheme) - return LinearGradient(colors: [flat, flat], startPoint: .top, endPoint: .bottom) - } - - let colors: [Color] - switch self { - case .fitbod: - colors = [BFColors.background(for: scheme), BFColors.background(for: scheme)] - case .bold: - if scheme == .dark { - colors = [Color.black, Color(red: 0.06, green: 0.06, blue: 0.06)] - } else { - // Neutral warm white with a hint of yellow — keeps the Bold feel without overpowering text. - colors = [ - Color(red: 0.97, green: 0.96, blue: 0.93), - Color(red: 0.94, green: 0.93, blue: 0.89), - ] - } - case .classic: - if scheme == .dark { - colors = [Color(red: 0.07, green: 0.07, blue: 0.08), Color(red: 0.13, green: 0.13, blue: 0.15)] - } else { - colors = [Color(red: 0.98, green: 0.98, blue: 1.00), Color(red: 0.93, green: 0.95, blue: 0.99)] - } - case .midnight: - if scheme == .dark { - colors = [ - Color(red: 0.05, green: 0.06, blue: 0.10), - Color(red: 0.10, green: 0.07, blue: 0.18), - ] - } else { - colors = [ - Color(red: 0.95, green: 0.95, blue: 1.00), - Color(red: 0.90, green: 0.88, blue: 0.98), - ] - } - case .forest: - if scheme == .dark { - colors = [ - Color(red: 0.04, green: 0.09, blue: 0.07), - Color(red: 0.06, green: 0.13, blue: 0.09), - ] - } else { - colors = [ - Color(red: 0.94, green: 0.98, blue: 0.94), - Color(red: 0.85, green: 0.94, blue: 0.86), - ] - } - case .sunset: - if scheme == .dark { - colors = [ - Color(red: 0.10, green: 0.06, blue: 0.07), - Color(red: 0.16, green: 0.08, blue: 0.08), - ] - } else { - colors = [ - Color(red: 0.99, green: 0.95, blue: 0.92), - Color(red: 0.98, green: 0.88, blue: 0.84), - ] - } - } - return LinearGradient( - colors: colors, - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + let flat = backgroundBase(for: scheme) + return LinearGradient(colors: [flat, flat], startPoint: .top, endPoint: .bottom) } - /// Accent color tinted for use as a soft surface fill. - /// In light mode the opacity is boosted so the tint reads against the bright background. func accentSurface(_ opacity: Double, for scheme: ColorScheme) -> Color { accent.opacity(accentSurfaceOpacity(opacity, isDark: scheme == .dark)) } - /// Shadow func shadowColor(for scheme: ColorScheme) -> Color { scheme == .dark ? Color.black.opacity(0.30) @@ -329,6 +122,11 @@ extension AppTheme { static let storageKey = "betterfit.appTheme" static let defaultTheme: AppTheme = .fitbod + /// Themes shown in the picker (yellow density). + static var selectableThemes: [AppTheme] { + [.fitbod, .bold, .classic] + } + static func fromStorage(_ rawValue: String?) -> AppTheme { guard let rawValue, let theme = AppTheme(rawValue: rawValue) else { return defaultTheme @@ -394,22 +192,18 @@ extension View { font(theme.italicFont(size: size, relativeTo: textStyle)) } - /// Primary text color (strongest contrast against the background). func bfTextPrimary(theme: AppTheme) -> some View { modifier(BFTextPrimaryModifier(theme: theme)) } - /// Secondary text color (labels, captions). func bfTextSecondary(theme: AppTheme) -> some View { modifier(BFTextSecondaryModifier(theme: theme)) } - /// Tertiary text color (hints, placeholders). func bfTextTertiary(theme: AppTheme) -> some View { modifier(BFTextTertiaryModifier(theme: theme)) } - /// Page-level background that resolves the theme gradient against the current color scheme. func bfBackground(theme: AppTheme) -> some View { modifier(BFBackgroundModifier(theme: theme)) } @@ -450,3 +244,9 @@ private struct BFBackgroundModifier: ViewModifier { content.background(theme.backgroundGradient(for: scheme).ignoresSafeArea()) } } + +// MARK: - Helpers + +private func accentSurfaceOpacity(_ base: Double, isDark: Bool) -> Double { + isDark ? base : min(1.0, base * 1.4) +} diff --git a/Apps/iOS/BetterFitApp/Assets.xcassets/IconCombo.imageset/icon-combo.png b/Apps/iOS/BetterFitApp/Assets.xcassets/IconCombo.imageset/icon-combo.png new file mode 100644 index 0000000..59c5085 Binary files /dev/null and b/Apps/iOS/BetterFitApp/Assets.xcassets/IconCombo.imageset/icon-combo.png differ diff --git a/Apps/iOS/BetterFitApp/Assets.xcassets/IconLettermark.imageset/icon-lettermark.png b/Apps/iOS/BetterFitApp/Assets.xcassets/IconLettermark.imageset/icon-lettermark.png new file mode 100644 index 0000000..9af7c5a Binary files /dev/null and b/Apps/iOS/BetterFitApp/Assets.xcassets/IconLettermark.imageset/icon-lettermark.png differ diff --git a/Apps/iOS/BetterFitApp/Assets.xcassets/Logo.imageset/Logo.png b/Apps/iOS/BetterFitApp/Assets.xcassets/Logo.imageset/Logo.png index 468d21d..72e7e60 100644 Binary files a/Apps/iOS/BetterFitApp/Assets.xcassets/Logo.imageset/Logo.png and b/Apps/iOS/BetterFitApp/Assets.xcassets/Logo.imageset/Logo.png differ diff --git a/Apps/iOS/BetterFitApp/Assets.xcassets/Logo.imageset/Logo@2x.png b/Apps/iOS/BetterFitApp/Assets.xcassets/Logo.imageset/Logo@2x.png index 0ee34bc..72e7e60 100644 Binary files a/Apps/iOS/BetterFitApp/Assets.xcassets/Logo.imageset/Logo@2x.png and b/Apps/iOS/BetterFitApp/Assets.xcassets/Logo.imageset/Logo@2x.png differ diff --git a/Apps/iOS/BetterFitApp/Assets.xcassets/LogoStacked.imageset/logo-stacked.png b/Apps/iOS/BetterFitApp/Assets.xcassets/LogoStacked.imageset/logo-stacked.png new file mode 100644 index 0000000..f2d8372 Binary files /dev/null and b/Apps/iOS/BetterFitApp/Assets.xcassets/LogoStacked.imageset/logo-stacked.png differ diff --git a/Apps/iOS/BetterFitApp/Assets.xcassets/LogoStackedDark.imageset/logo-stacked-dark.png b/Apps/iOS/BetterFitApp/Assets.xcassets/LogoStackedDark.imageset/logo-stacked-dark.png new file mode 100644 index 0000000..f6a12b1 Binary files /dev/null and b/Apps/iOS/BetterFitApp/Assets.xcassets/LogoStackedDark.imageset/logo-stacked-dark.png differ diff --git a/Apps/iOS/BetterFitApp/Assets.xcassets/app-icon-1024.png b/Apps/iOS/BetterFitApp/Assets.xcassets/app-icon-1024.png new file mode 100644 index 0000000..c31db1f Binary files /dev/null and b/Apps/iOS/BetterFitApp/Assets.xcassets/app-icon-1024.png differ diff --git a/Apps/iOS/BetterFitApp/Components/AddExerciseSheet.swift b/Apps/iOS/BetterFitApp/Components/AddExerciseSheet.swift index 6e51808..bcb432b 100644 --- a/Apps/iOS/BetterFitApp/Components/AddExerciseSheet.swift +++ b/Apps/iOS/BetterFitApp/Components/AddExerciseSheet.swift @@ -384,7 +384,7 @@ private extension View { // MARK: - Preview #Preview { - AddExerciseSheet(theme: .midnight) { exercise in + AddExerciseSheet(theme: .fitbod) { exercise in print("Added: \(exercise.name)") } } diff --git a/Apps/iOS/BetterFitApp/Components/AdjustSetsSheet.swift b/Apps/iOS/BetterFitApp/Components/AdjustSetsSheet.swift index 8c1b172..9d4f492 100644 --- a/Apps/iOS/BetterFitApp/Components/AdjustSetsSheet.swift +++ b/Apps/iOS/BetterFitApp/Components/AdjustSetsSheet.swift @@ -26,35 +26,55 @@ struct AdjustSetsSheet: View { var body: some View { NavigationStack { - ZStack { + ZStack(alignment: .bottom) { BFColors.backgroundElevated(for: colorScheme).ignoresSafeArea() - VStack(spacing: 24) { - // Exercise name header - Text(exercise.name) - .font(.title2.weight(.bold)) - .frame(maxWidth: .infinity, alignment: .leading) - - // Sets stepper - setsSection - - // Reps input - repsSection - - // Weight input - weightSection - - Spacer() - - // Save button - Button { - save() - } label: { - Text("Save Changes") + ScrollView { + VStack(spacing: 20) { + Text(exercise.name) + .font(.title2.weight(.bold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + + VStack(alignment: .leading, spacing: 8) { + Text("Reps") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + + TextField("e.g., 8-12", text: $reps) + .font(.title3.weight(.semibold)) + .padding() + .background(inputBackground) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Target Weight") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + + TextField("e.g., 135 lbs", text: $targetWeight) + .font(.title3.weight(.semibold)) + .padding() + .background(inputBackground) + } } - .buttonStyle(.bfPrimary) + .padding(.horizontal) + .padding(.bottom, 120) // clear save button + } + + Button { + save() + } label: { + Text("Save Changes") + .font(BFTypography.headline) + .foregroundStyle(BFColors.accentInk) + .frame(maxWidth: .infinity) + .frame(height: BFControlSize.buttonLarge) + .background(Capsule(style: .continuous).fill(BFColors.accent)) } - .padding() + .buttonStyle(.plain) + .padding(.horizontal, 20) + .padding(.bottom, 24) } .navigationTitle("Adjust Sets") .navigationBarTitleDisplayMode(.inline) @@ -62,74 +82,62 @@ struct AdjustSetsSheet: View { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } - } - } - } - - // MARK: - Sets Section - - private var setsSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Sets") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - - HStack(spacing: 16) { - Button { - if sets > 1 { sets -= 1 } - } label: { - Image(systemName: "minus.circle.fill") - .font(.title) - .foregroundStyle(sets > 1 ? theme.accent : BFColors.border(for: colorScheme)) + ToolbarItem(placement: .principal) { + HStack(spacing: 10) { + Text("\(sets)") + .font(.system(size: 17, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Text("sets") + .font(.system(size: 13)) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + } } - .disabled(sets <= 1) - - Text("\(sets)") - .font(BFTypography.statLarge) - .frame(width: 50) - - Button { - if sets < 10 { sets += 1 } - } label: { - Image(systemName: "plus.circle.fill") - .font(.title) - .foregroundStyle(sets < 10 ? theme.accent : BFColors.border(for: colorScheme)) + ToolbarItem(placement: .primaryAction) { + Button { + save() + } label: { + Text("Save") + .fontWeight(.semibold) + } } - .disabled(sets >= 10) } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(inputBackground) - } - } - - // MARK: - Reps Section - - private var repsSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Reps") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - - TextField("e.g., 8-12", text: $reps) - .font(.title3.weight(.semibold)) - .padding() - .background(inputBackground) - } - } - - // MARK: - Weight Section - - private var weightSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Target Weight") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + .safeAreaInset(edge: .bottom) { + HStack(spacing: 10) { + Button { + if sets > 1 { sets -= 1 } + } label: { + Label("Remove set", systemImage: "minus.circle.fill") + .font(.title3) + .foregroundStyle(sets > 1 ? BFColors.textPrimary(for: colorScheme) : BFColors.textTertiary(for: colorScheme)) + .frame(maxWidth: .infinity) + .frame(height: BFControlSize.buttonMedium) + .background( + Capsule() + .fill(BFColors.surfaceRaised(for: colorScheme)) + ) + } + .buttonStyle(.plain) + .disabled(sets <= 1) - TextField("e.g., 135 lbs", text: $targetWeight) - .font(.title3.weight(.semibold)) - .padding() - .background(inputBackground) + Button { + if sets < 10 { sets += 1 } + } label: { + Label("Add set", systemImage: "plus.circle.fill") + .font(.title3) + .foregroundStyle(BFColors.accentInk) + .frame(maxWidth: .infinity) + .frame(height: BFControlSize.buttonMedium) + .background(Capsule().fill(BFColors.accent)) + } + .buttonStyle(.plain) + .disabled(sets >= 10) + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 8) + .background(BFColors.backgroundElevated(for: colorScheme)) + } } } @@ -141,8 +149,6 @@ struct AdjustSetsSheet: View { .stroke(BFColors.border(for: colorScheme), lineWidth: 1) } - // MARK: - Actions - private func save() { let updated = PlannedExercise( id: exercise.id, @@ -160,7 +166,7 @@ struct AdjustSetsSheet: View { #Preview { AdjustSetsSheet( - theme: .forest, + theme: .fitbod, exercise: PlannedExercise( name: "Bench Press", category: .push, @@ -170,4 +176,4 @@ struct AdjustSetsSheet: View { muscleGroups: ["Chest", "Triceps"] ) ) { _ in } -} +} \ No newline at end of file diff --git a/Apps/iOS/BetterFitApp/Components/ExerciseDetailSheet+Sections.swift b/Apps/iOS/BetterFitApp/Components/ExerciseDetailSheet+Sections.swift new file mode 100644 index 0000000..d0c84dd --- /dev/null +++ b/Apps/iOS/BetterFitApp/Components/ExerciseDetailSheet+Sections.swift @@ -0,0 +1,103 @@ +import BetterFit +import SwiftUI + +// MARK: - Exercise Detail Sheet — header, quick actions & style + +extension ExerciseDetailSheet { + + // MARK: - Header + + var exerciseHeader: some View { + VStack(alignment: .leading, spacing: 8) { + Text(exercise.displayName) + .bfHeading(theme: theme, size: 28, relativeTo: .title) + + HStack(spacing: 12) { + Label(exercise.displayCategory.rawValue, systemImage: categoryIcon) + .font(.subheadline.weight(.medium)) + .foregroundStyle(categoryColor) + + if !exercise.muscleGroups.isEmpty { + Text("•").foregroundStyle(BFColors.textSecondary(for: colorScheme)) + Text(exercise.muscleGroups.joined(separator: ", ")) + .font(.subheadline) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + } + } + } + } + + // MARK: - Quick actions + + var quickActionButtons: some View { + HStack(spacing: 12) { + quickActionButton(icon: "arrow.triangle.2.circlepath", label: "Replace", color: .orange) { + dismiss(); onReplace() + } + quickActionButton(icon: "link", label: "Superset", color: .purple) { + dismiss(); onSuperset() + } + quickActionButton(icon: "trash", label: "Delete", color: .red) { + dismiss(); onDelete() + } + } + } + + func quickActionButton(icon: String, label: String, color: Color, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 6) { + ZStack { + Circle() + .fill(color.opacity(0.15)) + .frame(width: 48, height: 48) + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(color) + } + Text(label) + .font(.caption2.weight(.medium)) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } + + // MARK: - Style + + var gradientColors: [Color] { + switch exercise.displayCategory { + case .push: return [.blue, .cyan] + case .pull: return [.purple, .pink] + case .legs: return [.orange, .yellow] + case .core: return [.yellow, .orange] + case .cardio: return [.red, .orange] + case .compound: return [.green, .teal] + case .all: return [.gray, .secondary] + } + } + + var categoryIcon: String { + switch exercise.displayCategory { + case .push: return "arrow.up.circle" + case .pull: return "arrow.down.circle" + case .legs: return "figure.walk" + case .core: return "circle.hexagongrid" + case .cardio: return "heart" + case .compound: return "dumbbell" + case .all: return "figure.mixed.cardio" + } + } + + var categoryColor: Color { + switch exercise.displayCategory { + case .push: return .blue + case .pull: return .purple + case .legs: return .orange + case .core: return .yellow + case .cardio: return .red + case .compound: return theme.accent + case .all: return .gray + } + } +} diff --git a/Apps/iOS/BetterFitApp/Components/ExerciseDetailSheet.swift b/Apps/iOS/BetterFitApp/Components/ExerciseDetailSheet.swift index 9e692c8..6c1a437 100644 --- a/Apps/iOS/BetterFitApp/Components/ExerciseDetailSheet.swift +++ b/Apps/iOS/BetterFitApp/Components/ExerciseDetailSheet.swift @@ -1,7 +1,13 @@ import BetterFit import SwiftUI +#if canImport(UIKit) + import UIKit +#endif // MARK: - Exercise Detail Sheet +// +// Plan-mode editor for a single exercise. Lists each set as its own row with +// editable kg + reps. Users can add or remove set rows from the bottom. struct ExerciseDetailSheet: View { @Environment(\.colorScheme) var colorScheme @@ -15,11 +21,18 @@ struct ExerciseDetailSheet: View { @AppStorage(WeightUnitSetting.storageKey) private var weightUnit: String = WeightUnitSetting.lbs .rawValue - @State private var sets: Int - @State private var reps: String - @State private var weight: Double + @State private var setRows: [PlannedSet] = [] + /// Draft text for the in-progress field (so empty reps don't auto-become 0). + @State private var rowLoadDraft: [Int: String] = [:] + @State private var rowRepsDraft: [Int: String] = [:] + @FocusState private var focusedRow: RowField? - @Environment(\.dismiss) private var dismiss + private enum RowField: Hashable { + case load(Int) + case reps(Int) + } + + @Environment(\.dismiss) var dismiss init( exercise: PlannedExercise, @@ -35,17 +48,7 @@ struct ExerciseDetailSheet: View { self.onReplace = onReplace self.onSuperset = onSuperset self.onUpdate = onUpdate - - // Initialize state from exercise - _sets = State(initialValue: exercise.sets) - _reps = State(initialValue: exercise.reps) - - // Parse weight from string - let weightValue = - Double( - exercise.targetWeight?.components(separatedBy: CharacterSet.decimalDigits.inverted) - .first ?? "0") ?? 0 - _weight = State(initialValue: weightValue) + _setRows = State(initialValue: exercise.resolvedSets()) } private var currentUnit: WeightUnitSetting { @@ -56,28 +59,25 @@ struct ExerciseDetailSheet: View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 20) { - // Video placeholder - videoPlaceholder + DemoVideoPlayer( + url: DemoVideoLibrary.videoURL(for: exercise.displayName), + height: 200, + fallbackGradient: gradientColors + ) + .overlay(alignment: .topTrailing) { + Text("AUTO-PLAY") + .font(.caption2.weight(.bold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(BFColors.surfaceRaised(for: colorScheme))) + .padding(12) + } - // Exercise title and category exerciseHeader - - // Quick action buttons quickActionButtons - - Divider() - .padding(.vertical, 8) - - // Sets adjustment - setsAdjustmentSection - - Divider() - .padding(.vertical, 8) - - // Weight unit toggle - weightUnitSection - - Spacer(minLength: 40) + Divider().padding(.vertical, 4) + setsEditor + Color.clear.frame(height: 80) } .padding(20) } @@ -86,344 +86,270 @@ struct ExerciseDetailSheet: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } + Button("Cancel") { dismiss() } } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - saveChanges() - } - .fontWeight(.semibold) + ToolbarItem(placement: .principal) { + Text("\(setRows.count) sets") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) } - } - } - } - - // MARK: - Video Placeholder - - private var videoPlaceholder: some View { - DemoVideoPlayer( - url: DemoVideoLibrary.videoURL(for: exercise.displayName), - height: 200, - fallbackGradient: gradientColors - ) - .overlay(alignment: .topTrailing) { - Text("AUTO-PLAY") - .font(.caption2.weight(.bold)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Capsule().fill(BFColors.surfaceRaised(for: colorScheme))) - .padding(12) - } - } - - // MARK: - Exercise Header - - private var exerciseHeader: some View { - VStack(alignment: .leading, spacing: 8) { - Text(exercise.displayName) - .bfHeading(theme: theme, size: 28, relativeTo: .title) - - HStack(spacing: 12) { - Label(exercise.displayCategory.rawValue, systemImage: categoryIcon) - .font(.subheadline.weight(.medium)) - .foregroundStyle(categoryColor) - - if !exercise.muscleGroups.isEmpty { - Text("•") - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - - Text(exercise.muscleGroups.joined(separator: ", ")) - .font(.subheadline) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + ToolbarItem(placement: .primaryAction) { + Button("Save") { saveChanges() }.fontWeight(.semibold) } } - } - } - - // MARK: - Quick Action Buttons - - private var quickActionButtons: some View { - HStack(spacing: 12) { - quickActionButton( - icon: "clock.arrow.circlepath", - label: "History", - color: .blue - ) { - // Show history - } - - quickActionButton( - icon: "arrow.triangle.2.circlepath", - label: "Replace", - color: .orange - ) { - dismiss() - onReplace() - } - - quickActionButton( - icon: "link", - label: "Superset", - color: .purple - ) { - dismiss() - onSuperset() - } - - quickActionButton( - icon: "trash", - label: "Delete", - color: .red - ) { - dismiss() - onDelete() - } - } - } + .safeAreaInset(edge: .bottom) { + HStack(spacing: 10) { + Button { + removeSet() + } label: { + Label("Remove set", systemImage: "minus.circle.fill") + .font(.title3) + .foregroundStyle(setRows.count > 1 ? BFColors.textPrimary(for: colorScheme) : BFColors.textTertiary(for: colorScheme)) + .frame(maxWidth: .infinity) + .frame(height: BFControlSize.buttonMedium) + .background(Capsule().fill(BFColors.surfaceRaised(for: colorScheme))) + } + .buttonStyle(.plain) + .disabled(setRows.count <= 1) - private func quickActionButton( - icon: String, - label: String, - color: Color, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - VStack(spacing: 6) { - ZStack { - Circle() - .fill(color.opacity(0.15)) - .frame(width: 48, height: 48) - - Image(systemName: icon) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(color) + Button { + addSet() + } label: { + Label("Add set", systemImage: "plus.circle.fill") + .font(.title3) + .foregroundStyle(BFColors.accentInk) + .frame(maxWidth: .infinity) + .frame(height: BFControlSize.buttonMedium) + .background(Capsule().fill(BFColors.accent)) + } + .buttonStyle(.plain) + .disabled(setRows.count >= 10) } - - Text(label) - .font(.caption2.weight(.medium)) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 8) + .background(BFColors.backgroundElevated(for: colorScheme)) } - .frame(maxWidth: .infinity) } - .buttonStyle(.plain) } - // MARK: - Sets Adjustment Section + // MARK: - Weight unit + // Moved to global Settings. - private var setsAdjustmentSection: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Adjust Sets") - .bfHeading(theme: theme, size: 18, relativeTo: .headline) + // MARK: - Per-set editor - // Sets stepper + private var setsEditor: some View { + VStack(alignment: .leading, spacing: 12) { HStack { Text("Sets") - .font(.subheadline.weight(.medium)) - + .font(.system(size: 18, weight: .bold, design: .rounded)) Spacer() + Text("Tap a value to edit") + .font(.caption) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + } - HStack(spacing: 12) { - Button { - if sets > 1 { sets -= 1 } - } label: { - Image(systemName: "minus.circle.fill") - .font(.title2) - .foregroundStyle(sets > 1 ? theme.accent : BFColors.textTertiary(for: colorScheme)) - } - .disabled(sets <= 1) - - Text("\(sets)") - .font(.title3.weight(.bold).monospacedDigit()) - .frame(minWidth: 30) + VStack(spacing: 0) { + // Header row + HStack(spacing: 8) { + Text("#") + .font(.caption.weight(.bold)) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + .frame(width: 24, alignment: .leading) + Text("KG") + .font(.caption.weight(.bold)) + .frame(maxWidth: .infinity) + Text("REPS") + .font(.caption.weight(.bold)) + .frame(maxWidth: .infinity) + Color.clear.frame(width: 28) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) - Button { - if sets < 10 { sets += 1 } - } label: { - Image(systemName: "plus.circle.fill") - .font(.title2) - .foregroundStyle(sets < 10 ? theme.accent : BFColors.textTertiary(for: colorScheme)) - } - .disabled(sets >= 10) + ForEach(Array(setRows.enumerated()), id: \.offset) { index, row in + setEditorRow(index: index, row: row) } } - .padding(16) - .background { + .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(BFColors.surface(for: colorScheme)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(BFColors.border(for: colorScheme), lineWidth: 1) - } + ) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(BFColors.border(for: colorScheme), lineWidth: 1) } + } + } - // Reps input - HStack { - Text("Reps") - .font(.subheadline.weight(.medium)) - - Spacer() - - TextField("8-12", text: $reps) - .font(.title3.weight(.bold).monospacedDigit()) + private func setEditorRow(index: Int, row: PlannedSet) -> some View { + HStack(spacing: 8) { + Text(String(format: "%02d", index + 1)) + .font(.system(size: 14, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + .frame(width: 24, alignment: .leading) + + // KG field + HStack(spacing: 2) { + TextField("0", text: loadDraftBinding(for: index, fallback: row.load)) + .keyboardType(.decimalPad) + .font(.system(size: 17, weight: .bold).monospacedDigit()) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) .multilineTextAlignment(.center) - .frame(width: 80) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(BFColors.surfaceRaised(for: colorScheme)) - .overlay { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(BFColors.border(for: colorScheme), lineWidth: 1) - } + .frame(maxWidth: .infinity) + .focused($focusedRow, equals: .load(index)) + .onSubmit { commitLoad(index) } + .onChange(of: focusedRow) { _, new in + if new != .load(index) { commitLoad(index) } } + Text("kg") + .font(.caption.weight(.semibold)) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) } - .padding(16) - .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(BFColors.surface(for: colorScheme)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(BFColors.border(for: colorScheme), lineWidth: 1) - } + .padding(.horizontal, 10) + .frame(height: 38) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(BFColors.surfaceRaised(for: colorScheme)) + ) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke( + focusedRow == .load(index) ? BFColors.accent : BFColors.border(for: colorScheme), + lineWidth: focusedRow == .load(index) ? 1.5 : 1 + ) } - // Weight input - HStack { - Text("Weight") - .font(.subheadline.weight(.medium)) - - Spacer() - - HStack(spacing: 8) { - Button { - let increment: Double = currentUnit == .lbs ? 5 : 2.5 - if weight >= increment { weight -= increment } - } label: { - Image(systemName: "minus.circle.fill") - .font(.title2) - .foregroundStyle(weight > 0 ? theme.accent : BFColors.textTertiary(for: colorScheme)) - } - .disabled(weight <= 0) - - Text(currentUnit.format(weight)) - .font(.title3.weight(.bold).monospacedDigit()) - .frame(minWidth: 70) - - Button { - let increment: Double = currentUnit == .lbs ? 5 : 2.5 - weight += increment - } label: { - Image(systemName: "plus.circle.fill") - .font(.title2) - .foregroundStyle(theme.accent) + // REPS field + HStack(spacing: 2) { + TextField("0", text: repsDraftBinding(for: index, fallback: row.reps)) + .keyboardType(.numberPad) + .font(.system(size: 17, weight: .bold).monospacedDigit()) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .focused($focusedRow, equals: .reps(index)) + .onSubmit { commitReps(index) } + .onChange(of: focusedRow) { _, new in + if new != .reps(index) { commitReps(index) } } - } + Text("reps") + .font(.caption.weight(.semibold)) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) } - .padding(16) - .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(BFColors.surface(for: colorScheme)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(BFColors.border(for: colorScheme), lineWidth: 1) - } + .padding(.horizontal, 10) + .frame(height: 38) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(BFColors.surfaceRaised(for: colorScheme)) + ) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke( + focusedRow == .reps(index) ? BFColors.accent : BFColors.border(for: colorScheme), + lineWidth: focusedRow == .reps(index) ? 1.5 : 1 + ) + } + + // Remove row button + Button { + setRows.remove(at: index) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) } + .buttonStyle(.plain) + .frame(width: 28) + .accessibilityLabel("Remove set \(index + 1)") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .overlay(alignment: .top) { + Rectangle() + .fill(BFColors.separator(for: colorScheme)) + .frame(height: 1) } } - // MARK: - Weight Unit Section + // MARK: - Mutations - private var weightUnitSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Weight Unit") - .bfHeading(theme: theme, size: 18, relativeTo: .headline) + private func addSet() { + let last = setRows.last ?? PlannedSet() + setRows.append(PlannedSet(load: last.load, reps: last.reps)) + } - Text("This setting applies globally to all exercises") - .font(.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + private func removeSet() { + guard setRows.count > 1 else { return } + setRows.removeLast() + } - HStack(spacing: 0) { - ForEach(WeightUnitSetting.allCases, id: \.self) { unit in - Button { - withAnimation(.spring(response: 0.2)) { - // Convert weight when switching units - if currentUnit != unit { - weight = unit.convert(weight, from: currentUnit) - } - weightUnit = unit.rawValue - } - } label: { - Text(unit.rawValue.uppercased()) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(currentUnit == unit ? .white : BFColors.textSecondary(for: colorScheme)) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background { - if currentUnit == unit { - Capsule() - .fill(theme.accent) - } - } - } - .buttonStyle(.plain) - } - } - .padding(4) - .background(Capsule().fill(BFColors.surfaceRaised(for: colorScheme))) - .overlay(Capsule().stroke(BFColors.border(for: colorScheme), lineWidth: 1)) + private func convertAllLoads(from: WeightUnitSetting, to: WeightUnitSetting) { + for index in setRows.indices { + setRows[index].load = to.convert(setRows[index].load, from: from) } } - // MARK: - Helpers - - private var gradientColors: [Color] { - switch exercise.displayCategory { - case .push: return [.blue, .cyan] - case .pull: return [.purple, .pink] - case .legs: return [.orange, .yellow] - case .core: return [.yellow, .orange] - case .cardio: return [.red, .orange] - case .compound: return [.green, .teal] - case .all: return [.gray, .secondary] - } + private func loadDraftBinding(for index: Int, fallback: Double) -> Binding { + Binding( + get: { rowLoadDraft[index] ?? (fallback > 0 ? formatWeight(fallback) : "") }, + set: { rowLoadDraft[index] = $0 } + ) } - private var categoryIcon: String { - switch exercise.displayCategory { - case .push: return "arrow.up.circle" - case .pull: return "arrow.down.circle" - case .legs: return "figure.walk" - case .core: return "circle.hexagongrid" - case .cardio: return "heart" - case .compound: return "dumbbell" - case .all: return "figure.mixed.cardio" - } + private func repsDraftBinding(for index: Int, fallback: Int) -> Binding { + Binding( + get: { rowRepsDraft[index] ?? "\(max(1, fallback))" }, + set: { rowRepsDraft[index] = $0 } + ) } - private var categoryColor: Color { - switch exercise.displayCategory { - case .push: return .blue - case .pull: return .purple - case .legs: return .orange - case .core: return .yellow - case .cardio: return .red - case .compound: return theme.accent - case .all: return .gray + private func commitLoad(_ index: Int) { + guard setRows.indices.contains(index) else { return } + let raw = rowLoadDraft[index] ?? "" + let cleaned = raw.replacingOccurrences(of: ",", with: ".").filter { $0.isNumber || $0 == "." } + guard !cleaned.isEmpty, cleaned != ".", let value = Double(cleaned), value > 0 else { + setRows[index].load = 0 + rowLoadDraft[index] = "" + return } + setRows[index].load = value + rowLoadDraft[index] = formatWeight(value) + } + + private func commitReps(_ index: Int) { + guard setRows.indices.contains(index) else { return } + let raw = rowRepsDraft[index] ?? "" + let digits = raw.filter { $0.isNumber } + let value = Int(digits.prefix(4)) ?? setRows[index].reps + let stored = max(1, value) + setRows[index].reps = stored + rowRepsDraft[index] = "\(stored)" } + private func formatWeight(_ value: Double) -> String { + value.truncatingRemainder(dividingBy: 1) == 0 + ? String(Int(value)) + : String(format: "%.1f", value) + } + + // MARK: - Save + private func saveChanges() { - let weightString = weight > 0 ? "\(Int(weight)) \(currentUnit.rawValue)" : nil + // Flush all in-progress drafts so nothing is lost on dismiss. + for index in 0.. 0 ? "\(formatWeight(weight)) \(currentUnit.rawValue)" : nil, + individualSets: setRows, muscleGroups: exercise.muscleGroups ) onUpdate(updated) @@ -441,10 +367,10 @@ struct ExerciseDetailSheet: View { targetWeight: "135 lbs", muscleGroups: ["Chest", "Triceps"] ), - theme: .forest, + theme: .fitbod, onDelete: {}, onReplace: {}, onSuperset: {}, onUpdate: { _ in } ) -} +} \ No newline at end of file diff --git a/Apps/iOS/BetterFitApp/Components/UnifiedExerciseTimeline.swift b/Apps/iOS/BetterFitApp/Components/UnifiedExerciseTimeline.swift index 40e9249..fbac11c 100644 --- a/Apps/iOS/BetterFitApp/Components/UnifiedExerciseTimeline.swift +++ b/Apps/iOS/BetterFitApp/Components/UnifiedExerciseTimeline.swift @@ -575,7 +575,7 @@ struct SupersetIndicator: View { targetWeight: "30 lbs", muscleGroups: ["Rear Delts"]), ], selectedIndex: 1, - theme: .forest, + theme: .fitbod, onSelect: { _ in }, onDelete: { _ in }, onReplace: { _ in }, diff --git a/Apps/iOS/BetterFitApp/DesignSystem/BFButtonStyles.swift b/Apps/iOS/BetterFitApp/DesignSystem/BFButtonStyles.swift index 818b40d..bb42856 100644 --- a/Apps/iOS/BetterFitApp/DesignSystem/BFButtonStyles.swift +++ b/Apps/iOS/BetterFitApp/DesignSystem/BFButtonStyles.swift @@ -2,19 +2,19 @@ import SwiftUI // MARK: - BF Button Styles -/// Solid accent fill, white label. Primary CTA ("Start Workout", "Save"). +/// Yellow fill, black label. Primary CTA ("Start workout", "Save"). struct BFPrimaryButtonStyle: ButtonStyle { var isFullWidth = true func makeBody(configuration: Configuration) -> some View { configuration.label .font(BFTypography.headline) - .foregroundStyle(.white) + .foregroundStyle(BFColors.accentInk) .frame(maxWidth: isFullWidth ? .infinity : nil) .frame(height: BFControlSize.buttonLarge) .background( - RoundedRectangle(cornerRadius: BFRadius.button, style: .continuous) - .fill(BFColors.brandAccent) + Capsule(style: .continuous) + .fill(BFColors.accent) ) .opacity(configuration.isPressed ? 0.85 : 1) .scaleEffect(configuration.isPressed ? 0.985 : 1) @@ -34,11 +34,11 @@ struct BFSecondaryButtonStyle: ButtonStyle { .frame(maxWidth: isFullWidth ? .infinity : nil) .frame(height: BFControlSize.buttonLarge) .background( - RoundedRectangle(cornerRadius: BFRadius.button, style: .continuous) + Capsule(style: .continuous) .fill(BFColors.surfaceRaised(for: scheme)) ) .overlay { - RoundedRectangle(cornerRadius: BFRadius.button, style: .continuous) + Capsule(style: .continuous) .stroke(BFColors.border(for: scheme), lineWidth: 1) } .opacity(configuration.isPressed ? 0.7 : 1) @@ -46,30 +46,34 @@ struct BFSecondaryButtonStyle: ButtonStyle { } } -/// No fill, accent text. Inline/tertiary actions ("View Details"). +/// No fill, accent text. Inline/tertiary actions. struct BFGhostButtonStyle: ButtonStyle { - var color: Color = BFColors.brandAccent + @Environment(\.colorScheme) private var scheme + var color: Color? = nil func makeBody(configuration: Configuration) -> some View { configuration.label .font(BFTypography.subheadlineEmphasis) - .foregroundStyle(color) + .foregroundStyle(color ?? BFColors.accentText(for: scheme)) .opacity(configuration.isPressed ? 0.6 : 1) .animation(.easeOut(duration: 0.12), value: configuration.isPressed) } } -/// Solid red fill, white label. Destructive confirmations. +/// Inverted fill (primary text on opposite field). Destructive confirmations — +/// no alarm red; meaning comes from the label. struct BFDestructiveButtonStyle: ButtonStyle { + @Environment(\.colorScheme) private var scheme + func makeBody(configuration: Configuration) -> some View { configuration.label .font(BFTypography.headline) - .foregroundStyle(.white) + .foregroundStyle(scheme == .dark ? BFColors.background(for: .dark) : .white) .frame(maxWidth: .infinity) .frame(height: BFControlSize.buttonLarge) .background( - RoundedRectangle(cornerRadius: BFRadius.button, style: .continuous) - .fill(BFColors.danger) + Capsule(style: .continuous) + .fill(BFColors.textPrimary(for: scheme)) ) .opacity(configuration.isPressed ? 0.85 : 1) .animation(.easeOut(duration: 0.12), value: configuration.isPressed) @@ -78,7 +82,7 @@ struct BFDestructiveButtonStyle: ButtonStyle { // MARK: - BF Icon Button -/// 40pt circular icon button on a raised surface. Replaces glass chrome buttons. +/// Circular icon button on a raised surface. struct BFIconButton: View { let systemImage: String let accessibilityLabel: String diff --git a/Apps/iOS/BetterFitApp/DesignSystem/BFColors.swift b/Apps/iOS/BetterFitApp/DesignSystem/BFColors.swift index 31a5d88..f62e016 100644 --- a/Apps/iOS/BetterFitApp/DesignSystem/BFColors.swift +++ b/Apps/iOS/BetterFitApp/DesignSystem/BFColors.swift @@ -2,10 +2,31 @@ import SwiftUI // MARK: - BF Color Tokens -/// Flat, dark-first color system. All colors resolve against the color scheme. -/// Accent stays theme-driven (AppTheme.accent); everything else is semantic. +/// Two-colour system: electric yellow and black. Neutrals carry structure; +/// yellow is the single accent. Status meaning rides a yellow ladder plus words. enum BFColors { + // MARK: Identity + + /// BetterFit yellow — the only chromatic accent. + static let identity = Color(red: 1.0, green: 0.839, blue: 0.039) // #FFD60A + static let identityInk = Color.black + static let identitySurface = Color(red: 1.0, green: 0.839, blue: 0.039).opacity(0.15) + + /// Product accent alias. Always yellow with black ink. + static let accent = identity + static let accentInk = identityInk + static let accentSurface = identitySurface + + /// Deprecated alias — kept so call sites compile. Points at yellow. + static let brandAccent = accent + + // MARK: Yellow ladder (status / recovery brightness) + + static let yellowBright = Color(red: 1.0, green: 0.910, blue: 0.376) // #FFE860 + static let yellowDeep = Color(red: 0.788, green: 0.659, blue: 0.0) // #C9A800 + static let yellowDim = Color(red: 0.478, green: 0.400, blue: 0.0) // #7A6600 + // MARK: Page Backgrounds static func background(for scheme: ColorScheme) -> Color { @@ -22,14 +43,12 @@ enum BFColors { // MARK: Surfaces - /// Card / list-row fill. static func surface(for scheme: ColorScheme) -> Color { scheme == .dark ? Color(red: 0.090, green: 0.090, blue: 0.106) // #17171B : Color.white } - /// Pressed/hover state, inset wells, segmented controls. static func surfaceRaised(for scheme: ColorScheme) -> Color { scheme == .dark ? Color(red: 0.122, green: 0.122, blue: 0.145) // #1F1F25 @@ -44,6 +63,12 @@ enum BFColors { : Color.black.opacity(0.08) } + static func borderStrong(for scheme: ColorScheme) -> Color { + scheme == .dark + ? Color.white.opacity(0.14) + : Color.black.opacity(0.16) + } + static func separator(for scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.06) @@ -61,47 +86,103 @@ enum BFColors { static func textSecondary(for scheme: ColorScheme) -> Color { scheme == .dark ? Color(red: 0.612, green: 0.612, blue: 0.651) // #9C9CA6 - : Color(red: 0.380, green: 0.380, blue: 0.420) + : Color(red: 0.380, green: 0.380, blue: 0.420) // #61616B } static func textTertiary(for scheme: ColorScheme) -> Color { scheme == .dark ? Color(red: 0.388, green: 0.388, blue: 0.427) // #63636D - : Color(red: 0.580, green: 0.580, blue: 0.620) + : Color(red: 0.580, green: 0.580, blue: 0.620) // #94949E } - // MARK: Brand / Semantic + /// Yellow used as *text/icon* colour. On light fields use the deep step for AA. + static func accentText(for scheme: ColorScheme) -> Color { + scheme == .dark ? identity : Color(red: 0.541, green: 0.443, blue: 0.0) // #8A7100 + } - /// Fitbod-style ember orange. Used by the `.fitbod` AppTheme accent; - /// exposed here for places that need a fixed brand color. - static let brandAccent = Color(red: 1.0, green: 0.353, blue: 0.235) // #FF5A3C + // MARK: Glass (floating chrome only) - /// Identity yellow. Reserved for brand moments only (logo, launch, achievements). - /// Per design spec, never use as a default product surface or action colour. - static let identity = Color(red: 1.0, green: 0.839, blue: 0.039) // #FFD60A + static func glassFill(for scheme: ColorScheme) -> Color { + scheme == .dark + ? Color(red: 0.094, green: 0.094, blue: 0.114).opacity(0.72) + : Color.white.opacity(0.72) + } - /// Foreground paired with `identity`. The spec mandates black content on large - /// yellow fields; this colour is the canonical ink. - static let identityInk = Color.black + static func glassFillStrong(for scheme: ColorScheme) -> Color { + scheme == .dark + ? Color(red: 0.071, green: 0.071, blue: 0.086).opacity(0.86) + : Color.white.opacity(0.88) + } - /// Tinted surface that signals an identity moment on the dark canvas without - /// committing to a full yellow field. - static let identitySurface = Color(red: 1.0, green: 0.839, blue: 0.039).opacity(0.16) + static func glassBorder(for scheme: ColorScheme) -> Color { + scheme == .dark + ? Color.white.opacity(0.12) + : Color.black.opacity(0.10) + } - static let success = Color(red: 0.290, green: 0.871, blue: 0.502) // #4ADE80 - static let warning = Color(red: 0.961, green: 0.722, blue: 0.239) // #F5B83D - static let danger = Color(red: 0.941, green: 0.267, blue: 0.220) // #F04438 - static let info = Color(red: 0.180, green: 0.565, blue: 0.980) // #2E90FA + static func glassHighlight(for scheme: ColorScheme) -> Color { + scheme == .dark + ? Color.white.opacity(0.06) + : Color.white.opacity(0.60) + } + + // MARK: Status (brightness on the yellow ladder + wording) + + static let success = yellowBright + static let warning = identity + /// Danger is expressed by inversion at the control. Call sites that need a + /// fill should use `textPrimary` (dark) / white (light) rather than a red hue. + static let danger = Color.white + static let info = yellowDeep + + static func successSurface(for scheme: ColorScheme) -> Color { + yellowBright.opacity(0.15) + } + + static func warningSurface(for scheme: ColorScheme) -> Color { + identity.opacity(0.15) + } + + static func dangerSurface(for scheme: ColorScheme) -> Color { + scheme == .dark ? Color.white.opacity(0.10) : Color.black.opacity(0.08) + } // MARK: Recovery Scale - /// Muscle recovery colors — recovered (blue) → sore (red). - static func recoveryColor(for status: RecoveryStatusLike) -> Color { + /// Brightest = recovered, dimmest = sore. Always pair with a written label. + static func recoveryColor(for status: RecoveryStatusLike, scheme: ColorScheme = .dark) -> Color { switch status { - case .recovered: return info - case .slightlyFatigued: return success - case .fatigued: return warning - case .sore: return danger + case .recovered: + return scheme == .dark ? yellowBright : yellowDeep + case .slightlyFatigued: + return scheme == .dark ? identity : Color(red: 0.659, green: 0.549, blue: 0.0) // #A88C00 + case .fatigued: + return scheme == .dark ? yellowDeep : yellowDim + case .sore: + return scheme == .dark ? yellowDim : Color(red: 0.290, green: 0.239, blue: 0.0) // #4A3D00 + } + } + + // MARK: Heatmap ramp + + static func heat(level: Int, for scheme: ColorScheme) -> Color { + let clamped = max(0, min(4, level)) + if scheme == .dark { + switch clamped { + case 0: return Color.white.opacity(0.05) + case 1: return identity.opacity(0.22) + case 2: return identity.opacity(0.45) + case 3: return identity.opacity(0.70) + default: return identity + } + } else { + switch clamped { + case 0: return Color.black.opacity(0.06) + case 1: return yellowDeep.opacity(0.30) + case 2: return yellowDeep.opacity(0.55) + case 3: return yellowDeep.opacity(0.80) + default: return yellowDeep + } } } } @@ -109,7 +190,6 @@ enum BFColors { // MARK: - RecoveryStatusLike /// DS-local mirror so tokens compile without importing BetterFit into every file. -/// Map `RecoveryStatus` → this at call sites via `RecoveryStatusLike(status)`. enum RecoveryStatusLike { case recovered case slightlyFatigued diff --git a/Apps/iOS/BetterFitApp/DesignSystem/BFComponents.swift b/Apps/iOS/BetterFitApp/DesignSystem/BFComponents.swift index ccd6191..c38f812 100644 --- a/Apps/iOS/BetterFitApp/DesignSystem/BFComponents.swift +++ b/Apps/iOS/BetterFitApp/DesignSystem/BFComponents.swift @@ -62,7 +62,7 @@ struct BFStatTile: View { let systemImage: String let value: String let label: String - var tint: Color = BFColors.brandAccent + var tint: Color = BFColors.accent @Environment(\.colorScheme) private var scheme @@ -111,14 +111,14 @@ struct BFChip: View { .padding(.vertical, 8) .background( Capsule() - .fill(isSelected ? BFColors.brandAccent : BFColors.surfaceRaised(for: scheme)) + .fill(isSelected ? BFColors.accent : BFColors.surfaceRaised(for: scheme)) ) .overlay { if !isSelected { Capsule().stroke(BFColors.border(for: scheme), lineWidth: 1) } } - .foregroundStyle(isSelected ? .white : BFColors.textPrimary(for: scheme)) + .foregroundStyle(isSelected ? BFColors.accentInk : BFColors.textPrimary(for: scheme)) } .buttonStyle(.plain) } @@ -131,7 +131,7 @@ struct BFListRow: View { let systemImage: String let title: String var subtitle: String? = nil - var iconTint: Color = BFColors.brandAccent + var iconTint: Color = BFColors.accent @ViewBuilder var trailing: Trailing @Environment(\.colorScheme) private var scheme @@ -140,7 +140,7 @@ struct BFListRow: View { systemImage: String, title: String, subtitle: String? = nil, - iconTint: Color = BFColors.brandAccent, + iconTint: Color = BFColors.accent, @ViewBuilder trailing: () -> Trailing ) { self.systemImage = systemImage @@ -186,7 +186,7 @@ extension BFListRow where Trailing == EmptyView { systemImage: String, title: String, subtitle: String? = nil, - iconTint: Color = BFColors.brandAccent + iconTint: Color = BFColors.accent ) { self.init(systemImage: systemImage, title: title, subtitle: subtitle, iconTint: iconTint) { EmptyView() @@ -201,7 +201,7 @@ struct BFChevronRow: View { let systemImage: String let title: String var subtitle: String? = nil - var iconTint: Color = BFColors.brandAccent + var iconTint: Color = BFColors.accent var action: () -> Void = {} @Environment(\.colorScheme) private var scheme @@ -260,7 +260,7 @@ struct BFEmptyState: View { struct BFProgressRing: View { let progress: Double // 0...1 var lineWidth: CGFloat = 10 - var tint: Color = BFColors.brandAccent + var tint: Color = BFColors.accent var size: CGFloat? = nil @Environment(\.colorScheme) private var scheme diff --git a/Apps/iOS/BetterFitApp/DesignSystem/BFFormat.swift b/Apps/iOS/BetterFitApp/DesignSystem/BFFormat.swift new file mode 100644 index 0000000..3f9578c --- /dev/null +++ b/Apps/iOS/BetterFitApp/DesignSystem/BFFormat.swift @@ -0,0 +1,22 @@ +import Foundation + +// MARK: - Number formatting + +/// Shared numeric display rules for the Ledger UI. +/// +/// Values that read as whole numbers drop their decimal; anything else keeps a +/// single significant decimal so a plate change stays legible mid-set. +enum BFFormat { + /// Whole numbers render as integers, everything else keeps one decimal. + static func trimmed(_ value: Double) -> String { + value.truncatingRemainder(dividingBy: 1) == 0 + ? String(Int(value)) + : String(format: "%.1f", value) + } + + /// Rounds to the nearest whole number. Used for headline aggregates where a + /// fractional value would only add noise. + static func rounded(_ value: Double) -> String { + String(Int(value.rounded())) + } +} diff --git a/Apps/iOS/BetterFitApp/DesignSystem/BFIllustrations.swift b/Apps/iOS/BetterFitApp/DesignSystem/BFIllustrations.swift index 81bc5a7..9572ae8 100644 --- a/Apps/iOS/BetterFitApp/DesignSystem/BFIllustrations.swift +++ b/Apps/iOS/BetterFitApp/DesignSystem/BFIllustrations.swift @@ -77,7 +77,7 @@ struct WorkoutMovementIllustration: View { @Environment(\.colorScheme) private var colorScheme let pose: WorkoutMovementPose - var accent: Color = BFColors.brandAccent + var accent: Color = BFColors.accent var body: some View { ZStack { diff --git a/Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Actions.swift b/Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Actions.swift new file mode 100644 index 0000000..47455b6 --- /dev/null +++ b/Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Actions.swift @@ -0,0 +1,197 @@ +import SwiftUI + +// MARK: - Ledger action primitives +// Rules, not cards. A mono gutter, one yellow field per screen, floating glass chrome. +// Glass is reserved for floating chrome (nav, action dock, revealed swipe actions); +// scrolling controls use flat raised surfaces. + +// MARK: Quick actions strip + +struct BFQuickAction: Identifiable { + let id = UUID() + let systemImage: String + let label: String + var action: () -> Void = {} +} + +struct BFQuickActions: View { + let items: [BFQuickAction] + @Environment(\.colorScheme) private var scheme + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(items) { item in + Button(action: item.action) { + HStack(spacing: 7) { + Image(systemName: item.systemImage) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(BFColors.accentText(for: scheme)) + Text(item.label) + .font(BFTypography.footnoteEmphasis) + .foregroundStyle(BFColors.textPrimary(for: scheme)) + } + .padding(.horizontal, 16) + .frame(height: BFControlSize.minTapTarget) + .background { + Capsule() + .fill(BFColors.surfaceRaised(for: scheme)) + .overlay { + Capsule().stroke(BFColors.border(for: scheme), lineWidth: 1) + } + } + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, BFSpacing.pageHorizontal) + } + } +} + +// MARK: Glass dock + +/// Floating action dock. Content is a single HStack of equal-height controls +/// (primary pill + square companions). Sits above the tab bar via safeAreaInset. +struct BFGlassDock: View { + var horizontalPadding: CGFloat = 12 + @ViewBuilder var content: Content + @Environment(\.colorScheme) private var scheme + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + var body: some View { + HStack(alignment: .center, spacing: 8) { + content + } + .frame(maxWidth: .infinity, minHeight: BFControlSize.buttonLarge, alignment: .center) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .background { + Capsule(style: .continuous) + .fill(reduceTransparency + ? BFColors.backgroundElevated(for: scheme) + : BFColors.glassFillStrong(for: scheme)) + .overlay { + Capsule(style: .continuous) + .stroke(BFColors.glassBorder(for: scheme), lineWidth: 1) + } + .shadow(color: Color.black.opacity(scheme == .dark ? 0.45 : 0.14), radius: 18, y: 6) + } + .padding(.horizontal, horizontalPadding) + } +} + +/// Primary docked action. Standalone = compact centered pill; otherwise fills +/// available width. Yellow by default; `quiet` renders neutral glass so a screen +/// that leads with a full yellow field never shows two yellow fills at once. +struct BFDockPrimaryButton: View { + let title: String + var systemImage: String? = "play.fill" + /// When true, sizes to the label (rounded pill). When false, expands full width. + var standalone = true + /// One-yellow rule (design.md): set when the screen leads with a full yellow field. + var quiet = false + var action: () -> Void + + @Environment(\.colorScheme) private var scheme + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 16, weight: .bold)) + } + Text(title) + .font(BFTypography.headline) + } + .foregroundStyle(quiet ? BFColors.textPrimary(for: scheme) : BFColors.accentInk) + .padding(.horizontal, standalone ? 36 : 20) + .frame(maxWidth: standalone ? nil : .infinity) + .frame(height: BFControlSize.buttonLarge) + .background { + Capsule(style: .continuous) + .fill(quiet ? BFColors.glassHighlight(for: scheme) : BFColors.accent) + .overlay { + if quiet { + Capsule(style: .continuous) + .stroke(BFColors.glassBorder(for: scheme), lineWidth: 1) + } + } + } + .contentShape(Capsule()) + .shadow( + color: Color.black.opacity(standalone ? 0.4 : 0.2), + radius: standalone ? 20 : 10, + y: standalone ? 8 : 4 + ) + } + .buttonStyle(.plain) + .fixedSize(horizontal: standalone, vertical: true) + } +} + +// MARK: Dock companion button + +struct BFDockButton: View { + let systemImage: String + let label: String + var action: () -> Void = {} + @Environment(\.colorScheme) private var scheme + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(BFColors.textPrimary(for: scheme)) + .frame(width: BFControlSize.buttonLarge, height: BFControlSize.buttonLarge) + .background { + Circle() + .fill(BFColors.glassHighlight(for: scheme)) + .overlay { + Circle().stroke(BFColors.glassBorder(for: scheme), lineWidth: 1) + } + } + .contentShape(Circle()) + } + .buttonStyle(.plain) + .frame(width: BFControlSize.buttonLarge, height: BFControlSize.buttonLarge) + .accessibilityLabel(label) + } +} + +// MARK: Source switch (Suggested / Frequent) + +struct BFSourceSwitch: View { + @Binding var selection: String + let options: [(id: String, label: String)] + @Environment(\.colorScheme) private var scheme + + var body: some View { + HStack(spacing: 4) { + ForEach(options, id: \.id) { option in + Button { + selection = option.id + } label: { + Text(option.label) + .font(BFTypography.footnoteEmphasis) + .foregroundStyle( + selection == option.id + ? BFColors.textPrimary(for: scheme) + : BFColors.textSecondary(for: scheme) + ) + .frame(maxWidth: .infinity) + .frame(height: BFControlSize.minTapTarget) + .background { + if selection == option.id { + Capsule().fill(BFColors.background(for: scheme)) + } + } + } + .buttonStyle(.plain) + } + } + .padding(4) + .background(Capsule().fill(BFColors.surfaceRaised(for: scheme))) + } +} diff --git a/Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Identity.swift b/Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Identity.swift new file mode 100644 index 0000000..7ed348b --- /dev/null +++ b/Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Identity.swift @@ -0,0 +1,153 @@ +import SwiftUI + +// MARK: - Ledger identity primitives +// Rules, not cards. A mono gutter, one yellow field per screen, floating glass chrome. + +// MARK: Eyebrow + +struct BFEyebrow: View { + let text: String + var color: Color? = nil + @Environment(\.colorScheme) private var scheme + + var body: some View { + Text(text) + .font(BFTypography.eyebrow) + .textCase(.uppercase) + .tracking(BFTypography.labelTracking) + .foregroundStyle(color ?? BFColors.textSecondary(for: scheme)) + } +} + +// MARK: Section rule + +struct BFSectionRule: View { + let label: String + var trailing: String? = nil + @Environment(\.colorScheme) private var scheme + + var body: some View { + HStack(spacing: BFSpacing.md) { + BFEyebrow(text: label) + Rectangle() + .fill(BFColors.separator(for: scheme)) + .frame(height: 1) + if let trailing { + Text(trailing) + .font(BFTypography.caption) + .monospacedDigit() + .foregroundStyle(BFColors.textTertiary(for: scheme)) + } + } + .padding(.top, BFSpacing.xxl) + .padding(.bottom, 10) + } +} + +// MARK: Spec strip + +struct BFSpecItem: Identifiable { + let id = UUID() + let value: String + let label: String +} + +struct BFSpecStrip: View { + let items: [BFSpecItem] + var ink: Color? = nil + @Environment(\.colorScheme) private var scheme + + var body: some View { + HStack(spacing: 18) { + ForEach(items) { item in + VStack(alignment: .leading, spacing: 5) { + Text(item.value) + .font(BFTypography.specValue) + .foregroundStyle(ink ?? BFColors.textPrimary(for: scheme)) + Text(item.label) + .font(BFTypography.eyebrow) + .textCase(.uppercase) + .tracking(BFTypography.labelTracking) + .opacity(0.58) + .foregroundStyle(ink ?? BFColors.textSecondary(for: scheme)) + } + } + Spacer(minLength: 0) + } + } +} + +// MARK: Header block (action screens — neutral by default) + +struct BFHeaderBlock: View { + var eyebrow: String? = nil + var title: String? = nil + var specs: [BFSpecItem] = [] + /// When true, the header takes the yellow field (docked action goes quiet). + var yellow = false + @Environment(\.colorScheme) private var scheme + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let eyebrow { + BFEyebrow( + text: eyebrow, + color: yellow ? BFColors.identityInk.opacity(0.62) : BFColors.accentText(for: scheme) + ) + } + if let title { + Text(title) + .font(BFTypography.display) + .tracking(BFTypography.displayTracking) + .foregroundStyle(yellow ? BFColors.identityInk : BFColors.textPrimary(for: scheme)) + .padding(.top, 10) + .lineLimit(2) + .minimumScaleFactor(0.7) + } + if !specs.isEmpty { + BFSpecStrip(items: specs, ink: yellow ? BFColors.identityInk : nil) + .padding(.top, 14) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, BFSpacing.pageHorizontal) + .padding(.top, 4) + .padding(.bottom, 20) + .background(yellow ? BFColors.identity : Color.clear) + .overlay(alignment: .bottom) { + if !yellow { + Rectangle() + .fill(BFColors.separator(for: scheme)) + .frame(height: 1) + } + } + } +} + +// MARK: Ledger head + +struct BFLedgerHead: View { + let title: String + @ViewBuilder var trailing: Trailing + @Environment(\.colorScheme) private var scheme + + var body: some View { + HStack(spacing: 12) { + Text(title) + .font(BFTypography.screenTitle) + .tracking(BFTypography.displayTracking) + .foregroundStyle(BFColors.textPrimary(for: scheme)) + Spacer(minLength: 0) + trailing + } + .padding(.horizontal, BFSpacing.pageHorizontal) + .padding(.bottom, 14) + } +} + +extension BFLedgerHead where Trailing == EmptyView { + init(title: String) { + self.title = title + self.trailing = EmptyView() + } +} diff --git a/Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Rows.swift b/Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Rows.swift new file mode 100644 index 0000000..f08f4a5 --- /dev/null +++ b/Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Rows.swift @@ -0,0 +1,151 @@ +import SwiftUI + +// MARK: - Ledger row primitives +// Rules, not cards. A mono gutter, one yellow field per screen, floating glass chrome. + +// MARK: Ledger row + +struct BFLedgerRow: View { + var gutter: String? = nil + var gutterAccent = false + var systemImage: String? = nil + let title: String + var meta: String? = nil + var dim = false + var accentEdge = false + @ViewBuilder var trailing: Trailing + + @Environment(\.colorScheme) private var scheme + + var body: some View { + HStack(alignment: .center, spacing: 14) { + if let systemImage { + ZStack { + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(BFColors.surfaceRaised(for: scheme)) + .overlay { + RoundedRectangle(cornerRadius: 11, style: .continuous) + .stroke(BFColors.border(for: scheme), lineWidth: 1) + } + Image(systemName: systemImage) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(BFColors.accentText(for: scheme)) + } + .frame(width: 38, height: 38) + } else if let gutter { + Text(gutter) + .font(BFTypography.gutter) + .foregroundStyle( + gutterAccent + ? BFColors.accentText(for: scheme) + : BFColors.textTertiary(for: scheme) + ) + .frame(width: 32, alignment: .leading) + } + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(BFTypography.bodyEmphasis) + .foregroundStyle(BFColors.textPrimary(for: scheme)) + .lineLimit(2) + if let meta { + Text(meta) + .font(BFTypography.footnote) + .foregroundStyle(BFColors.textSecondary(for: scheme)) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + trailing + } + .padding(.vertical, 14) + .opacity(dim ? 0.45 : 1) + .overlay(alignment: .top) { + Rectangle() + .fill(BFColors.separator(for: scheme)) + .frame(height: 1) + } + .overlay(alignment: .leading) { + if accentEdge { + Rectangle() + .fill(BFColors.accent) + .frame(width: 3) + .offset(x: -BFSpacing.pageHorizontal) + } + } + } +} + +extension BFLedgerRow where Trailing == EmptyView { + init( + gutter: String? = nil, + gutterAccent: Bool = false, + systemImage: String? = nil, + title: String, + meta: String? = nil, + dim: Bool = false, + accentEdge: Bool = false + ) { + self.gutter = gutter + self.gutterAccent = gutterAccent + self.systemImage = systemImage + self.title = title + self.meta = meta + self.dim = dim + self.accentEdge = accentEdge + self.trailing = EmptyView() + } +} + +// MARK: Ledger row trailing + +struct BFLedgerTrailing: View { + let value: String + var meta: String? = nil + @Environment(\.colorScheme) private var scheme + + var body: some View { + VStack(alignment: .trailing, spacing: 3) { + Text(value) + .font(BFTypography.subheadlineEmphasis) + .monospacedDigit() + .foregroundStyle(BFColors.textPrimary(for: scheme)) + if let meta { + Text(meta) + .font(BFTypography.caption) + .monospacedDigit() + .foregroundStyle(BFColors.textTertiary(for: scheme)) + } + } + } +} + +// MARK: Add row + +struct BFAddRow: View { + var label = "Add exercise" + var action: () -> Void + @Environment(\.colorScheme) private var scheme + + var body: some View { + Button(action: action) { + HStack(spacing: 14) { + Image(systemName: "plus") + .font(.system(size: 16, weight: .bold)) + .frame(width: 32, alignment: .leading) + Text(label) + .font(BFTypography.bodyEmphasis) + Spacer() + } + .foregroundStyle(BFColors.accentText(for: scheme)) + .padding(.vertical, 16) + .overlay(alignment: .top) { + Rectangle() + .fill(BFColors.separator(for: scheme)) + .frame(height: 1) + } + } + .buttonStyle(.plain) + } +} diff --git a/Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Surfaces.swift b/Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Surfaces.swift new file mode 100644 index 0000000..060386d --- /dev/null +++ b/Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Surfaces.swift @@ -0,0 +1,76 @@ +import SwiftUI + +// MARK: - Ledger surface primitives +// Rules, not cards. A mono gutter, one yellow field per screen, floating glass chrome. + +// MARK: Slab (full-bleed yellow field for outcome screens) + +struct BFSlab: View { + @ViewBuilder var content: Content + + var body: some View { + content + .foregroundStyle(BFColors.identityInk) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, BFSpacing.pageHorizontal) + .padding(.vertical, 18) + .background(BFColors.identity) + } +} + +// MARK: Readout (conclusion first) + +struct BFReadout: View { + let value: String + var unit: String? = nil + let label: String + var note: String? = nil + @Environment(\.colorScheme) private var scheme + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + BFEyebrow(text: label) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(value) + .font(BFTypography.readout) + .foregroundStyle(BFColors.textPrimary(for: scheme)) + if let unit { + Text(unit) + .font(BFTypography.specValue) + .foregroundStyle(BFColors.textSecondary(for: scheme)) + } + } + .padding(.top, 10) + if let note { + Text(note) + .font(BFTypography.subheadline) + .foregroundStyle(BFColors.textSecondary(for: scheme)) + .padding(.top, 12) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 10) + } +} + +// MARK: Bar (shared left-edge progress — never a ring on iPhone) + +struct BFBar: View { + let progress: Double + var color: Color = BFColors.accent + @Environment(\.colorScheme) private var scheme + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(BFColors.surfaceRaised(for: scheme)) + Capsule() + .fill(color) + .frame(width: max(2, geo.size.width * CGFloat(min(1, max(0, progress))))) + } + } + .frame(height: 4) + } +} diff --git a/Apps/iOS/BetterFitApp/DesignSystem/BFRecovery.swift b/Apps/iOS/BetterFitApp/DesignSystem/BFRecovery.swift index 47f71b7..777f2a4 100644 --- a/Apps/iOS/BetterFitApp/DesignSystem/BFRecovery.swift +++ b/Apps/iOS/BetterFitApp/DesignSystem/BFRecovery.swift @@ -15,12 +15,16 @@ extension RecoveryStatusLike { } extension RecoveryStatus { - /// Muscle recovery color — recovered (blue) → sore (red). + /// Muscle recovery colour on the yellow ladder. var bfColor: Color { BFColors.recoveryColor(for: RecoveryStatusLike(self)) } - /// Short label for chips/legends. + func bfColor(for scheme: ColorScheme) -> Color { + BFColors.recoveryColor(for: RecoveryStatusLike(self), scheme: scheme) + } + + /// Short label for chips/legends — always pair with the colour. var bfLabel: String { switch self { case .recovered: return "Recovered" @@ -29,18 +33,28 @@ extension RecoveryStatus { case .sore: return "Sore" } } + + /// 0…1 readiness used by ledger bars. + var bfReadiness: Double { + switch self { + case .recovered: return 0.96 + case .slightlyFatigued: return 0.72 + case .fatigued: return 0.48 + case .sore: return 0.22 + } + } } // MARK: - Recovery Dot -/// Small colored dot indicating a region's recovery status. struct BFRecoveryDot: View { let status: RecoveryStatus var size: CGFloat = 10 + @Environment(\.colorScheme) private var scheme var body: some View { Circle() - .fill(status.bfColor) + .fill(status.bfColor(for: scheme)) .frame(width: size, height: size) .accessibilityLabel("Recovery: \(status.bfLabel)") } @@ -48,16 +62,19 @@ struct BFRecoveryDot: View { // MARK: - Recovery Badge -/// Colored capsule with status label (e.g. "Recovered", "Sore"). struct BFRecoveryBadge: View { let status: RecoveryStatus + @Environment(\.colorScheme) private var scheme var body: some View { Text(status.bfLabel) .font(BFTypography.captionEmphasis) - .foregroundStyle(status.bfColor) + .foregroundStyle(status.bfColor(for: scheme)) .padding(.horizontal, 10) .padding(.vertical, 4) - .background(Capsule().fill(status.bfColor.opacity(0.15))) + .background(Capsule().fill(status.bfColor(for: scheme).opacity(0.22))) + .overlay { + Capsule().stroke(status.bfColor(for: scheme).opacity(0.45), lineWidth: 1) + } } } diff --git a/Apps/iOS/BetterFitApp/DesignSystem/BFTypography.swift b/Apps/iOS/BetterFitApp/DesignSystem/BFTypography.swift index 9897bc6..ff96be5 100644 --- a/Apps/iOS/BetterFitApp/DesignSystem/BFTypography.swift +++ b/Apps/iOS/BetterFitApp/DesignSystem/BFTypography.swift @@ -1,23 +1,36 @@ import SwiftUI +#if canImport(UIKit) + import UIKit +#endif + // MARK: - BF Typography +// +// Type pairings (design system): +// Display → BBH Hegarty (hero, titles) +// UI → system SF (body, controls, labels) +// Numeric → system SF monospaced digits (timers, weights, reps, stats) -/// Type scale for the app. Bold, high-contrast headings; monospaced digits -/// for anything numeric so stats don't jitter. enum BFTypography { - // MARK: Display + // MARK: Display (BBH Hegarty) - static let largeTitle = Font.system(size: 34, weight: .bold) - static let title1 = Font.system(size: 28, weight: .bold) - static let title2 = Font.system(size: 22, weight: .bold) - static let title3 = Font.system(size: 20, weight: .semibold) + /// Full-bleed identity headline. + static var hero: Font { display(56, relativeTo: .largeTitle) } + /// Slab / session titles. + static var display: Font { display(44, relativeTo: .largeTitle) } + static var largeTitle: Font { display(34, relativeTo: .largeTitle) } + static var title1: Font { display(28, relativeTo: .title) } + static var title2: Font { display(22, relativeTo: .title2) } + /// Screen head (Plan / Body / Log). + static var screenTitle: Font { display(26, relativeTo: .title2) } - // MARK: Body + // MARK: UI (system) + static let title3 = Font.system(size: 20, weight: .semibold) static let headline = Font.system(size: 17, weight: .semibold) static let body = Font.system(size: 17, weight: .regular) - static let bodyEmphasis = Font.system(size: 17, weight: .medium) + static let bodyEmphasis = Font.system(size: 17, weight: .semibold) static let callout = Font.system(size: 16, weight: .regular) static let subheadline = Font.system(size: 15, weight: .regular) static let subheadlineEmphasis = Font.system(size: 15, weight: .semibold) @@ -25,22 +38,74 @@ enum BFTypography { static let footnoteEmphasis = Font.system(size: 13, weight: .semibold) static let caption = Font.system(size: 12, weight: .regular) static let captionEmphasis = Font.system(size: 12, weight: .semibold) + /// Uppercase micro label (section rules, eyebrows). + static let eyebrow = Font.system(size: 11, weight: .bold) - // MARK: Numeric (monospaced digits — timers, weights, reps, stats) + // MARK: Numeric (tabular / mono digits) + static let readout = Font.system(size: 64, weight: .bold).monospacedDigit() + static let timerDisplay = Font.system(size: 40, weight: .bold).monospacedDigit() + static let stepperValue = Font.system(size: 34, weight: .bold).monospacedDigit() + static let setValue = Font.system(size: 28, weight: .bold).monospacedDigit() static let statLarge = Font.system(size: 28, weight: .bold).monospacedDigit() + static let specValue = Font.system(size: 22, weight: .bold).monospacedDigit() static let statMedium = Font.system(size: 20, weight: .bold).monospacedDigit() static let statSmall = Font.system(size: 15, weight: .semibold).monospacedDigit() - static let timerDisplay = Font.system(size: 40, weight: .bold).monospacedDigit() + static let gutter = Font.system(size: 15, weight: .bold).monospacedDigit() + + // MARK: Tracking + + /// Display tracking with Regular cut (looser so glyphs don't collide). + static let displayTracking: CGFloat = 0.2 + /// Uppercase section / eyebrow tracking. + static let labelTracking: CGFloat = 1.2 + + // MARK: Resolution + + private static let displayCandidates: [String] = [ + "BBHHegarty-ExtraBold", + "BBHHegarty-Bold", + "BBH Hegarty", + "BBHHegarty", + "BBH-Hegarty", + "BBHHegarty-Regular", + ] + + private static let resolvedDisplayName: String? = { + #if canImport(UIKit) + return displayCandidates.first { UIFont(name: $0, size: 24) != nil } + #else + return nil + #endif + }() + + /// BBH Hegarty when registered; heavy rounded system face otherwise. + static func display(_ size: CGFloat, relativeTo textStyle: Font.TextStyle = .largeTitle) -> Font { + if let name = resolvedDisplayName { + return .custom(name, size: size, relativeTo: textStyle) + } + return .system(size: size, weight: .black, design: .rounded) + } } // MARK: - View helpers extension View { - /// Uppercased, tracked section label (e.g. "TODAY'S WORKOUT"). + /// Uppercased, tracked section label (e.g. "THE WORK"). func bfSectionLabel() -> some View { font(BFTypography.captionEmphasis) .textCase(.uppercase) - .tracking(1.2) + .tracking(BFTypography.labelTracking) + } + + /// Display heading in BBH Hegarty with design-system tracking. + func bfDisplay(_ size: CGFloat = 44) -> some View { + font(BFTypography.display(size)) + .tracking(BFTypography.displayTracking) + } + + /// Theme-aware display heading (kept for existing call sites). + func bfDisplay(_ theme: AppTheme, size: CGFloat = 44) -> some View { + bfDisplay(size) } } diff --git a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Actions.swift b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Actions.swift new file mode 100644 index 0000000..e242147 --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Actions.swift @@ -0,0 +1,240 @@ +import BetterFit +import SwiftUI +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - Actions + +extension ActiveSessionView { + + // MARK: - Session lifecycle + + func bootstrap() { + startedAt = workout?.date ?? Date() + // New workout session → wipe kg/reps overrides so this session's + // numbers don't bleed in from a previous workout. + if sessionWorkoutID != workout?.id { + sessionWorkoutID = workout?.id + setOverrides = [:] + draftLoad = [:] + draftReps = [:] + completedSetIndexes = [:] + exerciseIndex = 0 + setIndex = 0 + } + // Capture working order once so the user can reorder freely. + if orderedIDs.isEmpty { + orderedIDs = (workout?.exercises ?? []).map(\.id) + } else { + // Make sure any new exercises added since last time are picked up. + for id in (workout?.exercises ?? []).map(\.id) where !orderedIDs.contains(id) { + orderedIDs.append(id) + } + } + // Mirror shared superset links into local state so the session sees them. + supersetGroup = BFSupersetStore.shared.snapshot() + if let we = current { + loadDefaults(for: we) + } + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + tick.toggle() + } + } + + // MARK: - Navigation + + func advanceExercise() { + ensureOrderSeeded() + // Next incomplete exercise after the current one (supports out-of-order). + let start = exerciseIndex + 1 + if let next = (start.. [(muscle: String, from: Int, to: Int)] { + guard let bodyMap = betterFit?.bodyMapManager else { return [] } + return trainedRegions().map { region in + let before = bodyMap.getRecoveryStatus(for: region) + let after = before.afterWorkout() + return ( + muscle: region.rawValue.capitalized, + from: Int((before.bfReadiness * 100).rounded()), + to: Int((after.bfReadiness * 100).rounded()) + ) + } + } + + /// Distinct body regions with at least one logged set, in first-hit order. + func trainedRegions() -> [BodyRegion] { + var seen: Set = [] + var ordered: [BodyRegion] = [] + for item in exercises where loggedCount(for: item) > 0 { + for group in item.exercise.muscleGroups { + guard let region = BodyRegion(rawValue: group.bodyMapRegion), region != .other else { continue } + if seen.insert(region).inserted { + ordered.append(region) + } + } + } + return ordered + } + + // MARK: - Session composition + + func addExercise(_ planned: PlannedExercise) { + guard var updatedWorkout = workout else { return } + let repsVal = Int(planned.reps.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 10 + let weightVal = Double(planned.targetWeight?.components(separatedBy: CharacterSet.decimalDigits.inverted).joined() ?? "0") ?? 0 + let sets = (0.. Int { + max(setOverrides[we.id]?.count ?? we.sets.count, 1) + } + + func loggedCount(for we: WorkoutExercise) -> Int { + if let local = completedSetIndexes[we.id] { + return local.count + } + return we.sets.filter(\.isCompleted).count + } + + func isSetCompleted(we: WorkoutExercise, index: Int) -> Bool { + if let local = completedSetIndexes[we.id] { + return local.contains(index) + } + guard we.sets.indices.contains(index) else { return false } + return we.sets[index].isCompleted + } + + func defaultSets(for we: WorkoutExercise) -> [(load: Double, reps: Int)] { + if we.sets.isEmpty { + return [(60, 8), (60, 8), (60, 8)] + } + // Prefer first non-zero weight as a fill-in so empty optionals don't zero the session. + let fallbackLoad = we.sets.compactMap(\.weight).first(where: { $0 > 0 }) ?? 0 + let fallbackReps = we.sets.first(where: { $0.reps > 0 })?.reps ?? 8 + return we.sets.map { set in + (set.weight ?? fallbackLoad, set.reps > 0 ? set.reps : fallbackReps) + } + } + + func setValues(for we: WorkoutExercise, index: Int) -> (load: Double, reps: Int) { + let pairs = setOverrides[we.id] ?? defaultSets(for: we) + if pairs.indices.contains(index) { return pairs[index] } + return pairs.last ?? (60, 8) + } +} diff --git a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Drafts.swift b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Drafts.swift new file mode 100644 index 0000000..3ef161b --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Drafts.swift @@ -0,0 +1,144 @@ +import BetterFit +import SwiftUI + +// MARK: - Drafts (keeps fields editable while typing) + +extension ActiveSessionView { + + func draftKey(we: WorkoutExercise, index: Int, kind: String) -> String { + "\(we.id.uuidString)-\(index)-\(kind)" + } + + func syncDraftsFromModel(we: WorkoutExercise) { + let total = setCount(for: we) + for setIdx in 0.. Double? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return fallback } + let cleaned = trimmed + .replacingOccurrences(of: ",", with: ".") + .filter { $0.isNumber || $0 == "." } + if cleaned.isEmpty || cleaned == "." { return fallback } + guard let value = Double(cleaned) else { return fallback } + return max(0, value) + } + + func parseReps(_ raw: String, fallback: Int?) -> Int? { + let digits = raw.filter(\.isNumber) + if digits.isEmpty { return fallback } + return max(1, Int(digits) ?? fallback ?? 1) + } + + // MARK: - Draft bindings + + func headerLoadDraft(for we: WorkoutExercise) -> Binding { + let key = draftKey(we: we, index: -1, kind: "load") + return Binding( + get: { draftLoad[key] ?? BFFormat.trimmed(setValues(for: we, index: setIndex).load) }, + set: { draftLoad[key] = $0 } + ) + } + + func headerRepsDraft(for we: WorkoutExercise) -> Binding { + let key = draftKey(we: we, index: -1, kind: "reps") + return Binding( + get: { draftReps[key] ?? "\(setValues(for: we, index: setIndex).reps)" }, + set: { draftReps[key] = $0 } + ) + } + + func setLoadDraft(we: WorkoutExercise, index: Int) -> Binding { + let key = draftKey(we: we, index: index, kind: "load") + return Binding( + get: { draftLoad[key] ?? BFFormat.trimmed(setValues(for: we, index: index).load) }, + set: { draftLoad[key] = $0 } + ) + } + + func setRepsDraft(we: WorkoutExercise, index: Int) -> Binding { + let key = draftKey(we: we, index: index, kind: "reps") + return Binding( + get: { draftReps[key] ?? "\(setValues(for: we, index: index).reps)" }, + set: { draftReps[key] = $0 } + ) + } + + // MARK: - Commit + + func commitHeaderLoad(we: WorkoutExercise) { + let key = draftKey(we: we, index: -1, kind: "load") + let raw = draftLoad[key] ?? "" + let pair = setValues(for: we, index: setIndex) + let load = parseLoad(raw, fallback: pair.load) ?? pair.load + updateSet(we: we, index: setIndex, load: load, reps: pair.reps) + } + + func commitHeaderReps(we: WorkoutExercise) { + let key = draftKey(we: we, index: -1, kind: "reps") + let raw = draftReps[key] ?? "" + let pair = setValues(for: we, index: setIndex) + let value = parseReps(raw, fallback: pair.reps) ?? pair.reps + updateSet(we: we, index: setIndex, load: pair.load, reps: value) + } + + func commitSetLoad(we: WorkoutExercise, index: Int) { + let key = draftKey(we: we, index: index, kind: "load") + let raw = draftLoad[key] ?? "" + let pair = setValues(for: we, index: index) + let load = parseLoad(raw, fallback: pair.load) ?? pair.load + updateSet(we: we, index: index, load: load, reps: pair.reps) + } + + func commitSetReps(we: WorkoutExercise, index: Int) { + let key = draftKey(we: we, index: index, kind: "reps") + let raw = draftReps[key] ?? "" + let pair = setValues(for: we, index: index) + let value = parseReps(raw, fallback: pair.reps) ?? pair.reps + updateSet(we: we, index: index, load: pair.load, reps: value) + } + + func commitFocusedField() { + guard let we = current else { return } + switch focusedField { + case .headerLoad: commitHeaderLoad(we: we) + case .headerReps: commitHeaderReps(we: we) + case .setLoad(let setIdx): commitSetLoad(we: we, index: setIdx) + case .setReps(let setIdx): commitSetReps(we: we, index: setIdx) + case .none: break + } + } + + func commitAllDrafts(we: WorkoutExercise) { + commitHeaderLoad(we: we) + commitHeaderReps(we: we) + let total = setCount(for: we) + for setIdx in 0..= 0, to < orderedIDs.count, from != to + else { return } + let currentID = current?.id + let id = orderedIDs.remove(at: from) + orderedIDs.insert(id, at: to) + persistOrder() + if let currentID, let newIdx = orderedIDs.firstIndex(of: currentID) { + exerciseIndex = newIdx + } + } + + func ensureOrderSeeded() { + if orderedIDs.isEmpty { + orderedIDs = (workout?.exercises ?? []).map(\.id) + } + // Sync newly added exercises + let allIDs = (workout?.exercises ?? []).map(\.id) + for id in allIDs where !orderedIDs.contains(id) { + orderedIDs.append(id) + } + orderedIDs.removeAll { id in !allIDs.contains(id) } + } + + func linkSuperset(_ firstIndex: Int, with secondIndex: Int) { + ensureOrderSeeded() + guard exercises.indices.contains(firstIndex), exercises.indices.contains(secondIndex) else { return } + let idA = exercises[firstIndex].id + let idB = exercises[secondIndex].id + BFSupersetStore.shared.link(idA, idB) + supersetGroup = BFSupersetStore.shared.snapshot() + // Keep partners adjacent in order + if let ia = orderedIDs.firstIndex(of: idA), let ib = orderedIDs.firstIndex(of: idB), abs(ia - ib) != 1 { + orderedIDs.remove(at: ib) + let insertAt = min(orderedIDs.count, ia + 1) + orderedIDs.insert(idB, at: insertAt) + persistOrder() + if let cur = current?.id, let idx = orderedIDs.firstIndex(of: cur) { + exerciseIndex = idx + } + } + } + + func toggleSuperset(withNextOf id: UUID) { + ensureOrderSeeded() + if isSuperset(id) { + unlinkSuperset(id) + return + } + guard let idx = orderedIDs.firstIndex(of: id), idx + 1 < orderedIDs.count else { return } + linkSuperset(idx, with: idx + 1) + } + + func unlinkSuperset(_ id: UUID) { + BFSupersetStore.shared.unlink(id) + supersetGroup = BFSupersetStore.shared.snapshot() + } + + // MARK: - Session order section + + var sessionOrderSection: some View { + let list = exercises + let incomplete = list.filter { loggedCount(for: $0) < setCount(for: $0) }.count + return VStack(alignment: .leading, spacing: 0) { + BFSectionRule( + label: "Session", + trailing: editMode.isEditing ? "Drag to reorder" : "\(incomplete) left · tap any" + ) + + if editMode.isEditing { + ForEach(Array(list.enumerated()), id: \.element.id) { index, we in + sessionOrderRow(we: we, index: index, showsDrag: true) + } + } else { + ForEach(Array(list.enumerated()), id: \.element.id) { index, we in + sessionOrderRow(we: we, index: index, showsDrag: false) + } + } + + BFAddRow { showAddExercise = true } + } + .padding(.horizontal, BFSpacing.pageHorizontal) + } + + func sessionOrderRow(we: WorkoutExercise, index: Int, showsDrag: Bool) -> some View { + let done = loggedCount(for: we) + let total = setCount(for: we) + let isCurrent = index == exerciseIndex + let pair = setValues(for: we, index: min(done, max(total - 1, 0))) + let ssLabel = supersetLabel(for: we.id) + + let row = HStack(spacing: 12) { + // Superset rail + ZStack { + if isSuperset(we.id) { + RoundedRectangle(cornerRadius: 2) + .fill(BFColors.accent) + .frame(width: 3) + } + Text(String(format: "%02d", index + 1)) + .font(BFTypography.gutter) + .foregroundStyle( + isCurrent + ? BFColors.accentText(for: colorScheme) + : BFColors.textTertiary(for: colorScheme) + ) + .frame(width: 28, alignment: .leading) + } + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(we.exercise.name) + .font(BFTypography.bodyEmphasis) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + if let ssLabel { + Text(ssLabel) + .font(BFTypography.captionEmphasis) + .foregroundStyle(BFColors.accentInk) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Capsule().fill(BFColors.accent)) + } + } + Text( + done >= total + ? "Done · \(total) sets" + : "\(done)/\(total) · \(BFFormat.trimmed(pair.load)) kg × \(pair.reps)" + ) + .font(BFTypography.footnote) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + } + + Spacer(minLength: 0) + + sessionOrderTrailing(index: index, isCurrent: isCurrent, showsDrag: showsDrag, done: done, total: total) + } + .padding(.vertical, 12) + .opacity(done >= total && !isCurrent ? 0.55 : 1) + .overlay(alignment: .top) { + Rectangle() + .fill(BFColors.separator(for: colorScheme)) + .frame(height: 1) + } + .overlay(alignment: .leading) { + if isCurrent { + Rectangle() + .fill(BFColors.accent) + .frame(width: 3) + .offset(x: -BFSpacing.pageHorizontal) + } + } + .contentShape(Rectangle()) + + return Group { + if showsDrag { + row + } else { + Button { jumpTo(index) } label: { row } + .buttonStyle(.plain) + } + } + .contextMenu { + sessionOrderContextMenu(we: we, index: index, isCurrent: isCurrent) + } + } + + // MARK: - Session order row parts + + @ViewBuilder + private func sessionOrderTrailing(index: Int, isCurrent: Bool, showsDrag: Bool, done: Int, total: Int) -> some View { + if showsDrag { + HStack(spacing: 4) { + Button { + moveExercise(from: index, to: index - 1) + } label: { + Image(systemName: "chevron.up") + .font(.system(size: 13, weight: .bold)) + .frame(width: 32, height: 32) + } + .disabled(index == 0) + Button { + moveExercise(from: index, to: index + 1) + } label: { + Image(systemName: "chevron.down") + .font(.system(size: 13, weight: .bold)) + .frame(width: 32, height: 32) + } + .disabled(index >= exercises.count - 1) + } + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + .buttonStyle(.plain) + } else if done >= total { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(BFColors.accentText(for: colorScheme)) + } else if isCurrent { + Text("Now") + .font(BFTypography.captionEmphasis) + .foregroundStyle(BFColors.accentText(for: colorScheme)) + } else { + Image(systemName: "play.fill") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(BFColors.accentText(for: colorScheme)) + } + } + + @ViewBuilder + private func sessionOrderContextMenu(we: WorkoutExercise, index: Int, isCurrent: Bool) -> some View { + if index > 0 { + Button { + moveExercise(from: index, to: index - 1) + } label: { + Label("Move up", systemImage: "arrow.up") + } + } + if index < exercises.count - 1 { + Button { + moveExercise(from: index, to: index + 1) + } label: { + Label("Move down", systemImage: "arrow.down") + } + Button { + linkSuperset(index, with: index + 1) + } label: { + Label("Superset with next", systemImage: "link") + } + } + if isSuperset(we.id) { + Button(role: .destructive) { + unlinkSuperset(we.id) + } label: { + Label("Break superset", systemImage: "link.badge.plus") + } + } + if !isCurrent { + Button { + jumpTo(index) + } label: { + Label("Train now", systemImage: "play.fill") + } + } + } +} diff --git a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sections.swift b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sections.swift new file mode 100644 index 0000000..d160048 --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sections.swift @@ -0,0 +1,304 @@ +import BetterFit +import SwiftUI +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - Sections + +extension ActiveSessionView { + + // MARK: - Top bar + + var topBar: some View { + HStack(spacing: 12) { + Button { + closeSession() + } label: { + Image(systemName: embeddedInTab ? "chevron.down" : "xmark") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(embeddedInTab ? "Back to plan" : "Close") + + Text(sessionTitle) + .font(BFTypography.footnote) + .monospacedDigit() + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + .frame(maxWidth: .infinity) + + Text(elapsedLabel) + .font(BFTypography.subheadlineEmphasis) + .monospacedDigit() + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + .padding(.trailing, 6) + } + .padding(.horizontal, 16) + .padding(.bottom, 10) + } + + var sessionTitle: String { + let name = workout?.name ?? "Session" + return "\(name) · \(String(format: "%02d", exerciseIndex + 1)) of \(String(format: "%02d", totalExercises))" + } + + // MARK: - Exercise header + + func exerciseHeader(_ we: WorkoutExercise) -> some View { + let total = max(setCount(for: we), 1) + let currentSet = min(setIndex + 1, total) + let videoURL = DemoVideoLibrary.videoURL(for: we.exercise.name) + return VStack(alignment: .leading, spacing: 0) { + // Demo / form video for the current movement. + DemoVideoPlayer( + url: videoURL, + height: 180, + fallbackGradient: [ + BFColors.surfaceRaised(for: colorScheme), + BFColors.surface(for: colorScheme), + ] + ) + .overlay(alignment: .bottomLeading) { + if videoURL != nil { + Text("FORM") + .font(BFTypography.captionEmphasis) + .foregroundStyle(BFColors.accentInk) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(BFColors.accent)) + .padding(10) + } + } + .padding(.bottom, 16) + + BFEyebrow( + text: "Exercise \(String(format: "%02d", exerciseIndex + 1)) · \(regionLabel(we))", + color: BFColors.accentText(for: colorScheme) + ) + Text(we.exercise.name) + .font(BFTypography.largeTitle) + .tracking(BFTypography.displayTracking) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + .padding(.top, 8) + + Text("Set \(currentSet) of \(total)") + .font(BFTypography.eyebrow) + .textCase(.uppercase) + .tracking(BFTypography.labelTracking) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + .padding(.top, 18) + + HStack(spacing: 12) { + headerField( + label: "Weight", + unit: "kg", + focus: .headerLoad, + keyboard: .decimalPad, + text: headerLoadDraft(for: we), + onCommit: { commitHeaderLoad(we: we) } + ) + headerField( + label: "Reps", + unit: nil, + focus: .headerReps, + keyboard: .numberPad, + text: headerRepsDraft(for: we), + onCommit: { commitHeaderReps(we: we) } + ) + } + .padding(.top, 10) + } + .padding(.horizontal, BFSpacing.pageHorizontal) + .padding(.top, 4) + .padding(.bottom, 20) + .overlay(alignment: .bottom) { + Rectangle() + .fill(BFColors.separator(for: colorScheme)) + .frame(height: 1) + } + } + + func headerField( + label: String, + unit: String?, + focus: FieldFocus, + keyboard: UIKeyboardType, + text: Binding, + onCommit: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(label) + .font(BFTypography.eyebrow) + .textCase(.uppercase) + .tracking(BFTypography.labelTracking) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + HStack(spacing: 4) { + TextField("0", text: text) + .keyboardType(keyboard) + .font(BFTypography.stepperValue) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .focused($focusedField, equals: focus) + .onSubmit(onCommit) + .onChange(of: focusedField) { _, newValue in + if newValue != focus { onCommit() } + } + if let unit { + Text(unit) + .font(BFTypography.subheadlineEmphasis) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + } + } + .padding(.horizontal, 12) + .frame(height: 46) + .background( + RoundedRectangle(cornerRadius: BFRadius.medium, style: .continuous) + .fill(BFColors.surfaceRaised(for: colorScheme)) + ) + .overlay { + RoundedRectangle(cornerRadius: BFRadius.medium, style: .continuous) + .stroke( + focusedField == focus + ? BFColors.accent + : BFColors.border(for: colorScheme), + lineWidth: focusedField == focus ? 1.5 : 1 + ) + } + } + .frame(maxWidth: .infinity) + } + + func regionLabel(_ we: WorkoutExercise) -> String { + we.exercise.muscleGroups.first?.rawValue.capitalized ?? "Lift" + } + + // MARK: - Rest + + var restBar: some View { + HStack(spacing: 12) { + Text("Rest") + .font(BFTypography.footnoteEmphasis) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + Text(String(format: "%d:%02d", remaining / 60, remaining % 60)) + .font(BFTypography.statMedium) + .monospacedDigit() + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Spacer() + Button("+15s") { + remaining += 15 + } + .font(BFTypography.footnoteEmphasis) + .foregroundStyle(BFColors.accentText(for: colorScheme)) + Button("Skip") { + stopRest() + } + .font(BFTypography.footnoteEmphasis) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + } + .padding(.horizontal, BFSpacing.pageHorizontal) + .padding(.vertical, 12) + .background(BFColors.surfaceRaised(for: colorScheme)) + } + + // MARK: - Quick actions + + func quickActions(_ we: WorkoutExercise) -> some View { + var items: [BFQuickAction] = [ + BFQuickAction(systemImage: "doc.on.doc", label: "Same for all sets") { + applyCurrentToAll(we) + }, + BFQuickAction(systemImage: "plus.circle", label: "Add a set") { + addSet(to: we) + }, + BFQuickAction( + systemImage: isSuperset(we.id) ? "link.badge.plus" : "link", + label: isSuperset(we.id) ? "Unlink superset" : "Superset next" + ) { + toggleSuperset(withNextOf: we.id) + }, + BFQuickAction(systemImage: "arrow.up.arrow.down", label: editMode.isEditing ? "Done ordering" : "Reorder") { + withAnimation { + editMode = editMode.isEditing ? .inactive : .active + } + }, + BFQuickAction(systemImage: "timer", label: "Rest \(preferredRest)s") { + startRest() + }, + ] + return BFQuickActions(items: items) + .padding(.top, 14) + .padding(.bottom, 4) + } + + // MARK: - Finish + + var finishButton: some View { + Button { + showFinish = true + } label: { + Text("Finish workout") + .font(BFTypography.subheadlineEmphasis) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + .frame(maxWidth: .infinity) + .frame(height: BFControlSize.buttonMedium) + .overlay { + RoundedRectangle(cornerRadius: BFRadius.button, style: .continuous) + .stroke(BFColors.borderStrong(for: colorScheme), lineWidth: 1) + } + } + .buttonStyle(.plain) + .padding(.horizontal, BFSpacing.pageHorizontal) + .padding(.top, 26) + } + + // MARK: - Dock + + var dock: some View { + let complete = current.map { loggedCount(for: $0) >= setCount(for: $0) } ?? true + return HStack(spacing: 10) { + BFDockPrimaryButton( + title: complete + ? "Next exercise" + : "Log \(BFFormat.trimmed(weight)) × \(reps)", + systemImage: complete ? "arrow.right" : "checkmark", + standalone: false + ) { + if complete { + advanceExercise() + } else { + logCurrentSet() + } + } + + BFDockButton(systemImage: "timer", label: "Start rest timer") { + startRest() + } + BFDockButton(systemImage: "plus", label: "Add exercise") { + showAddExercise = true + } + } + .padding(.horizontal, 16) + .padding(.bottom, embeddedInTab ? 78 : 16) + } + + var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "dumbbell") + .font(.system(size: 40)) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + Text("No exercises planned") + .font(BFTypography.headline) + Text("Add a lift to start logging sets.") + .font(BFTypography.subheadline) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + Button("Add exercise") { showAddExercise = true } + .buttonStyle(.bfPrimary) + .padding(.horizontal, 40) + .padding(.top, 8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sets.swift b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sets.swift new file mode 100644 index 0000000..1fa4cd8 --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sets.swift @@ -0,0 +1,379 @@ +import BetterFit +import SwiftUI +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - Sets + +extension ActiveSessionView { + + // MARK: - Sets section + + func setsSection(_ we: WorkoutExercise) -> some View { + let total = setCount(for: we) + let done = loggedCount(for: we) + return VStack(alignment: .leading, spacing: 0) { + BFSectionRule(label: "This exercise", trailing: "\(done) of \(total) logged") + ForEach(0.. some View { + let logged = isSetCompleted(we: we, index: index) + let isCurrent = index == setIndex && !logged + return HStack(spacing: 10) { + Button { + if !logged { + commitAllDrafts(we: we) + setIndex = index + let pair = setValues(for: we, index: index) + weight = pair.load + reps = pair.reps + syncDraftsFromModel(we: we) + } + } label: { + Text(String(format: "%02d", index + 1)) + .font(BFTypography.gutter) + .foregroundStyle( + isCurrent + ? BFColors.accentText(for: colorScheme) + : BFColors.textTertiary(for: colorScheme) + ) + .frame(width: 28, alignment: .leading) + } + .buttonStyle(.plain) + + // Editable kg + setInputField( + text: setLoadDraft(we: we, index: index), + unit: "kg", + focus: .setLoad(index), + keyboard: .decimalPad, + enabled: !logged, + onCommit: { commitSetLoad(we: we, index: index) } + ) + + Text("×") + .font(BFTypography.subheadlineEmphasis) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + + // Editable reps + setInputField( + text: setRepsDraft(we: we, index: index), + unit: nil, + focus: .setReps(index), + keyboard: .numberPad, + enabled: !logged, + onCommit: { commitSetReps(we: we, index: index) } + ) + + if logged { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(BFColors.accentText(for: colorScheme)) + .frame(width: 44, height: 44) + } else { + // Play = complete/log this set + Button { + completeSet(we: we, index: index) + } label: { + Image(systemName: isCurrent ? "play.circle.fill" : "play.circle") + .font(.system(size: 32, weight: .semibold)) + .foregroundStyle(BFColors.accent) + .frame(width: 44, height: 44) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Complete set \(index + 1)") + } + } + .padding(.vertical, 12) + .opacity(logged ? 0.7 : 1) + .overlay(alignment: .top) { + Rectangle() + .fill(BFColors.separator(for: colorScheme)) + .frame(height: 1) + } + .overlay(alignment: .leading) { + if isCurrent { + Rectangle() + .fill(BFColors.accent) + .frame(width: 3) + .offset(x: -BFSpacing.pageHorizontal) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if logged { + // Swipe right → uncheck / clear this set + Button { + unmarkSet(we: we, index: index) + } label: { + Label("Untick", systemImage: "arrow.uturn.backward") + } + .tint(BFColors.yellowDeep) + } + // Swipe right → delete the set entirely + Button(role: .destructive) { + removeSet(we: we, index: index) + } label: { + Label("Remove", systemImage: "trash") + } + } + } + + func setInputField( + text: Binding, + unit: String?, + focus: FieldFocus, + keyboard: UIKeyboardType, + enabled: Bool, + onCommit: @escaping () -> Void + ) -> some View { + HStack(spacing: 4) { + TextField("0", text: text) + .keyboardType(keyboard) + .font(BFTypography.setValue) + .foregroundStyle( + enabled + ? BFColors.textPrimary(for: colorScheme) + : BFColors.textSecondary(for: colorScheme) + ) + .multilineTextAlignment(.trailing) + .disabled(!enabled) + .focused($focusedField, equals: focus) + .onSubmit(onCommit) + .onChange(of: focusedField) { _, newValue in + if newValue != focus { onCommit() } + } + if let unit { + Text(unit) + .font(BFTypography.captionEmphasis) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(minWidth: 92) + .background( + RoundedRectangle(cornerRadius: BFRadius.medium, style: .continuous) + .fill(BFColors.surfaceRaised(for: colorScheme).opacity(enabled ? 1 : 0.5)) + ) + .overlay { + RoundedRectangle(cornerRadius: BFRadius.medium, style: .continuous) + .stroke( + focusedField == focus + ? BFColors.accent + : BFColors.border(for: colorScheme), + lineWidth: focusedField == focus ? 1.5 : 1 + ) + } + } + + // MARK: - Set editing + + func loadDefaults(for we: WorkoutExercise) { + let pair = setValues(for: we, index: setIndex) + weight = pair.load + reps = pair.reps + syncDraftsFromModel(we: we) + } + + /// Update set at `index`. Copies values forward to remaining unlogged sets. + /// Logged sets are never changed. + func updateSet(we: WorkoutExercise, index: Int, load: Double, reps: Int) { + guard !isSetCompleted(we: we, index: index) else { return } + + let safeLoad = load + let safeReps = max(1, reps) + + var overrides = setOverrides[we.id] ?? defaultSets(for: we) + let total = max(overrides.count, setCount(for: we), index + 1) + while overrides.count < total { + let prev = overrides.last ?? (safeLoad, safeReps) + overrides.append(prev) + } + + // This set + every remaining unlogged set. + for setIdx in index.. 0 })?.load ?? weight + load = fallback > 0 ? fallback : load + } + + updateSet(we: we, index: index, load: load, reps: repsVal) + setIndex = index + weight = load + reps = repsVal + markSetCompleted(we: we, index: index, load: load, reps: repsVal) + } + + func bumpSet(we: WorkoutExercise, loadDelta: Double, repsDelta: Int) { + let pair = setValues(for: we, index: setIndex) + let newLoad = max(0, roundedToTenth(pair.load + loadDelta)) + let newReps = max(1, pair.reps + repsDelta) + updateSet(we: we, index: setIndex, load: newLoad, reps: newReps) + } + + func logCurrentSet() { + guard let we = current else { return } + // Complete whatever set is currently selected. + completeSet(we: we, index: setIndex) + } + + func markSetCompleted(we: WorkoutExercise, index: Int, load: Double, reps: Int) { + var done = completedSetIndexes[we.id] ?? [] + done.insert(index) + completedSetIndexes[we.id] = done + + // Persist into workout model + if var updatedWorkout = workout, let idx = updatedWorkout.exercises.firstIndex(where: { $0.id == we.id }) { + var sets = updatedWorkout.exercises[idx].sets + let logged = ExerciseSet(reps: reps, weight: load, isCompleted: true, timestamp: Date()) + if index < sets.count { + sets[index] = logged + } else { + while sets.count < index { sets.append(ExerciseSet(reps: reps, weight: load)) } + sets.append(logged) + } + updatedWorkout.exercises[idx].sets = sets + betterFit?.updateActiveWorkout(updatedWorkout) + } + + UIImpactFeedbackGenerator(style: .light).impactOccurred() + + // Superset: after logging a set, flip to the partner for the same round + // before resting (A1 → B1 → rest → A2 → B2 …). + if let partnerId = supersetPartner(of: we.id), + let partnerIdx = exercises.firstIndex(where: { $0.id == partnerId }) + { + let partner = exercises[partnerIdx] + // Partner still needs this same set index. + if !isSetCompleted(we: partner, index: index) { + exerciseIndex = partnerIdx + setIndex = index + loadDefaults(for: partner) + stopRest() + return + } + } + + // Advance to next incomplete set on this exercise, else leave setIndex at end. + if let nextOpen = (0.. Double { + (value * 10).rounded() / 10 +} diff --git a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift index f248dca..32fc7d3 100644 --- a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift @@ -1,517 +1,161 @@ import BetterFit import SwiftUI +#if canImport(UIKit) + import UIKit +#endif -// MARK: - Active Session Sheet -// -// A minimal set-tracker that appears as a sheet when the user pauses their -// active workout. Lets the lifter log each set (weight + reps), advance to -// the next set / next exercise, and end the workout. Built around -// BetterFit.updateActiveWorkout so logged sets persist into the workout -// record the same way the original ActiveWorkout screens did. +// MARK: - Live session — Ledger layout struct ActiveSessionView: View { - @Environment(\.dismiss) private var dismiss + @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var colorScheme + let betterFit: BetterFit? + var theme: AppTheme = .defaultTheme let onEnd: () -> Void - - @State private var setsPerExercise: [UUID: [LoggedSet]] = [:] - @State private var weightText: [UUID: String] = [:] - @State private var repsText: [UUID: String] = [:] - @State private var currentSetByExercise: [UUID: Int] = [:] - @State private var restTimer: RestTimerState? = nil - @State private var preferredRestSeconds: Int = 90 - @State private var selectedExerciseID: UUID? - @State private var showFinishConfirmation = false - - private var workout: Workout? { betterFit?.getActiveWorkout() } + /// Called when the user closes/minimizes back to Plan without finishing. + var onClose: (() -> Void)? = nil + var onFinishedSummary: ((WorkoutSummaryData) -> Void)? = nil + /// When true, rendered inside the Workout tab (no modal chrome expectations). + var embeddedInTab = false + + @State var exerciseIndex = 0 + @State var setIndex = 0 + @State var weight: Double = 60 + @State var reps: Int = 8 + /// Completed set indexes per exercise (supports completing any open set). + @State var completedSetIndexes: [UUID: Set] = [:] + @State var setOverrides: [UUID: [(load: Double, reps: Int)]] = [:] + /// ID of the workout this state belongs to. Used to reset kg/reps when + /// the user starts a new workout session so separate sessions stay + /// independent and don't inherit the previous session's overrides. + @State var sessionWorkoutID: UUID? + /// Custom session order (supports out-of-order training + drag reorder). + @State var orderedIDs: [UUID] = [] + /// Superset groups: exercise id → shared group id (same id = paired). + @State var supersetGroup: [UUID: UUID] = [:] + /// Draft text while typing so fields stay editable mid-keystroke. + @State var draftLoad: [String: String] = [:] + @State var draftReps: [String: String] = [:] + @FocusState var focusedField: FieldFocus? + @State var resting = false + @State var remaining = 90 + @State var preferredRest = 90 + @State var startedAt = Date() + @State var tick = false + @State var timer: Timer? + @State var restTimer: Timer? + @State var showFinish = false + @State var showAddExercise = false + @State var editMode: EditMode = .inactive + + enum FieldFocus: Hashable { + case headerLoad + case headerReps + case setLoad(Int) + case setReps(Int) + } + + var workout: Workout? { betterFit?.getActiveWorkout() } + + /// Exercises in the user's working order. + var exercises: [WorkoutExercise] { + let all = workout?.exercises ?? [] + if orderedIDs.isEmpty { return all } + let mapped = orderedIDs.compactMap { id in all.first(where: { $0.id == id }) } + // Append any new exercises not yet in the order list. + let missing = all.filter { ex in !orderedIDs.contains(ex.id) } + return mapped + missing + } + + var current: WorkoutExercise? { + guard exercises.indices.contains(exerciseIndex) else { return exercises.first } + return exercises[exerciseIndex] + } + + var totalExercises: Int { max(exercises.count, 1) } + + func isSuperset(_ id: UUID) -> Bool { + supersetGroup[id] != nil + } + + func supersetPartner(of id: UUID) -> UUID? { + guard let group = supersetGroup[id] else { return nil } + return orderedIDs.first { $0 != id && supersetGroup[$0] == group } + } + + func supersetLabel(for id: UUID) -> String? { + guard let group = supersetGroup[id] else { return nil } + let members = orderedIDs.filter { supersetGroup[$0] == group } + guard members.count >= 2, let pos = members.firstIndex(of: id) else { return "Superset" } + return "Superset \(pos + 1)/\(members.count)" + } + + var elapsedLabel: String { + _ = tick + let secs = Int(Date().timeIntervalSince(startedAt)) + return String(format: "%02d:%02d", secs / 60, secs % 60) + } + + // MARK: - View var body: some View { - NavigationStack { - VStack(spacing: 0) { - if let restTimer { - RestTimerBar( - state: restTimer, - onSkip: { self.restTimer = nil }, - onAddSeconds: { self.restTimer?.addSeconds(15) } - ) - .transition(.move(edge: .top).combined(with: .opacity)) - } - - Group { - if let workout, !workout.exercises.isEmpty { - exerciseList(workout: workout) - } else { - emptyState - } - } - } - .animation(BFAnimation.standard, value: restTimer) - .navigationTitle("Active Workout") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close") { dismiss() } - } - ToolbarItem(placement: .primaryAction) { - Button(role: .destructive) { - showFinishConfirmation = true - } label: { - Text("Finish") - } - } - } - .confirmationDialog( - "Finish Workout?", - isPresented: $showFinishConfirmation, - titleVisibility: .visible - ) { - Button("Finish & Save") { - onEnd() - dismiss() - } - Button("Keep Training", role: .cancel) {} - } - } - } - - // MARK: - Exercise list - - @ViewBuilder - private func exerciseList(workout: Workout) -> some View { - let selectedExercise = - workout.exercises.first(where: { $0.id == selectedExerciseID }) - ?? workout.exercises.first - let otherExercises = workout.exercises.filter { $0.id != selectedExercise?.id } - - ScrollView { - VStack(spacing: BFSpacing.lg) { - if let selectedExercise { - Text("Current exercise") - .bfSectionLabel() - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - .frame(maxWidth: .infinity, alignment: .leading) + ZStack(alignment: .bottom) { + BFColors.background(for: colorScheme).ignoresSafeArea() - exerciseCard(selectedExercise) - } - - if !otherExercises.isEmpty { - VStack(alignment: .leading, spacing: BFSpacing.sm) { - Text("Exercises") - .bfSectionLabel() - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - - ForEach(otherExercises) { exercise in - ActiveExercisePickerRow(exercise: exercise) { - selectedExerciseID = exercise.id - } - } - } - } - } - .padding(.horizontal, BFSpacing.lg) - .padding(.vertical, BFSpacing.md) - } - .background(BFColors.background(for: colorScheme)) - .onAppear { - if selectedExerciseID == nil { - selectedExerciseID = workout.exercises.first?.id - } - } - } - - @ViewBuilder - private func exerciseCard(_ we: WorkoutExercise) -> some View { - let current = currentSetByExercise[we.id] ?? 0 - let total = we.sets.count - let logged = setsPerExercise[we.id] ?? [] - let nextSetLabel = nextSetLabel(we: we, current: current) - VStack(alignment: .leading, spacing: BFSpacing.md) { - HStack(alignment: .firstTextBaseline) { - Text(we.exercise.name) - .font(BFTypography.title2) - Spacer() - Text(nextSetLabel) - .font(BFTypography.captionEmphasis) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - - BFDSCard(padding: BFSpacing.md) { - VStack(spacing: BFSpacing.sm) { - if total == 0 { - Text("No sets planned for this exercise.") - .font(BFTypography.subheadline) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } else { - HStack(spacing: BFSpacing.md) { - ActiveSessionField( - label: "Weight", - unit: "lbs", - binding: weightBinding(for: we.id, default: weightSuggestion(for: we)) - ) - ActiveSessionField( - label: "Reps", - unit: "reps", - binding: repsBinding(for: we.id, default: "\(repsSuggestion(for: we))") - ) - } - - Button { - logSet(for: we) - } label: { - HStack { - Image(systemName: "checkmark.circle.fill") - Text("Log Set") + VStack(spacing: 0) { + topBar + if let current { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + exerciseHeader(current) + if resting { + restBar } + quickActions(current) + setsSection(current) + sessionOrderSection + finishButton } - .buttonStyle(.bfPrimary) + .padding(.bottom, 140) } + } else { + emptyState } } - if !logged.isEmpty { - VStack(alignment: .leading, spacing: BFSpacing.xs) { - Text("Completed sets") - .font(BFTypography.captionEmphasis) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - ForEach(Array(logged.enumerated()), id: \.offset) { idx, set in - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text("Set \(idx + 1) — \(set.reps) reps × \(formatted(set.weight)) lbs") - .font(BFTypography.subheadline) - .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - Spacer() - } - } - } + if current != nil { + dock } } - .padding(.bottom, BFSpacing.sm) - } - - private var emptyState: some View { - VStack(spacing: BFSpacing.md) { - Image(systemName: "dumbbell") - .font(.system(size: 44)) - .foregroundStyle(BFColors.textTertiary(for: colorScheme)) - Text("No exercises in this workout.") - .font(BFTypography.subheadline) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - // MARK: - Actions - - private func logSet(for we: WorkoutExercise) { - let weight = Double(weightText[we.id] ?? "") ?? we.sets.first?.weight ?? 0 - let reps = Int(repsText[we.id] ?? "") ?? we.sets.first?.reps ?? 0 - guard reps > 0 else { return } - - let logged = LoggedSet(reps: reps, weight: weight, timestamp: Date()) - var entries = setsPerExercise[we.id] ?? [] - entries.append(logged) - setsPerExercise[we.id] = entries - - // Persist into the workout model. - if var workout, let idx = workout.exercises.firstIndex(where: { $0.id == we.id }) { - let updatedSet = ExerciseSet( - reps: reps, - weight: weight, - isCompleted: true, - timestamp: Date(), - autoTracked: false - ) - var updatedSets = workout.exercises[idx].sets - let pos = currentSetByExercise[we.id] ?? 0 - if pos < updatedSets.count { - updatedSets[pos] = updatedSet - } else { - updatedSets.append(updatedSet) - } - workout.exercises[idx].sets = updatedSets - betterFit?.updateActiveWorkout(workout) - } - - // Advance. - let nextSet = (currentSetByExercise[we.id] ?? 0) + 1 - currentSetByExercise[we.id] = nextSet - if nextSet >= we.sets.count { - selectNextExercise(after: we.id) - } - - // Kick off the rest timer. UIImpactFeedbackGenerator gives a small - // haptic so the lifter knows the set was logged. - restTimer = RestTimerState(start: Date(), duration: TimeInterval(preferredRestSeconds)) - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - - private func weightSuggestion(for we: WorkoutExercise) -> String { - if let weight = we.sets.first?.weight { return String(Int(weight)) } - return "0" - } - - private func repsSuggestion(for we: WorkoutExercise) -> Int { - we.sets.first?.reps ?? 8 - } - - private func nextSetLabel(we: WorkoutExercise, current: Int) -> String { - let total = we.sets.count - if total == 0 { return "No sets" } - if current >= total { return "Complete" } - let next = min(current + 1, total) - return "Set \(next) of \(total)" - } - - private func selectNextExercise(after exerciseID: UUID) { - guard let workout, - let index = workout.exercises.firstIndex(where: { $0.id == exerciseID }), - workout.exercises.indices.contains(index + 1) - else { - return + .onAppear(perform: bootstrap) + .onDisappear { + timer?.invalidate() + restTimer?.invalidate() } - selectedExerciseID = workout.exercises[index + 1].id - } - - private func formatted(_ weight: Double) -> String { - weight.truncatingRemainder(dividingBy: 1) == 0 - ? String(Int(weight)) - : String(format: "%.1f", weight) - } - - /// Stable Binding factories that resolve the dictionary at view-build time. - /// SwiftUI's `$weightText[uuid, default: "0"]` form isn't a Binding in - /// this version of Swift (the default parameter makes the subscript - /// a value projection, not a writable one), so we project manually. - private func weightBinding(for id: UUID, default defaultText: String) -> Binding { - Binding( - get: { weightText[id] ?? defaultText }, - set: { weightText[id] = $0 } - ) - } - - private func repsBinding(for id: UUID, default defaultText: String) -> Binding { - Binding( - get: { repsText[id] ?? defaultText }, - set: { repsText[id] = $0 } - ) - } - - @Environment(\.colorScheme) private var colorScheme -} - -// MARK: - Subviews - -private struct ActiveExercisePickerRow: View { - let exercise: WorkoutExercise - let action: () -> Void - - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - Button(action: action) { - HStack(spacing: BFSpacing.md) { - Image(systemName: "dumbbell.fill") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(BFColors.brandAccent) - .frame(width: BFControlSize.minTapTarget, height: BFControlSize.minTapTarget) - .background(Circle().fill(BFColors.brandAccent.opacity(0.15))) - - VStack(alignment: .leading, spacing: 2) { - Text(exercise.exercise.name) - .font(BFTypography.subheadlineEmphasis) - .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - Text("\(exercise.sets.count) sets") - .font(BFTypography.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - + .toolbar { + ToolbarItemGroup(placement: .keyboard) { Spacer() - - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(BFColors.textTertiary(for: colorScheme)) - } - .padding(BFSpacing.md) - .background( - RoundedRectangle(cornerRadius: BFRadius.card, style: .continuous) - .fill(BFColors.surface(for: colorScheme)) - ) - .overlay { - RoundedRectangle(cornerRadius: BFRadius.card, style: .continuous) - .stroke(BFColors.border(for: colorScheme), lineWidth: 1) + Button("Done") { + commitFocusedField() + focusedField = nil + } + .fontWeight(.semibold) } } - .buttonStyle(.plain) - } -} - -private struct ActiveSessionField: View { - let label: String - let unit: String - @Binding var binding: String - - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(BFTypography.captionEmphasis) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - HStack(spacing: 6) { - TextField(unit, text: $binding) - .keyboardType(.decimalPad) - .font(BFTypography.headline) - .multilineTextAlignment(.center) - .frame(minWidth: 48) - .padding(.vertical, 6) - .padding(.horizontal, 10) - .background( - RoundedRectangle(cornerRadius: BFRadius.small, style: .continuous) - .fill(BFColors.surfaceRaised(for: colorScheme)) - ) - Text(unit) - .font(BFTypography.caption) - .foregroundStyle(BFColors.textTertiary(for: colorScheme)) - } + .confirmationDialog("Finish workout?", isPresented: $showFinish, titleVisibility: .visible) { + Button("Finish & save") { finish() } + Button("Keep training", role: .cancel) {} } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -// MARK: - Local types - -struct LoggedSet: Identifiable { - let id = UUID() - let reps: Int - let weight: Double - let timestamp: Date -} - -// MARK: - Rest Timer - -/// Countdown state for the post-set rest period. Stays in the view as an -/// optional so the bar disappears naturally when the timer ends or is -/// skipped. -struct RestTimerState: Equatable { - let start: Date - var duration: TimeInterval - - /// Remaining time in seconds, clamped to zero. Negative values from - /// elapsed > duration are clamped so the progress / countdown never - /// display a negative number. - func remaining(at now: Date) -> TimeInterval { - let elapsed = now.timeIntervalSince(start) - return max(0, duration - elapsed) - } - - func progress(at now: Date) -> Double { - guard duration > 0 else { return 1 } - return min(1, max(0, 1 - remaining(at: now) / duration)) - } - - func isFinished(at now: Date) -> Bool { - remaining(at: now) <= 0 - } - - /// Add extra seconds to the countdown (the '+15s' button). - mutating func addSeconds(_ seconds: TimeInterval) { - duration += seconds - } -} - -/// Sticky bar at the top of the active session. Renders a circular ring + -/// mm:ss countdown, a 'Skip' button, and a '+15s' quick-add. Uses -/// TimelineView to redraw every second without a manual Timer. -struct RestTimerBar: View { - let state: RestTimerState - let onSkip: () -> Void - let onAddSeconds: () -> Void - - @Environment(\.colorScheme) private var scheme - - var body: some View { - TimelineView(.periodic(from: .now, by: 1)) { context in - let now = context.date - let remaining = state.remaining(at: now) - let progress = state.progress(at: now) - let finished = state.isFinished(at: now) - - HStack(spacing: BFSpacing.md) { - ZStack { - Circle() - .stroke(BFColors.surfaceRaised(for: scheme), lineWidth: 3) - - Circle() - .trim(from: 0, to: progress) - .stroke( - finished ? .green : BFColors.brandAccent, - style: StrokeStyle(lineWidth: 3, lineCap: .round) - ) - .rotationEffect(.degrees(-90)) - .animation(.linear(duration: 0.2), value: progress) - - Text(formatted(remaining)) - .font(BFTypography.headline) - .monospacedDigit() - .foregroundStyle(BFColors.textPrimary(for: scheme)) - } - .frame(width: 52, height: 52) - - VStack(alignment: .leading, spacing: 2) { - Text(finished ? "Rest done" : "Rest timer") - .font(BFTypography.subheadlineEmphasis) - .foregroundStyle(BFColors.textPrimary(for: scheme)) - Text(finished ? "Ready for the next set" : "Between sets") - .font(BFTypography.caption) - .foregroundStyle(BFColors.textSecondary(for: scheme)) - } - - Spacer() - - Button { - onAddSeconds() - } label: { - Text("+15s") - .font(BFTypography.captionEmphasis) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - Capsule().fill(BFColors.surfaceRaised(for: scheme)) - ) - .foregroundStyle(BFColors.textPrimary(for: scheme)) - } - .buttonStyle(.plain) - - Button(role: .destructive) { - onSkip() - } label: { - Text("Skip") - .font(BFTypography.captionEmphasis) - .padding(.horizontal, 10) - .padding(.vertical, 6) - } - .buttonStyle(.plain) - } - .padding(.horizontal, BFSpacing.lg) - .padding(.vertical, BFSpacing.md) - .background(BFColors.backgroundElevated(for: scheme)) - .overlay(alignment: .bottom) { - Rectangle() - .fill(BFColors.separator(for: scheme)) - .frame(height: 1) + .sheet(isPresented: $showAddExercise) { + AddExerciseSheet(theme: theme) { planned in + addExercise(planned) } + .presentationDetents([.medium, .large]) } } - - private func formatted(_ seconds: TimeInterval) -> String { - let total = Int(seconds.rounded(.up)) - return String(format: "%d:%02d", total / 60, total % 60) - } } -// MARK: - Animation helper - -private enum BFAnimation { - static let standard: Animation = .easeInOut(duration: 0.25) +#Preview { + ActiveSessionView(betterFit: BetterFit(), onEnd: {}) } diff --git a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/WorkoutSummaryView.swift b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/WorkoutSummaryView.swift new file mode 100644 index 0000000..93275e2 --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/WorkoutSummaryView.swift @@ -0,0 +1,256 @@ +import SwiftUI + +// MARK: - Summary data + +struct WorkoutSummaryData { + var name: String + var duration: TimeInterval + var volume: Double = 0 + var sets: Int = 0 + var note: String = "Good work. Consistency compounds." + var exercises: [WorkoutSummaryExercise] + /// Readiness change per trained muscle region, computed at finish from the + /// session's logged sets and `BetterFit`'s recovery map. Empty when it + /// cannot be derived — the summary hides the section rather than faking it. + var recoveryEffects: [(muscle: String, from: Int, to: Int)] = [] +} + +struct WorkoutSummaryExercise: Identifiable { + let id = UUID() + let name: String + let best: String + let setsDone: Int + let setsPlanned: Int + let volume: Double + let isPR: Bool +} + +// MARK: - Workout summary — Ledger layout + +struct WorkoutSummaryView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + let data: WorkoutSummaryData + var onDone: () -> Void = {} + + var body: some View { + ZStack(alignment: .bottom) { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + topBar + slab + content + } + .padding(.bottom, 120) + } + .background(BFColors.background(for: colorScheme).ignoresSafeArea()) + + BFGlassDock { + Button { + onDone() + dismiss() + } label: { + Text("Done") + .font(BFTypography.headline) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + .frame(maxWidth: .infinity) + .frame(height: BFControlSize.buttonLarge) + .background { + Capsule(style: .continuous) + .fill(BFColors.glassHighlight(for: colorScheme)) + .overlay { + Capsule(style: .continuous) + .stroke(BFColors.glassBorder(for: colorScheme), lineWidth: 1) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .layoutPriority(1) + } + .padding(.bottom, 16) + } + } + + // MARK: - Top + + private var topBar: some View { + HStack { + Button { + onDone() + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + Spacer() + } + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + + // MARK: - Slab + + private var slab: some View { + BFSlab { + VStack(alignment: .leading, spacing: 0) { + BFEyebrow( + text: "\(dateLabel) · Session complete", + color: BFColors.identityInk.opacity(0.6) + ) + Text(data.name) + .font(BFTypography.display) + .tracking(BFTypography.displayTracking) + .foregroundStyle(BFColors.identityInk) + .padding(.top, 10) + + BFSpecStrip( + items: [ + BFSpecItem(value: durationLabel, label: "Duration"), + BFSpecItem(value: volumeLabel, label: "Volume kg"), + BFSpecItem(value: "\(data.sets)", label: "Sets"), + ], + ink: BFColors.identityInk + ) + .padding(.top, 4) + + Text(data.note) + .font(BFTypography.subheadlineEmphasis) + .foregroundStyle(BFColors.identityInk) + .padding(.top, 18) + .padding(.top, 16) + .overlay(alignment: .top) { + Rectangle() + .fill(BFColors.identityInk.opacity(0.2)) + .frame(height: 1.5) + } + .fixedSize(horizontal: false, vertical: true) + } + } + } + + // MARK: - Content + + private var content: some View { + VStack(alignment: .leading, spacing: 0) { + let prs = data.exercises.filter(\.isPR) + if !prs.isEmpty { + BFSectionRule(label: "New records", trailing: "\(prs.count)") + ForEach(prs) { ex in + BFLedgerRow( + gutterAccent: true, + systemImage: "medal.fill", + title: ex.name, + meta: "Personal best" + ) { + BFLedgerTrailing(value: ex.best) + } + } + } + + BFSectionRule(label: "What you lifted", trailing: "\(data.exercises.count) exercises") + ForEach(Array(data.exercises.enumerated()), id: \.element.id) { index, ex in + BFLedgerRow( + gutter: String(format: "%02d", index + 1), + gutterAccent: ex.isPR, + title: ex.name, + meta: "Best \(ex.best) · \(ex.setsDone) of \(ex.setsPlanned) sets" + ) { + BFLedgerTrailing(value: volumeShort(ex.volume), meta: "kg") + } + } + + if !data.recoveryEffects.isEmpty { + BFSectionRule(label: "Effect on recovery") + ForEach(Array(data.recoveryEffects.enumerated()), id: \.offset) { _, effect in + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Text(effect.muscle) + .font(BFTypography.bodyEmphasis) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Spacer() + Text("\(effect.from)%") + .font(BFTypography.footnote) + .monospacedDigit() + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + Image(systemName: "arrow.right") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + Text("\(effect.to)%") + .font(BFTypography.subheadlineEmphasis) + .monospacedDigit() + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + } + BFBar(progress: Double(effect.to) / 100.0, color: BFColors.yellowDeep) + } + .padding(.vertical, 13) + .overlay(alignment: .top) { + Rectangle() + .fill(BFColors.separator(for: colorScheme)) + .frame(height: 1) + } + } + } + + BFSectionRule(label: "Next") + BFLedgerRow( + systemImage: "calendar", + title: "Keep the streak going", + meta: "Your next session is ready on Plan" + ) { + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + } + } + .padding(.horizontal, BFSpacing.pageHorizontal) + } + + // MARK: - Format + + private var dateLabel: String { + Date.now.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated)) + } + + private var durationLabel: String { + let mins = Int(data.duration / 60) + return "\(mins)'" + } + + private var volumeLabel: String { + if data.volume >= 1000 { + return String(format: "%.1fk", data.volume / 1000) + } + return data.volume > 0 ? String(format: "%.0f", data.volume) : "—" + } + + private func volumeShort(_ value: Double) -> String { + if value >= 1000 { return String(format: "%.1fk", value / 1000) } + return value > 0 ? String(format: "%.0f", value) : "—" + } +} + +#Preview { + WorkoutSummaryView( + data: WorkoutSummaryData( + name: "Pull Day", + duration: 44 * 60, + volume: 6700, + sets: 22, + note: "Strongest pull session this month. Cable row went up 4 kg.", + exercises: [ + WorkoutSummaryExercise(name: "Lat pulldown", best: "50 kg × 8", setsDone: 3, setsPlanned: 3, volume: 1200, isPR: false), + WorkoutSummaryExercise(name: "Cable row", best: "64 kg × 8", setsDone: 4, setsPlanned: 4, volume: 2000, isPR: true), + ], + recoveryEffects: [ + (muscle: "Back", from: 96, to: 48), + (muscle: "Arms", from: 72, to: 48), + ] + ) + ) + .preferredColorScheme(.dark) +} diff --git a/Apps/iOS/BetterFitApp/Features/AppSearch/AppSearchView.swift b/Apps/iOS/BetterFitApp/Features/AppSearch/AppSearchView.swift index edbe014..2192be8 100644 --- a/Apps/iOS/BetterFitApp/Features/AppSearch/AppSearchView.swift +++ b/Apps/iOS/BetterFitApp/Features/AppSearch/AppSearchView.swift @@ -500,7 +500,7 @@ struct AppSearchView: View { var body: some View { AppSearchView( - theme: .midnight, + theme: .fitbod, betterFit: BetterFit(), query: $query, previousTabIcon: "figure.run", diff --git a/Apps/iOS/BetterFitApp/Features/AppSearch/SettingDetailView.swift b/Apps/iOS/BetterFitApp/Features/AppSearch/SettingDetailView.swift index b62ea89..96e8254 100644 --- a/Apps/iOS/BetterFitApp/Features/AppSearch/SettingDetailView.swift +++ b/Apps/iOS/BetterFitApp/Features/AppSearch/SettingDetailView.swift @@ -244,7 +244,7 @@ struct SettingDetailView: View { SettingDetailView( settingId: "notifications", title: "Notifications", - theme: .midnight + theme: .fitbod ) } } diff --git a/Apps/iOS/BetterFitApp/Features/Auth/SignInView.swift b/Apps/iOS/BetterFitApp/Features/Auth/SignInView.swift index ff78fcb..b4fe5e5 100644 --- a/Apps/iOS/BetterFitApp/Features/Auth/SignInView.swift +++ b/Apps/iOS/BetterFitApp/Features/Auth/SignInView.swift @@ -68,16 +68,26 @@ struct SignInView: View { // MARK: - Logo & Title - VStack(spacing: 16) { - FitnessIcon(systemImage: "figure.strengthtraining.traditional", size: 80, color: theme.accent) + VStack(spacing: 16) { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(BFColors.identity) + .frame(width: 88, height: 88) + .overlay { + Text("B") + .font(BFTypography.display(48)) + .foregroundStyle(BFColors.identityInk) + } Text("BetterFit") - .bfHeading(theme: theme, size: 44, relativeTo: .largeTitle) - .foregroundStyle(theme.accent) + .font(BFTypography.display) + .tracking(BFTypography.displayTracking) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - Text("Your strength training coach") - .font(.title3) + Text("Train with direction. Get better with every session.") + .font(BFTypography.title3) + .multilineTextAlignment(.center) .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + .padding(.horizontal, 24) } Spacer() diff --git a/Apps/iOS/BetterFitApp/Features/Calendar/CalendarSheetView.swift b/Apps/iOS/BetterFitApp/Features/Calendar/CalendarSheetView.swift index cead085..c598a6e 100644 --- a/Apps/iOS/BetterFitApp/Features/Calendar/CalendarSheetView.swift +++ b/Apps/iOS/BetterFitApp/Features/Calendar/CalendarSheetView.swift @@ -36,5 +36,5 @@ struct CalendarSheetView: View { #Preview { @Previewable @State var date = Date.now - CalendarSheetView(selectedDate: $date, theme: .midnight) + CalendarSheetView(selectedDate: $date, theme: .fitbod) } diff --git a/Apps/iOS/BetterFitApp/Features/Demo/ContentView.swift b/Apps/iOS/BetterFitApp/Features/Demo/ContentView.swift index 0dba44e..e34be42 100644 --- a/Apps/iOS/BetterFitApp/Features/Demo/ContentView.swift +++ b/Apps/iOS/BetterFitApp/Features/Demo/ContentView.swift @@ -256,5 +256,5 @@ struct ContentView: View { #Preview { UserDefaults.standard.set(true, forKey: "betterfit.workoutHome.demoMode") - return ContentView(betterFit: BetterFit(), theme: .midnight) + return ContentView(betterFit: BetterFit(), theme: .fitbod) } diff --git a/Apps/iOS/BetterFitApp/Features/Log/LogView.swift b/Apps/iOS/BetterFitApp/Features/Log/LogView.swift new file mode 100644 index 0000000..2d3b6b0 --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/Log/LogView.swift @@ -0,0 +1,264 @@ +import BetterFit +import SwiftUI + +// MARK: - Log — streak, consistency, month ledger + +struct LogView: View { + @Environment(\.colorScheme) private var colorScheme + let betterFit: BetterFit + let theme: AppTheme + + @State private var showAddPast = false + + private var streak: Int { + betterFit.socialManager.getCurrentStreak() + } + + private var history: [Workout] { + betterFit.getWorkoutHistory().sorted { $0.date > $1.date } + } + + private var monthTitle: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM" + return formatter.string(from: Date()) + } + + private var heatmapValues: [Int] { + var values = Array(repeating: 0, count: 26 * 7) + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + for workout in history { + let day = cal.startOfDay(for: workout.date) + let daysAgo = cal.dateComponents([.day], from: day, to: today).day ?? 0 + guard daysAgo >= 0, daysAgo < values.count else { continue } + let idx = values.count - 1 - daysAgo + values[idx] = min(4, values[idx] + 1) + } + return values + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + BFReadout( + value: "\(streak)", + unit: "days", + label: "Current streak", + note: streakNote + ) + + BFSectionRule(label: "Consistency", trailing: "26 weeks") + LogContributionHeatmap(values: heatmapValues) + .padding(.vertical, 4) + + BFSectionRule( + label: monthTitle, + trailing: "\(monthSessions.count) sessions" + ) + + if monthLedger.isEmpty { + BFEmptyState( + systemImage: "calendar", + title: "No sessions this month", + message: "Completed workouts show up here." + ) + } else { + ForEach(Array(monthLedger.enumerated()), id: \.offset) { _, entry in + dayRow(entry) + } + } + + BFAddRow(label: "Log a past workout") { + showAddPast = true + } + + BFSectionRule(label: "All time") + BFLedgerRow(systemImage: "dumbbell.fill", title: "Sessions") { + BFLedgerTrailing(value: "\(history.count)") + } + BFLedgerRow(systemImage: "scalemass.fill", title: "Volume lifted") { + BFLedgerTrailing(value: totalVolumeLabel, meta: "kg") + } + BFLedgerRow(systemImage: "clock.fill", title: "Time training") { + BFLedgerTrailing(value: totalHoursLabel, meta: totalHoursUnit) + } + } + .padding(.horizontal, BFSpacing.pageHorizontal) + .padding(.bottom, 110) + } + .bfBackground(theme: theme) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Log") + .font(BFTypography.screenTitle) + .tracking(BFTypography.displayTracking) + } + } + .sheet(isPresented: $showAddPast) { + Text("Log a past workout") + .presentationDetents([.medium]) + } + } + + // MARK: - Month ledger + + private struct DayEntry: Identifiable { + let id = UUID() + let day: Int + let name: String + let meta: String? + let volume: String? + let isRest: Bool + let isToday: Bool + let inStreak: Bool + } + + private var monthSessions: [Workout] { + let cal = Calendar.current + let now = Date() + return history.filter { cal.isDate($0.date, equalTo: now, toGranularity: .month) } + } + + private var monthLedger: [DayEntry] { + let cal = Calendar.current + var entries: [DayEntry] = [] + + for (idx, workout) in monthSessions.prefix(14).enumerated() { + let day = cal.component(.day, from: workout.date) + let mins = Int((workout.duration ?? 0) / 60) + let vol = workout.exercises.reduce(0.0) { sum, ex in + sum + ex.sets.reduce(0.0) { $0 + (Double($1.reps) * ($1.weight ?? 0)) } + } + entries.append(DayEntry( + day: day, + name: workout.name, + meta: mins > 0 ? "\(mins) min · \(workout.exercises.count) exercises" : "\(workout.exercises.count) exercises", + volume: vol > 0 ? String(format: "%.1fk", vol / 1000) : nil, + isRest: false, + isToday: cal.isDateInToday(workout.date), + inStreak: idx < max(streak, 1) + )) + } + return entries + } + + private func dayRow(_ entry: DayEntry) -> some View { + HStack(alignment: .top, spacing: 14) { + ZStack { + Circle() + .fill(entry.inStreak ? BFColors.accent : Color.clear) + .frame(width: 32, height: 32) + Text("\(entry.day)") + .font(.system(size: 14, weight: .bold).monospacedDigit()) + .foregroundStyle( + entry.inStreak + ? BFColors.accentInk + : entry.isToday + ? BFColors.accentText(for: colorScheme) + : BFColors.textTertiary(for: colorScheme) + ) + } + .frame(width: 32) + + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline) { + Text(entry.name) + .font(BFTypography.bodyEmphasis) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Spacer() + if let volume = entry.volume { + HStack(spacing: 2) { + Text(volume) + .font(BFTypography.subheadlineEmphasis) + .monospacedDigit() + Text("kg") + .font(BFTypography.caption) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + } + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + } + } + if let meta = entry.meta { + Text(meta) + .font(BFTypography.footnote) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + } + } + } + .padding(.vertical, 14) + .opacity(entry.isRest ? 0.45 : 1) + .overlay(alignment: .top) { + Rectangle() + .fill(BFColors.separator(for: colorScheme)) + .frame(height: 1) + } + } + + // MARK: - Stats + + private var streakNote: String { + if streak == 0 { + return "Start a session today to open a new run." + } + return "Keep going. Consistency compounds — one session at a time." + } + + private var totalVolumeLabel: String { + let vol = history.reduce(0.0) { sum, workout in + sum + workout.exercises.reduce(0.0) { exerciseTotal, ex in + exerciseTotal + ex.sets.reduce(0.0) { $0 + (Double($1.reps) * ($1.weight ?? 0)) } + } + } + if vol >= 1_000_000 { return String(format: "%.2fM", vol / 1_000_000) } + if vol >= 1000 { return String(format: "%.0fk", vol / 1000) } + return vol > 0 ? String(format: "%.0f", vol) : "—" + } + + private var totalHoursLabel: String { + let secs = history.reduce(0.0) { $0 + ($1.duration ?? 0) } + let hours = secs / 3600 + return hours > 0 ? String(format: "%.0f", hours) : "—" + } + + private var totalHoursUnit: String { + totalHoursLabel == "1" ? "hour" : "hours" + } +} + +// MARK: - Compact contribution heatmap (Log) + +private struct LogContributionHeatmap: View { + let values: [Int] + var cell: CGFloat = 9 + var gap: CGFloat = 3 + @Environment(\.colorScheme) private var scheme + + private let rows = 7 + + var body: some View { + let cols = max(1, Int(ceil(Double(values.count) / Double(rows)))) + HStack(spacing: gap) { + ForEach(0.. String - - @State private var displayedValue: Double = 0 - - init(value: Double, unit: String = "", duration: Double = 1.0, formatter: ((Double) -> String)? = nil) { - self.value = value - self.unit = unit - self.duration = duration - self.formatter = formatter ?? { "\(Int($0))\(unit.isEmpty ? "" : " \(unit)")" - } - } - - var body: some View { - Text(formatter(displayedValue)) - .monospacedDigit() - .onAppear { - withAnimation(.easeOut(duration: duration)) { - displayedValue = value - } - } - } -} - -// MARK: - Personal Record - -struct PersonalRecord: Identifiable { - let id = UUID() - let exercise: String - let value: String - let date: Date - let improvement: String? - let icon: String -} - -// MARK: - Weekly Goal - -struct WeeklyGoal: Identifiable { - let id = UUID() - let title: String - let current: Double - let target: Double - let unit: String - let icon: String - let color: Color -} - -// MARK: - Achievement - -struct Achievement: Identifiable { - let id = UUID() - let title: String - let description: String - let icon: String - let isEarned: Bool - let earnedDate: Date? - let progress: Double? -} - -// MARK: - Profile View +// MARK: - Me — DS lists + cards struct ProfileView: View { - @Environment(\.colorScheme) var colorScheme - let betterFit: BetterFit? + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + + let betterFit: BetterFit let theme: AppTheme let isGuest: Bool let user: Auth.User? let onShowSignIn: () -> Void let onLogout: (() -> Void)? - @State private var showLogoutConfirmation = false - @State private var showYearlyWrapped = false - @State private var selectedYear = Calendar.current.component(.year, from: Date()) - @State private var isHeatmapExpanded = false - @State private var heatmapRange: HeatmapRange = .year - @State private var activityByDay: [Date: Int] = [:] - @State private var showAllPRs = false - @State private var showEditTargetsAlert = false - @State private var showingThemePicker = false - @State private var showingSettings = false - - @AppStorage("betterfit.workoutHome.demoMode") private var workoutHomeDemoModeEnabled = false + @State private var showSettings = false @AppStorage(AppTheme.storageKey) private var storedTheme: String = AppTheme.defaultTheme.rawValue - @AppStorage(AppearancePreference.storageKey) private var storedAppearance: String = - AppearancePreference.defaultPreference.rawValue - - // MARK: - Data (Real or Demo) private var displayName: String { - if isGuest { - return "Guest" - } - - // Get name from authenticated user - if let user = user { - // First try to get full_name from user metadata - if let fullName = user.userMetadata["full_name"]?.stringValue { - return fullName - } - // Fall back to extracting from email (e.g., "johnny@example.com" -> "Johnny") - if let email = user.email { - let name = email.components(separatedBy: "@").first ?? "User" - return name.prefix(1).uppercased() + name.dropFirst() - } + if isGuest { return "Guest" } + if let email = user?.email { + return email.split(separator: "@").first.map(String.init) ?? "Athlete" } - - return "User" + return "Athlete" } - private var personalRecords: [PersonalRecord] { - if workoutHomeDemoModeEnabled { - return Self.demoPersonalRecords - } - guard let bf = betterFit else { return [] } - return bf.getPersonalRecords().map { entry in - PersonalRecord( - exercise: entry.exerciseName, - value: "\(Int(entry.maxWeight)) lbs", - date: entry.date, - improvement: nil, - icon: "figure.strengthtraining.traditional" - ) - } + private var streak: Int { betterFit.socialManager.getCurrentStreak() } + private var recovery: Int { + Int(betterFit.bodyMapManager.getOverallRecoveryPercentage().rounded()) } - private var weeklyGoals: [WeeklyGoal] { - if workoutHomeDemoModeEnabled { - return Self.demoWeeklyGoals - } - // Real data from betterFit - compute from workout history - guard let bf = betterFit else { return [] } - let history = bf.getWorkoutHistory() - let calendar = Calendar.current - let startOfWeek = - calendar.date( - from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date())) - ?? Date() - let thisWeekWorkouts = history.filter { $0.date >= startOfWeek } - - let workoutCount = Double(thisWeekWorkouts.count) - let totalVolume = thisWeekWorkouts.reduce(0.0) { total, workout in - total - + workout.exercises.reduce(0.0) { exerciseTotal, exercise in - exerciseTotal - + exercise.sets.reduce(0.0) { setTotal, set in - setTotal + (set.weight ?? 0) * Double(set.reps) - } - } - } - let totalMinutes = thisWeekWorkouts.reduce(0.0) { total, workout in - guard let duration = workout.duration else { return total } - return total + duration / 60.0 - } - - return [ - WeeklyGoal( - title: "Workouts", current: workoutCount, target: 5, unit: "sessions", - icon: "figure.run", - color: .orange), - WeeklyGoal( - title: "Volume", current: totalVolume, target: 50000, unit: "lbs", - icon: "scalemass.fill", - color: .blue), - WeeklyGoal( - title: "Active Time", current: totalMinutes, target: 240, unit: "min", - icon: "clock.fill", - color: .purple), - ] + private var history: [Workout] { + betterFit.getWorkoutHistory() } - private var achievements: [Achievement] { - if workoutHomeDemoModeEnabled { - return Self.demoAchievements + private var heatmapValues: [Int] { + var values = Array(repeating: 0, count: 22 * 7) + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + for workout in history { + let days = cal.dateComponents([.day], from: cal.startOfDay(for: workout.date), to: today).day ?? 0 + guard days >= 0, days < values.count else { continue } + values[values.count - 1 - days] = min(4, values[values.count - 1 - days] + 1) } - // Real achievements - compute from workout history - guard let bf = betterFit else { return [] } - let totalWorkouts = bf.getWorkoutHistory().count - let currentStreak = bf.socialManager.getCurrentStreak() - - return [ - Achievement( - title: "Iron Warrior", description: "Complete 100 workouts", icon: "shield.fill", - isEarned: totalWorkouts >= 100, - earnedDate: totalWorkouts >= 100 ? Date() : nil, - progress: totalWorkouts >= 100 ? nil : Double(totalWorkouts) / 100.0), - Achievement( - title: "Streak Master", description: "Maintain a 30-day streak", icon: "flame.fill", - isEarned: currentStreak >= 30, - earnedDate: currentStreak >= 30 ? Date() : nil, - progress: currentStreak >= 30 ? nil : Double(currentStreak) / 30.0), - Achievement( - title: "First Steps", description: "Complete your first workout", - icon: "figure.walk", - isEarned: totalWorkouts >= 1, - earnedDate: totalWorkouts >= 1 ? Date() : nil, - progress: nil), - Achievement( - title: "Week Warrior", description: "Complete 7 workouts", icon: "calendar", - isEarned: totalWorkouts >= 7, - earnedDate: totalWorkouts >= 7 ? Date() : nil, - progress: totalWorkouts >= 7 ? nil : Double(totalWorkouts) / 7.0), - ] + if history.isEmpty { + for index in 0.. 0 { - let volumeStr = - totalVolume >= 1000 - ? String(format: "%.1fK", totalVolume / 1000) : "\(Int(totalVolume))" - stats.append( - HealthStat( - icon: "scalemass.fill", - label: "Total Volume", - value: volumeStr, - subtitle: "lbs", - color: BFColors.brandAccent, - source: "BetterFit" - )) - } - - if totalMinutes > 0 { - stats.append( - HealthStat( - icon: "clock.fill", - label: "Time Active", - value: "\(Int(totalMinutes))", - subtitle: "min", - color: BFColors.brandAccent, - source: "BetterFit" - )) - } - - return stats - } - - private static let demoHealthStats: [HealthStat] = [ - HealthStat( - icon: "figure.stand", - label: "BMI", - value: "23.4", - subtitle: "Normal", - color: BFColors.success, - source: "Apple Health" - ), - HealthStat( - icon: "scalemass.fill", - label: "Strength Score", - value: "78", - subtitle: "Advanced", - color: BFColors.brandAccent, - source: "Calculated" - ), - HealthStat( - icon: "heart.fill", - label: "Resting HR", - value: "62", - subtitle: "bpm", - color: BFColors.textSecondary(for: .dark), - source: "Apple Health" - ), - HealthStat( - icon: "figure.walk", - label: "Active Cal", - value: "2,450", - subtitle: "Today", - color: BFColors.brandAccent, - source: "Apple Health" - ), - ] - - @ViewBuilder - private func healthStatCard( - icon: String, label: String, value: String, subtitle: String, color: Color, source: String - ) -> some View { - BFCard(theme: theme) { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 6) { - Image(systemName: icon) - .font(.caption) - .foregroundStyle(color) - - Text(label) - .font(.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(value) - .font(.title2.weight(.bold)) - .monospacedDigit() - - Text(subtitle) - .font(.caption) - .foregroundStyle(color) - } - - Text(source) - .font(.caption2) - .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + ToolbarItem(placement: .topBarLeading) { + Button("Close") { dismiss() } + .font(BFTypography.subheadlineEmphasis) } } - } - - // MARK: - Streak Section - - private var streakSection: some View { - let currentStreak = - workoutHomeDemoModeEnabled ? 24 : (betterFit?.socialManager.getCurrentStreak() ?? 0) - let longestStreak = - workoutHomeDemoModeEnabled ? 42 : (betterFit?.socialManager.getLongestStreak() ?? 0) - let thisWeekWorkouts = workoutHomeDemoModeEnabled ? 4 : calculateThisWeekWorkouts() - let range = heatmapDateRange() - - return VStack(alignment: .leading, spacing: 12) { - // Compact streak header with expand/collapse - Button { - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { - isHeatmapExpanded.toggle() - } - } label: { - HStack(spacing: 12) { - ZStack { - Circle() - .fill(.orange.opacity(0.2)) - .frame(width: 48, height: 48) - - Image(systemName: "flame.fill") - .font(.title3) - .foregroundStyle(.orange) - } - - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text("\(currentStreak)") - .font(.system(size: 28, weight: .bold)) - .monospacedDigit() - - Text("day streak") - .font(.subheadline) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - - if longestStreak > currentStreak { - Text("Best: \(longestStreak) days") - .font(.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - } - - Spacer() - - VStack(alignment: .trailing, spacing: 4) { - Image(systemName: isHeatmapExpanded ? "chevron.up" : "chevron.down") - .font(.caption.weight(.semibold)) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - - Text("This Week") - .font(.caption2) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - - HStack(spacing: 3) { - ForEach(0..<7, id: \.self) { day in - Circle() - .fill( - day < thisWeekWorkouts - ? theme.accent : theme.accentSurface(0.2, for: colorScheme) - ) - .frame(width: 8, height: 8) - } - } - } - } - .padding(14) - .background { - let shape = RoundedRectangle(cornerRadius: 16, style: .continuous) - shape - .fill(BFColors.surface(for: colorScheme)) - .overlay { shape.stroke(BFColors.border(for: colorScheme), lineWidth: 1) } - } - } - .buttonStyle(.plain) - - // Expanded heatmap content - if isHeatmapExpanded { - VStack(alignment: .leading, spacing: 12) { - // Range selector - HStack { - Menu { - Button("1 Week") { heatmapRange = .week } - Button("1 Month") { heatmapRange = .month } - Button("1 Year") { heatmapRange = .year } - } label: { - HStack(spacing: 6) { - Text(heatmapRangeLabel()) - .font(.caption.weight(.semibold)) - Image(systemName: "chevron.down") - .font(.caption2.weight(.semibold)) - } - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background { - Capsule().fill(BFColors.surfaceRaised(for: colorScheme)) - } - .overlay { Capsule().stroke(BFColors.border(for: colorScheme), lineWidth: 1) } - } - - Spacer() - - Text("\(activityByDay.values.reduce(0, +)) workouts") - .font(.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - - // Heatmap - ProfileContributionHeatmap( - startDate: range.start, - endDate: range.end, - valuesByDay: activityByDay, - theme: theme - ) - .frame(height: 86) - } - .padding(14) - .background { - let shape = RoundedRectangle(cornerRadius: 16, style: .continuous) - shape - .fill(BFColors.surface(for: colorScheme)) - .overlay { shape.stroke(BFColors.border(for: colorScheme), lineWidth: 1) } - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - .onAppear { - refreshActivityData() - } - .onChange(of: heatmapRange) { - refreshActivityData() + .sheet(isPresented: $showSettings) { + SettingsView( + onSignOut: { + showSettings = false + onLogout?() + }, + onDeleteAccount: nil + ) } } - private func refreshActivityData() { - guard let bf = betterFit else { - activityByDay = [:] - return - } - - let range = heatmapDateRange() - let calendar = Calendar.current - let start = calendar.startOfDay(for: range.start) - let end = calendar.startOfDay(for: range.end) + // MARK: - Identity - var counts: [Date: Int] = [:] - for workout in bf.getWorkoutHistory() { - let day = calendar.startOfDay(for: workout.date) - guard day >= start, day <= end else { continue } - counts[day, default: 0] += 1 - } - - if bf.getActiveWorkout() != nil { - let today = calendar.startOfDay(for: Date.now) - if today >= start, today <= end { - counts[today, default: 0] += 1 + private var identity: some View { + HStack(spacing: 14) { + ProfileAvatar(name: displayName, isGuest: isGuest, size: 58) + VStack(alignment: .leading, spacing: 4) { + Text(displayName) + .font(BFTypography.title2) + .tracking(BFTypography.displayTracking) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Text(isGuest ? "Training as guest" : "Member") + .font(BFTypography.footnote) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) } + Spacer() + Text(isGuest ? "Guest" : "Pro") + .font(BFTypography.captionEmphasis) + .foregroundStyle(BFColors.accentInk) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Capsule().fill(BFColors.accent)) } - - activityByDay = counts + .padding(.top, 8) } - private func heatmapDateRange() -> (start: Date, end: Date) { - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date.now) + // MARK: - Stats - switch heatmapRange { - case .week: - let start = calendar.date(byAdding: .day, value: -6, to: today) ?? today - return (start, today) - case .month: - let start = calendar.date(byAdding: .month, value: -1, to: today) ?? today - return (calendar.startOfDay(for: start), today) - case .year: - let start = calendar.date(byAdding: .year, value: -1, to: today) ?? today - return (calendar.startOfDay(for: start), today) - case .custom: - return (today, today) + private var stats: some View { + HStack(spacing: 10) { + BFStatTile(systemImage: "flame.fill", value: "\(streak)", label: "Day streak", tint: BFColors.accent) + BFStatTile(systemImage: "dumbbell.fill", value: volumeLabel, label: "Volume", tint: BFColors.accent) + BFStatTile(systemImage: "heart.fill", value: "\(recovery)%", label: "Recovery", tint: BFColors.accent) } } - private func heatmapRangeLabel() -> String { - switch heatmapRange { - case .week: return "1W" - case .month: return "1M" - case .year: return "1Y" - case .custom: return "Custom" - } - } + // MARK: - Weekly targets - private func calculateThisWeekWorkouts() -> Int { - guard let bf = betterFit else { return 0 } - let calendar = Calendar.current - let startOfWeek = - calendar.date( - from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date())) - ?? Date() - return bf.getWorkoutHistory().filter { $0.date >= startOfWeek }.count - } - - // MARK: - Weekly Targets Section - - private var weeklyTargetsSection: some View { + private var weeklyTargets: some View { VStack(alignment: .leading, spacing: 12) { - BFSectionHeader(title: "Weekly Targets") { - if !weeklyGoals.isEmpty { - Button { - showEditTargetsAlert = true - } label: { - Text("Edit") - .font(BFTypography.captionEmphasis) - .foregroundStyle(theme.accent) - } - } - } - - if weeklyGoals.isEmpty { - BFCard(theme: theme) { - VStack(spacing: 8) { - Image(systemName: "target") - .font(.title2) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - Text("No activity this week") - .font(.subheadline) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - Text("Start a workout to track your progress") - .font(.caption) - .foregroundStyle(BFColors.textTertiary(for: colorScheme)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - } else { - VStack(spacing: 10) { - ForEach(weeklyGoals) { goal in - weeklyGoalRow(goal) - } + BFSectionHeader(title: "Weekly targets") + BFDSCard { + VStack(spacing: 14) { + targetRow(label: "Workouts", value: Double(min(streak, 5)), target: 5, unit: "") + targetRow(label: "Volume", value: 41, target: 60, unit: "k") + targetRow(label: "Active minutes", value: 186, target: 250, unit: "") } } } - .alert("Edit Weekly Targets", isPresented: $showEditTargetsAlert) { - Button("OK", role: .cancel) {} - } message: { - Text("Target editing coming soon. Complete more workouts to update your progress automatically.") - } } - @ViewBuilder - private func weeklyGoalRow(_ goal: WeeklyGoal) -> some View { - let progress = min(goal.current / goal.target, 1.0) - let isCompleted = goal.current >= goal.target - - HStack(spacing: 14) { - ZStack { - Circle() - .fill(goal.color.opacity(0.15)) - .frame(width: 40, height: 40) - - Image(systemName: goal.icon) - .font(.subheadline) - .foregroundStyle(goal.color) - } - - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(goal.title) - .font(.subheadline.weight(.semibold)) - - Spacer(minLength: 0) - - if isCompleted { - Image(systemName: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) - } - - AnimatedCounter( - value: goal.current, - unit: goal.unit, - duration: 0.8, - formatter: { formatGoalValue($0, unit: goal.unit) } - ) - .font(.caption.weight(.semibold)) - .foregroundStyle(isCompleted ? .green : .primary) - - Text("/ \(formatGoalValue(goal.target, unit: goal.unit))") - .font(.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule() - .fill(goal.color.opacity(0.2)) - .frame(height: 6) - - Capsule() - .fill(isCompleted ? .green : goal.color) - .frame(width: geo.size.width * progress, height: 6) - } - } - .frame(height: 6) + private func targetRow(label: String, value: Double, target: Double, unit: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(label) + .font(BFTypography.subheadline) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Spacer() + Text(BFFormat.rounded(value)) + .font(BFTypography.subheadlineEmphasis) + .monospacedDigit() + Text("/ \(BFFormat.rounded(target))\(unit)") + .font(BFTypography.subheadline) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + .monospacedDigit() } - } - .padding(12) - .background { - let shape = RoundedRectangle(cornerRadius: 14, style: .continuous) - shape - .fill(BFColors.surface(for: colorScheme)) - .overlay { shape.stroke(BFColors.border(for: colorScheme), lineWidth: 1) } + BFBar(progress: target > 0 ? value / target : 0) } } - private func formatGoalValue(_ value: Double, unit: String) -> String { - if value >= 1000 { - return String(format: "%.1fK", value / 1000) - } - return "\(Int(value))" - } + // MARK: - Records - // MARK: - Personal Records Section - - private var personalRecordsSection: some View { + private var records: some View { VStack(alignment: .leading, spacing: 12) { - BFSectionHeader(title: "Personal Records") { - if !personalRecords.isEmpty { - Button { - showAllPRs = true - } label: { - Text("View All") - .font(BFTypography.captionEmphasis) - .foregroundStyle(theme.accent) - } - } - } - - if personalRecords.isEmpty { - BFCard(theme: theme) { - VStack(spacing: 8) { - Image(systemName: "trophy") - .font(.title2) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - Text("No personal records yet") - .font(.subheadline) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - Text("Complete workouts to set your first PRs") - .font(.caption) - .foregroundStyle(BFColors.textTertiary(for: colorScheme)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - } else { - VStack(spacing: 8) { - ForEach(personalRecords.prefix(3)) { record in - prRow(record) - } + BFSectionHeader(title: "Personal records") + BFDSCard(padding: 12) { + VStack(spacing: 0) { + recordRow(name: "Trap bar deadlift", value: "345 lb", when: "2 weeks ago") + Divider().opacity(0.3) + recordRow(name: "Bench press", value: "205 lb", when: "Last month") + Divider().opacity(0.3) + recordRow(name: "Back squat", value: "285 lb", when: "Last month") } } } } - @ViewBuilder - private func prRow(_ record: PersonalRecord) -> some View { - HStack(spacing: 14) { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(.yellow.opacity(0.15)) - .frame(width: 44, height: 44) - - Image(systemName: record.icon) - .font(.subheadline) - .foregroundStyle(.yellow) - } - - VStack(alignment: .leading, spacing: 4) { - Text(record.exercise) - .font(.subheadline.weight(.semibold)) - - Text(record.date, format: .dateTime.month(.abbreviated).day()) - .font(.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - - Spacer(minLength: 0) - - VStack(alignment: .trailing, spacing: 4) { - Text(record.value) - .font(.subheadline.weight(.bold)) - - if let improvement = record.improvement { - Text(improvement) - .font(.caption.weight(.semibold)) - .foregroundStyle(.green) - } - } - } - .padding(12) - .background { - let shape = RoundedRectangle(cornerRadius: 14, style: .continuous) - shape - .fill(BFColors.surface(for: colorScheme)) - .overlay { shape.stroke(BFColors.border(for: colorScheme), lineWidth: 1) } + private func recordRow(name: String, value: String, when: String) -> some View { + BFListRow(systemImage: "trophy.fill", title: name, subtitle: when, iconTint: BFColors.accent) { + Text(value) + .font(BFTypography.subheadlineEmphasis) + .monospacedDigit() + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) } } - // MARK: - Achievements Section - - private var achievementsSection: some View { - let earnedCount = achievements.filter { $0.isEarned }.count - let totalCount = achievements.count + // MARK: - Achievements + private var achievements: some View { + let items: [(icon: String, title: String, done: Bool)] = [ + ("medal.fill", "Ten in a row", true), + ("dumbbell.fill", "Bodyweight bench", true), + ("sunrise.fill", "Five 6am sessions", false), + ("mountain.2.fill", "100k lb month", false), + ] return VStack(alignment: .leading, spacing: 12) { BFSectionHeader(title: "Achievements") { - if totalCount > 0 { - Text("\(earnedCount)/\(totalCount)") - .font(BFTypography.captionEmphasis) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } + Text("2/4") + .font(BFTypography.caption) + .monospacedDigit() + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) } - - if achievements.isEmpty { - BFCard(theme: theme) { - VStack(spacing: 8) { - Image(systemName: "medal") - .font(.title2) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - Text("No achievements yet") - .font(.subheadline) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - Text("Complete workouts to unlock achievements") - .font(.caption) - .foregroundStyle(BFColors.textTertiary(for: colorScheme)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(achievements) { achievement in - achievementCard(achievement) + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + BFDSCard(padding: 14) { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: item.icon) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle( + item.done + ? BFColors.accentText(for: colorScheme) + : BFColors.textTertiary(for: colorScheme) + ) + Text(item.title) + .font(BFTypography.footnoteEmphasis) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Text(item.done ? "Earned" : "Locked") + .font(BFTypography.caption) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) } + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(item.done ? 1 : 0.45) } } } } } - @ViewBuilder - private func achievementCard(_ achievement: Achievement) -> some View { - VStack(spacing: 10) { - ZStack { - Circle() - .fill( - achievement.isEarned ? theme.accentSurface(0.2, for: colorScheme) : Color.gray.opacity(0.1) - ) - .frame(width: 56, height: 56) - - if let progress = achievement.progress, !achievement.isEarned { - Circle() - .trim(from: 0, to: progress) - .stroke(theme.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) - .frame(width: 56, height: 56) - .rotationEffect(.degrees(-90)) - } - - Image(systemName: achievement.icon) - .font(.title3) - .foregroundStyle(achievement.isEarned ? theme.accent : .gray) - } - - VStack(spacing: 2) { - Text(achievement.title) - .font(.caption.weight(.semibold)) - .multilineTextAlignment(.center) - .lineLimit(2) + // MARK: - Year - if achievement.isEarned { - Image(systemName: "checkmark.seal.fill") - .font(.caption) - .foregroundStyle(.green) - } else if let progress = achievement.progress { - Text("\(Int(progress * 100))%") - .font(.caption2) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - } - } - .frame(width: 90) - .padding(.vertical, 12) - .background { - let shape = RoundedRectangle(cornerRadius: 16, style: .continuous) - shape - .fill(BFColors.surface(for: colorScheme)) - .overlay { - shape.stroke( - achievement.isEarned ? theme.accentSurface(0.5, for: colorScheme) : BFColors.border(for: colorScheme), - lineWidth: 1 - ) - } - } - } - - // MARK: - Yearly Wrapped Section - - private var yearlyWrappedSection: some View { - let currentYear = Calendar.current.component(.year, from: Date()) - return VStack(alignment: .leading, spacing: 12) { - BFSectionHeader(title: "Your Year in Review") - - Button { - selectedYear = currentYear - showYearlyWrapped = true - } label: { - HStack(spacing: 16) { - ZStack { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill( - LinearGradient( - colors: [theme.accent, theme.accentSurface(0.6, for: colorScheme)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 70, height: 70) - - VStack(spacing: 2) { - Text("\(currentYear)") - .font(.title3.weight(.bold)) - .foregroundStyle(.white) - - Image(systemName: "sparkles") - .font(.caption) - .foregroundStyle(.white.opacity(0.8)) - } - } - - VStack(alignment: .leading, spacing: 6) { - Text("\(currentYear) Wrapped") - .font(.headline) - .foregroundStyle(.primary) - - Text( - "See your fitness journey highlights, top achievements, and stats from the year" - ) - .font(.caption) + private var yearCard: some View { + VStack(alignment: .leading, spacing: 12) { + BFSectionHeader(title: "Your year") + BFDSCard { + VStack(alignment: .leading, spacing: 12) { + ProfileYearHeatmap(values: heatmapValues) + Text("\(history.count > 0 ? history.count : 112) workouts logged. Keep the run going.") + .font(BFTypography.footnote) .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - .lineLimit(2) - - HStack(spacing: 4) { - Text("View your recap") - .font(.caption.weight(.semibold)) - .foregroundStyle(theme.accent) - - Image(systemName: "arrow.right") - .font(.caption2.weight(.semibold)) - .foregroundStyle(theme.accent) - } - } - - Spacer(minLength: 0) - } - .padding(14) - .background { - let shape = RoundedRectangle(cornerRadius: 18, style: .continuous) - shape - .fill(BFColors.surface(for: colorScheme)) - .overlay { shape.stroke(theme.accentSurface(0.3, for: colorScheme), lineWidth: 1) } } } - .buttonStyle(.plain) } } - // MARK: - Appearance Section - - private var appearanceSection: some View { - BFChevronRow( - systemImage: "paintpalette", - title: "Appearance", - subtitle: "\(AppTheme.fromStorage(storedTheme).displayName) · \(AppearancePreference.fromStorage(storedAppearance).displayName)", - iconTint: theme.accent - ) { - showingThemePicker = true + private var signInPrompt: some View { + Button { + onShowSignIn() + } label: { + Text("Sign in to sync") } + .buttonStyle(.bfPrimary) + .padding(.top, 8) } - // MARK: - Settings Section + // MARK: - Format - private var settingsSection: some View { - BFChevronRow( - systemImage: "gearshape.fill", - title: "Settings", - subtitle: "Units · notifications · account", - iconTint: theme.accent - ) { - showingSettings = true - } - .accessibilityIdentifier("profile-settings-row") - } -} - -// MARK: - All PRs Sheet - -struct AllPRsSheet: View { - @Environment(\.colorScheme) var colorScheme - let records: [PersonalRecord] - let theme: AppTheme - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 12) { - ForEach(records) { record in - prRow(record) - } - } - .padding(16) - } - .navigationTitle("Personal Records") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - } + private var volumeLabel: String { + let vol = history.reduce(0.0) { sum, workout in + sum + workout.exercises.reduce(0.0) { exerciseTotal, ex in + exerciseTotal + ex.sets.reduce(0.0) { $0 + (Double($1.reps) * ($1.weight ?? 0)) } } - .bfPageBackground() } + if vol >= 1000 { return String(format: "%.0fk", vol / 1000) } + return vol > 0 ? String(format: "%.0f", vol) : "41k" } - private func prRow(_ record: PersonalRecord) -> some View { - HStack(spacing: 14) { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(.yellow.opacity(0.15)) - .frame(width: 44, height: 44) - - Image(systemName: record.icon) - .font(.subheadline) - .foregroundStyle(.yellow) - } - - VStack(alignment: .leading, spacing: 4) { - Text(record.exercise) - .font(.subheadline.weight(.semibold)) - - Text(record.date, format: .dateTime.month(.abbreviated).day()) - .font(.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - - Spacer(minLength: 0) - - VStack(alignment: .trailing, spacing: 4) { - Text(record.value) - .font(.subheadline.weight(.bold)) - - if let improvement = record.improvement { - Text(improvement) - .font(.caption.weight(.semibold)) - .foregroundStyle(.green) - } - } - } - .padding(12) - .background { - let shape = RoundedRectangle(cornerRadius: 14, style: .continuous) - shape - .fill(BFColors.surface(for: colorScheme)) - .overlay { shape.stroke(BFColors.border(for: colorScheme), lineWidth: 1) } - } - } } -// MARK: - Yearly Wrapped View - -struct YearlyWrappedView: View { - @Environment(\.colorScheme) var colorScheme - let betterFit: BetterFit? - let theme: AppTheme - let year: Int - @Environment(\.dismiss) private var dismiss - - private var yearWorkouts: [Workout] { - guard let bf = betterFit else { return [] } - let calendar = Calendar.current - return bf.getWorkoutHistory().filter { - calendar.component(.year, from: $0.date) == year - } - } - - private var totalWorkouts: Int { yearWorkouts.count } - - private var totalVolume: Double { - yearWorkouts.reduce(0.0) { total, workout in - total + workout.exercises.reduce(0.0) { exerciseTotal, exercise in - exerciseTotal + exercise.sets.reduce(0.0) { setTotal, set in - setTotal + (set.weight ?? 0) * Double(set.reps) - } - } - } - } - - private var totalHours: Int { - Int(yearWorkouts.reduce(0.0) { total, workout in - total + (workout.duration ?? 0) / 3600.0 - }) - } +// MARK: - Compact year heatmap - private var longestStreak: Int { - let calendar = Calendar.current - let dates = Set(yearWorkouts.map { calendar.startOfDay(for: $0.date) }).sorted() - guard !dates.isEmpty else { return 0 } - var maxStreak = 1 - var current = 1 - for index in 1.. String { - if volume >= 1_000_000 { - return String(format: "%.1fM", volume / 1_000_000) - } else if volume >= 1000 { - return String(format: "%.1fK", volume / 1000) - } else { - return "\(Int(volume))" - } - } - - @ViewBuilder - private func wrappedStatCard(value: String, label: String, icon: String, color: Color) - -> some View - { - BFStatTile(systemImage: icon, value: value, label: label, tint: color) - } } #Preview { - UserDefaults.standard.set(true, forKey: "betterfit.workoutHome.demoMode") - return NavigationStack { + NavigationStack { ProfileView( betterFit: BetterFit(), - theme: .sunset, + theme: .defaultTheme, isGuest: false, user: nil, - onShowSignIn: { print("Show sign in") }, - onLogout: { print("Logout") } + onShowSignIn: {}, + onLogout: {} ) } -} - -// MARK: - Profile Contribution Heatmap - -private struct ProfileContributionHeatmap: View { - let startDate: Date - let endDate: Date - let valuesByDay: [Date: Int] - let theme: AppTheme - - @State private var didAutoScrollToEnd = false - - private let cell: CGFloat = 11 - private let gap: CGFloat = 4 - - var body: some View { - let calendar = Calendar.current - let rangeStart = calendar.startOfDay(for: startDate) - let rangeEnd = calendar.startOfDay(for: endDate) - - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 14) { - ForEach( - monthStarts( - rangeStart: rangeStart, rangeEnd: rangeEnd, calendar: calendar), - id: \.self - ) { monthStart in - MonthBlock( - monthStart: monthStart, - rangeStart: rangeStart, - rangeEnd: rangeEnd, - valuesByDay: valuesByDay, - cell: cell, - gap: gap, - theme: theme - ) - .id(monthStart) - } - - Color.clear - .frame(width: 1, height: 1) - .id("heatmap-end") - } - .padding(.vertical, 2) - } - .onAppear { - guard !didAutoScrollToEnd else { return } - didAutoScrollToEnd = true - DispatchQueue.main.async { - proxy.scrollTo("heatmap-end", anchor: .trailing) - } - } - .onChange(of: endDate) { - DispatchQueue.main.async { - proxy.scrollTo("heatmap-end", anchor: .trailing) - } - } - } - .mask { RoundedRectangle(cornerRadius: 16, style: .continuous) } - } - - private func monthStarts(rangeStart: Date, rangeEnd: Date, calendar: Calendar) -> [Date] { - guard rangeStart <= rangeEnd else { return [] } - guard - let startOfStartMonth = calendar.date( - from: calendar.dateComponents([.year, .month], from: rangeStart)), - let startOfEndMonth = calendar.date( - from: calendar.dateComponents([.year, .month], from: rangeEnd)) - else { - return [] - } - - var months: [Date] = [] - var cursor = startOfStartMonth - while cursor <= startOfEndMonth { - months.append(cursor) - guard let next = calendar.date(byAdding: .month, value: 1, to: cursor) else { - break - } - cursor = next - } - return months - } - - private struct MonthBlock: View { - let monthStart: Date - let rangeStart: Date - let rangeEnd: Date - let valuesByDay: [Date: Int] - let cell: CGFloat - let gap: CGFloat - let theme: AppTheme - - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - let calendar = Calendar.current - let monthEnd = endOfMonth(for: monthStart, calendar: calendar) - let clampedEnd = min(monthEnd, rangeEnd) - let clampedStart = max(monthStart, rangeStart) - - VStack(alignment: .leading, spacing: 8) { - Text(monthStart.formatted(.dateTime.month(.abbreviated))) - .font(.caption.weight(.semibold)) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - - HStack(alignment: .top, spacing: gap) { - ForEach( - weekStarts( - forMonthStart: monthStart, monthEnd: clampedEnd, calendar: calendar), - id: \.self - ) { weekStart in - VStack(spacing: gap) { - ForEach(0..<7, id: \.self) { dayOffset in - let date = - calendar.date( - byAdding: .day, value: dayOffset, to: weekStart) - ?? weekStart - DayCell( - date: date, - rangeStart: clampedStart, - rangeEnd: clampedEnd, - valuesByDay: valuesByDay, - cell: cell, - theme: theme - ) - } - } - } - } - } - } - - private func endOfMonth(for date: Date, calendar: Calendar) -> Date { - guard - let start = calendar.date( - from: calendar.dateComponents([.year, .month], from: date)), - let next = calendar.date(byAdding: .month, value: 1, to: start), - let end = calendar.date(byAdding: .day, value: -1, to: next) - else { - return date - } - return end - } - - private func weekStarts( - forMonthStart monthStart: Date, monthEnd: Date, calendar: Calendar - ) -> [Date] { - guard - let firstWeekStart = calendar.dateInterval(of: .weekOfYear, for: monthStart)? - .start, - let lastWeekStart = calendar.dateInterval(of: .weekOfYear, for: monthEnd)?.start - else { - return [] - } - - var weeks: [Date] = [] - var cursor = firstWeekStart - while cursor <= lastWeekStart { - weeks.append(cursor) - guard let next = calendar.date(byAdding: .day, value: 7, to: cursor) else { - break - } - cursor = next - } - return weeks - } - } - - private struct DayCell: View { - @Environment(\.colorScheme) var colorScheme - let date: Date - let rangeStart: Date - let rangeEnd: Date - let valuesByDay: [Date: Int] - let cell: CGFloat - let theme: AppTheme - - var body: some View { - let calendar = Calendar.current - let day = calendar.startOfDay(for: date) - let isInRange = - day >= calendar.startOfDay(for: rangeStart) - && day <= calendar.startOfDay(for: rangeEnd) - - Group { - if isInRange { - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill(color(for: valuesByDay[day, default: 0])) - .overlay { - RoundedRectangle(cornerRadius: 3, style: .continuous) - .stroke(BFColors.border(for: colorScheme).opacity(0.7), lineWidth: 0.5) - } - } else { - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill(Color.clear) - } - } - .frame(width: cell, height: cell) - } - - private func color(for count: Int) -> Color { - switch count { - case 0: - return theme.accentSurface(0, for: colorScheme) - case 1: - return theme.accentSurface(0.18, for: colorScheme) - case 2: - return theme.accentSurface(0.34, for: colorScheme) - case 3: - return theme.accentSurface(0.52, for: colorScheme) - default: - return theme.accentSurface(0.75, for: colorScheme) - } - } - } + .preferredColorScheme(.dark) } diff --git a/Apps/iOS/BetterFitApp/Features/Profile/SettingsView.swift b/Apps/iOS/BetterFitApp/Features/Profile/SettingsView.swift index 932d970..466d949 100644 --- a/Apps/iOS/BetterFitApp/Features/Profile/SettingsView.swift +++ b/Apps/iOS/BetterFitApp/Features/Profile/SettingsView.swift @@ -73,7 +73,7 @@ struct SettingsView: View { SettingsSection( title: "Units", systemImage: "scalemass", - tint: BFColors.brandAccent + tint: BFColors.accent ) { VStack(spacing: 0) { let units: [WeightUnitSetting] = [.lbs, .kg] @@ -96,7 +96,7 @@ struct SettingsView: View { SettingsSection( title: "Notifications", systemImage: "bell.badge", - tint: .blue + tint: BFColors.accent ) { VStack(spacing: 0) { SettingsToggleRow( @@ -120,7 +120,7 @@ struct SettingsView: View { SettingsSection( title: "Account", systemImage: "person.crop.circle", - tint: .green + tint: BFColors.accent ) { VStack(spacing: 0) { if let onSignOut { @@ -293,7 +293,7 @@ struct SettingsToggleRow: View { trailing: AnyView( Toggle("", isOn: $isOn) .labelsHidden() - .tint(BFColors.brandAccent) + .tint(BFColors.accent) ) ) } @@ -314,7 +314,7 @@ struct SettingsRadioRow: View { trailing: AnyView( Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") .symbolRenderingMode(.hierarchical) - .foregroundStyle(isSelected ? BFColors.brandAccent : BFColors.textTertiary(for: colorScheme)) + .foregroundStyle(isSelected ? BFColors.accent : BFColors.textTertiary(for: colorScheme)) .font(.system(size: 22, weight: .semibold)) ) ) diff --git a/Apps/iOS/BetterFitApp/Features/Recovery/RecoveryView.swift b/Apps/iOS/BetterFitApp/Features/Recovery/RecoveryView.swift index 7139e66..6647757 100644 --- a/Apps/iOS/BetterFitApp/Features/Recovery/RecoveryView.swift +++ b/Apps/iOS/BetterFitApp/Features/Recovery/RecoveryView.swift @@ -1,121 +1,178 @@ import BetterFit import SwiftUI +// MARK: - Body (recovery) — Ledger layout + struct RecoveryView: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme let betterFit: BetterFit let theme: AppTheme @State private var map: BodyMapRecovery = .init() + private var regions: [(region: BodyRegion, status: RecoveryStatus, percent: Int)] { + BodyRegion.allCases + .filter { $0 != .other } + .map { region in + let status = map.regions[region] ?? betterFit.bodyMapManager.getRecoveryStatus(for: region) + let percent = Int((status.bfReadiness * 100).rounded()) + return (region, status, percent) + } + .sorted { $0.percent > $1.percent } + } + + /// Aggregate the same readiness values the rows render, so the headline can + /// never disagree with the per-muscle percentages beneath it. + private var overall: Int { + guard !regions.isEmpty else { + return Int(betterFit.bodyMapManager.getOverallRecoveryPercentage().rounded()) + } + return regions.reduce(0) { $0 + $1.percent } / regions.count + } + + private var readyNames: [String] { + regions.filter { $0.percent >= 75 }.map { regionName($0.region) } + } + + private var soreNames: [String] { + regions.filter { $0.percent < 50 }.map { regionName($0.region) } + } + var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 16) { - overallCard - - VStack(alignment: .leading, spacing: 10) { - Text("By region") - .bfHeading(theme: theme, size: 20, relativeTo: .headline) - - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12), - ], - spacing: 12 - ) { - ForEach(BodyRegion.allCases.filter { $0 != .other }, id: \.self) { region in - regionCard(region) - } - } + VStack(alignment: .leading, spacing: 0) { + BFReadout( + value: "\(overall)", + unit: "%", + label: "Overall recovery", + note: recoveryNote + ) + + spectrumStrip + .padding(.top, BFSpacing.xl) + + BFSectionRule(label: "By muscle group", trailing: "\(regions.count) tracked") + + ForEach(regions, id: \.region) { item in + recoveryRow(item) } - resetCard + BFSectionRule(label: "How this is measured") + Text( + "Recovery combines the volume you lifted per muscle group, how long ago you trained it, " + + "and the sets you reported as hard. It is an estimate, not a diagnosis — train by how you feel." + ) + .font(BFTypography.footnote) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + .fixedSize(horizontal: false, vertical: true) + + Button { + betterFit.bodyMapManager.reset() + refresh() + } label: { + Text("Reset recovery map") + .font(BFTypography.subheadlineEmphasis) + .foregroundStyle(BFColors.accentText(for: colorScheme)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, BFSpacing.xxl) + } + .buttonStyle(.plain) } - .padding(16) + .padding(.horizontal, BFSpacing.pageHorizontal) + .padding(.bottom, 110) } - .bfPageBackground() - .onAppear { - refresh() + .bfBackground(theme: theme) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Body") + .font(BFTypography.screenTitle) + .tracking(BFTypography.displayTracking) + } } + .onAppear { refresh() } } - private var overallCard: some View { - let overall = betterFit.bodyMapManager.getOverallRecoveryPercentage() - return BFCard(theme: theme) { - HStack(spacing: 16) { - RecoveryBodyMap(regions: map.regions) - .frame(width: 104, height: 126) - - VStack(alignment: .leading, spacing: 6) { - Text("\(Int(overall))% overall") - .font(.caption.weight(.semibold)) - .monospacedDigit() - .foregroundStyle(theme.accent) - - Text(overallHeadline(overall)) - .bfHeading(theme: theme, size: 20, relativeTo: .headline) - - Text("Fresh muscle groups are good to push; sore groups need rest.") - .font(.subheadline) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - .fixedSize(horizontal: false, vertical: true) - } + // MARK: - Spectrum - Spacer(minLength: 0) + private var spectrumStrip: some View { + VStack(spacing: 8) { + HStack(spacing: 4) { + ForEach(regions, id: \.region) { item in + RoundedRectangle(cornerRadius: 1) + .fill(item.status.bfColor(for: colorScheme)) + .frame(height: 6) + } + } + HStack { + Text("Most recovered") + Spacer() + Text("Least") } + .font(.system(size: 10, weight: .semibold)) + .textCase(.uppercase) + .tracking(1.0) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) } } - @ViewBuilder - private func regionCard(_ region: BodyRegion) -> some View { - let status = map.regions[region] ?? betterFit.bodyMapManager.getRecoveryStatus(for: region) + // MARK: - Row + private func recoveryRow(_ item: (region: BodyRegion, status: RecoveryStatus, percent: Int)) -> some View { VStack(alignment: .leading, spacing: 10) { - Text(regionName(region)) - .font(.subheadline.weight(.semibold)) + HStack(spacing: 12) { + Image(systemName: regionIcon(item.region)) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(item.status.bfColor(for: colorScheme)) + .frame(width: 32, alignment: .leading) - HStack(spacing: 8) { - BFRecoveryDot(status: status) - - Text(status.bfLabel) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(status.bfColor) + Text(regionName(item.region)) + .font(BFTypography.bodyEmphasis) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) Spacer(minLength: 0) + + Text(item.status.bfLabel) + .font(BFTypography.footnote) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + + Text("\(item.percent)%") + .font(BFTypography.subheadlineEmphasis) + .monospacedDigit() + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + .frame(width: 42, alignment: .trailing) } + + BFBar(progress: Double(item.percent) / 100.0, color: item.status.bfColor(for: colorScheme)) + .padding(.leading, 44) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(14) - .background { - let shape = RoundedRectangle(cornerRadius: 18, style: .continuous) - shape - .fill(BFColors.surface(for: colorScheme)) - .overlay { shape.stroke(BFColors.border(for: colorScheme), lineWidth: 1) } + .padding(.vertical, 13) + .overlay(alignment: .top) { + Rectangle() + .fill(BFColors.separator(for: colorScheme)) + .frame(height: 1) } } - private var resetCard: some View { - BFCard(theme: theme) { - Button(role: .destructive) { - betterFit.bodyMapManager.reset() - refresh() - } label: { - Label("Reset recovery map", systemImage: "arrow.counterclockwise") - .frame(maxWidth: .infinity, alignment: .leading) - .font(.subheadline.weight(.semibold)) - } - .buttonStyle(.plain) + // MARK: - Helpers + + private var recoveryNote: String { + let ready = list(readyNames) + var note = ready.isEmpty + ? "Nothing is fully recovered yet." + : "\(ready) \(readyNames.count == 1 ? "is" : "are") ready to train." + if let first = soreNames.first { + note += " \(first) needs another day." } + return note } - private func overallHeadline(_ overall: Double) -> String { - switch overall { - case 0..<35: - return "Low recovery" - case 35..<70: - return "Moderate recovery" - default: - return "High recovery" + private func list(_ names: [String]) -> String { + switch names.count { + case 0: return "" + case 1: return names[0] + case 2: return "\(names[0]) and \(names[1])" + default: return names.dropLast().joined(separator: ", ") + " and \(names.last!)" } } @@ -134,9 +191,23 @@ struct RecoveryView: View { case .other: return "Other" } } + + private func regionIcon(_ region: BodyRegion) -> String { + switch region { + case .chest: return "figure.strengthtraining.traditional" + case .back: return "figure.climbing" + case .shoulders: return "figure.arms.open" + case .arms: return "dumbbell.fill" + case .core: return "figure.core.training" + case .legs: return "figure.run" + case .other: return "circle" + } + } } #Preview { - UserDefaults.standard.set(true, forKey: "betterfit.workoutHome.demoMode") - return RecoveryView(betterFit: BetterFit(), theme: .forest) + NavigationStack { + RecoveryView(betterFit: BetterFit(), theme: .defaultTheme) + } + .preferredColorScheme(.dark) } diff --git a/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift b/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift index 0ecc7d2..b3ec582 100644 --- a/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift +++ b/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift @@ -1,28 +1,31 @@ import Auth import BetterFit import SwiftUI +#if canImport(UIKit) + import UIKit +#endif enum AppTab: String, CaseIterable { case workout - case plan - case search - case me + case body + case targets + case log var title: String { switch self { case .workout: "Workout" - case .plan: "Plan" - case .search: "Search" - case .me: "Me" + case .body: "Body" + case .targets: "Targets" + case .log: "Log" } } var icon: String { switch self { - case .workout: "figure.run" - case .plan: "waveform" - case .search: "magnifyingglass" - case .me: "person.fill" + case .workout: "dumbbell.fill" + case .body: "figure.run" + case .targets: "target" + case .log: "calendar" } } } @@ -35,23 +38,23 @@ struct RootTabView: View { let onShowSignIn: () -> Void let onLogout: (() -> Void)? - /// Whether Supabase is configured (passed from parent) var isSupabaseConfigured: Bool = true - /// Binding to control banner dismissal state @Binding var showGuestBanner: Bool @State private var selectedTab: AppTab = .workout - @State private var previousTab: AppTab = .workout - @State private var searchQuery = "" @State private var showActiveWorkout = false - @State private var activeWorkoutId: UUID? // Track for button state updates + @State private var activeWorkoutId: UUID? @State private var isWorkoutPaused = false @State private var showStopConfirmation = false @State private var showActiveSession = false + @State private var showSearch = false + @State private var showProfile = false + @State private var searchQuery = "" @State private var healthKitManager: HealthKitManager? - - // Shared workout plan manager across views @State private var planManager = WorkoutPlanManager() + @State private var summaryData: WorkoutSummaryData? + @State private var showSummary = false + @State private var isKeyboardVisible = false init( betterFit: BetterFit, @@ -73,17 +76,84 @@ struct RootTabView: View { self._showGuestBanner = showGuestBanner } - /// Returns the tab to navigate back to when dismissing search - private var tabToReturnTo: AppTab { - selectedTab == .search ? previousTab : selectedTab - } - - /// Check if there's an active workout private var hasActiveWorkout: Bool { activeWorkoutId != nil || betterFit.getActiveWorkout() != nil } + /// Physical bottom inset for the floating Start pill: + /// home indicator (~34) + tab bar (~49) + 8pt gap. + private static let startPillBottomInset: CGFloat = 78 + + /// One-yellow rule (design.md): at most one yellow fill is visible at a time. + /// When the screen leads with a full yellow field the field owns the yellow, + /// so the docked action renders as neutral glass. `AppTheme.quietPrimaryAction` + /// encodes exactly this ("header took the yellow"), so the decision holds for + /// any yellow-field screen — not a WorkoutHome special case. + private var dockIsQuiet: Bool { theme.quietPrimaryAction } + var body: some View { + tabView + .tint(BFColors.accentText(for: .dark)) + // Full-width Start button matching the floating tab bar width. + .overlay(alignment: .bottom) { + // Hide Plan's Start button while the live workout UI owns the tab, + // and while the keyboard is up — keyboard avoidance would otherwise + // drag the floating pill across the content being edited. + if selectedTab == .workout && !showActiveSession && !isKeyboardVisible { + startWorkoutDock + .padding(.horizontal, 16) + .padding(.bottom, Self.startPillBottomInset) + .ignoresSafeArea(.container, edges: .bottom) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .onAppear { + if healthKitManager == nil { + healthKitManager = HealthKitManager(healthKitService: betterFit.healthKitService) + } + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in + guard !isKeyboardVisible else { return } + withAnimation(.snappy(duration: 0.22)) { isKeyboardVisible = true } + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + guard isKeyboardVisible else { return } + withAnimation(.snappy(duration: 0.22)) { isKeyboardVisible = false } + } + // Active session is embedded in the Workout tab (Plan → Workout mode). + .sheet(isPresented: $showSummary) { + if let summaryData { + WorkoutSummaryView(data: summaryData) { + showSummary = false + } + } + } + .sheet(isPresented: $showSearch) { + AppSearchView( + theme: theme, + betterFit: betterFit, + query: $searchQuery, + previousTabIcon: "xmark", + onDismiss: { showSearch = false } + ) + } + .sheet(isPresented: $showProfile) { + NavigationStack { + ProfileView( + betterFit: betterFit, + theme: theme, + isGuest: isGuest, + user: user, + onShowSignIn: onShowSignIn, + onLogout: onLogout + ) + } + } + } + + // MARK: - Tab view + + private var tabView: some View { TabView(selection: $selectedTab) { ForEach(AppTab.allCases, id: \.self) { tab in tabContent(for: tab) @@ -93,113 +163,75 @@ struct RootTabView: View { .tag(tab) } } - .tint(theme.accent) - .onChange(of: selectedTab) { oldTab, newTab in - if newTab == .search && oldTab != .search { - previousTab = oldTab - } - } - .safeAreaInset(edge: .bottom) { - if selectedTab == .workout { - startWorkoutButton - .padding(.horizontal, 20) - .padding(.bottom, 60) - } - } - .onAppear { - if healthKitManager == nil { - healthKitManager = HealthKitManager(healthKitService: betterFit.healthKitService) - } - } - .sheet(isPresented: $showActiveSession) { - ActiveSessionView( - betterFit: betterFit, - onEnd: { completeWorkout() } - ) - } } - // MARK: - Start Workout Button + // MARK: - Start workout pill (standalone) @ViewBuilder - private var startWorkoutButton: some View { + private var startWorkoutDock: some View { if hasActiveWorkout { activeWorkoutControls } else { - Button { + BFDockPrimaryButton( + title: "Start workout", + systemImage: "play.fill", + standalone: false, + quiet: dockIsQuiet + ) { startOrResumeWorkout() - } label: { - HStack(spacing: 10) { - Image(systemName: "play.fill") - - Text("Start Workout") - - Spacer(minLength: 0) - - Image(systemName: "chevron.right") - .font(.subheadline.weight(.semibold)) - .opacity(0.7) - } - .padding(.horizontal, BFSpacing.xl) } - .buttonStyle(.bfPrimary) - .accessibilityLabel("Start Workout") + .accessibilityLabel("Start workout") } } @ViewBuilder private var activeWorkoutControls: some View { - HStack(spacing: BFSpacing.md) { - Button { + HStack(spacing: 10) { + BFDockPrimaryButton( + title: isWorkoutPaused ? "Resume" : "Pause", + systemImage: isWorkoutPaused ? "play.fill" : "pause.fill", + standalone: false, + quiet: dockIsQuiet + ) { if isWorkoutPaused { togglePause() } else { - // Pause + open the live session sheet so the user can log sets. betterFit.pauseWorkout() isWorkoutPaused = true showActiveSession = true } - } label: { - HStack(spacing: 8) { - Image(systemName: isWorkoutPaused ? "play.fill" : "pause.fill") - Text(isWorkoutPaused ? "Resume" : "Pause") - } } - .buttonStyle(.bfPrimary) - .accessibilityLabel(isWorkoutPaused ? "Resume Workout" : "Pause Workout") + .accessibilityLabel(isWorkoutPaused ? "Resume workout" : "Pause workout") Button { showStopConfirmation = true } label: { Image(systemName: "stop.fill") - .font(.body.weight(.semibold)) - .foregroundStyle(.white) + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(BFColors.background(for: .dark)) .frame(width: BFControlSize.buttonLarge, height: BFControlSize.buttonLarge) - .background( - RoundedRectangle(cornerRadius: BFRadius.button, style: .continuous) - .fill(BFColors.danger) - ) + .background(Circle().fill(Color.white)) + .shadow(color: Color.black.opacity(0.28), radius: 14, y: 6) + .contentShape(Circle()) } .buttonStyle(.plain) - .accessibilityLabel("Stop Workout") + .accessibilityLabel("Stop workout") } .confirmationDialog( - "End Workout", + "End workout", isPresented: $showStopConfirmation, titleVisibility: .visible ) { - Button("Complete & Save") { - completeWorkout() - } - Button("Discard Workout", role: .destructive) { - cancelWorkout() - } + Button("Complete & save") { completeWorkout() } + Button("Discard workout", role: .destructive) { cancelWorkout() } Button("Cancel", role: .cancel) {} } message: { Text("Would you like to save this workout or discard it?") } } + // MARK: - Workout actions + private func togglePause() { if isWorkoutPaused { betterFit.resumeWorkout() @@ -234,96 +266,103 @@ struct RootTabView: View { } private func startOrResumeWorkout() { - if hasActiveWorkout { - // Navigate to workout tab to show active workout - selectedTab = .workout - } else { - // Start a new workout — prefer today's plan so home/plan stay in sync - var workoutToStart: Workout - if let todayPlan = planManager.getTodayPlan(), !todayPlan.exercises.isEmpty { - workoutToStart = todayPlan.toWorkout() - } else if let recommended = betterFit.getRecommendedWorkout() { - workoutToStart = recommended - } else { - // Create a quick workout if no recommendation available - workoutToStart = Workout( - name: "Quick Workout", - exercises: [], - date: Date() - ) - } - // Mirror the started workout into the plan so the home preview and Plan tab agree - planManager.setSelectedWorkoutForToday(workoutToStart) - betterFit.startWorkout(workoutToStart) + selectedTab = .workout - // Update local state and post notification - activeWorkoutId = workoutToStart.id - NotificationCenter.default.post( - name: .workoutStarted, - object: workoutToStart.id - ) + if hasActiveWorkout { + // Resume — jump straight into the live session. + isWorkoutPaused = false + betterFit.resumeWorkout() + showActiveSession = true + return + } - // Navigate to workout tab - selectedTab = .workout + // Prefer today's plan (what Plan tab is showing). + var workoutToStart: Workout + if let todayPlan = planManager.getTodayPlan(), !todayPlan.exercises.isEmpty { + workoutToStart = todayPlan.toWorkout() + } else if let recommended = betterFit.getRecommendedWorkout() { + workoutToStart = recommended + } else { + workoutToStart = Workout(name: "Quick workout", exercises: [], date: Date()) } + + planManager.setSelectedWorkoutForToday(workoutToStart) + betterFit.startWorkout(workoutToStart) + activeWorkoutId = workoutToStart.id + isWorkoutPaused = false + NotificationCenter.default.post(name: .workoutStarted, object: workoutToStart.id) + showActiveSession = true } + // MARK: - Tabs + @ViewBuilder private func tabContent(for tab: AppTab) -> some View { switch tab { case .workout: NavigationStack { - WorkoutHomeView( - betterFit: betterFit, - theme: theme, - healthKitManager: healthKitManager, - planManager: planManager, - isGuest: isGuest, - user: user - ) + if hasActiveWorkout { + // Active workout takes over the Workout tab. + ActiveSessionView( + betterFit: betterFit, + theme: theme, + onEnd: { + completeWorkout() + showActiveSession = false + }, + onClose: { + // Minimize back to Plan without ending the session. + showActiveSession = false + isWorkoutPaused = true + betterFit.pauseWorkout() + }, + onFinishedSummary: { data in + summaryData = data + showActiveSession = false + showSummary = true + }, + embeddedInTab: true + ) + .navigationBarHidden(true) + } else { + WorkoutHomeView( + betterFit: betterFit, + theme: theme, + healthKitManager: healthKitManager, + planManager: planManager, + isGuest: isGuest, + user: user, + onSearch: { showSearch = true }, + onProfile: { showProfile = true } + ) + } } - case .plan: + case .body: NavigationStack { - PlanView(betterFit: betterFit, theme: theme, planManager: planManager) + RecoveryView(betterFit: betterFit, theme: theme) } - case .search: - AppSearchView( - theme: theme, - betterFit: betterFit, - query: $searchQuery, - previousTabIcon: tabToReturnTo.icon, - onDismiss: { - withAnimation { selectedTab = tabToReturnTo } - } - ) - case .me: + case .targets: NavigationStack { - ProfileView( - betterFit: betterFit, - theme: theme, - isGuest: isGuest, - user: user, - onShowSignIn: onShowSignIn, - onLogout: onLogout - ) + TargetsView(betterFit: betterFit, theme: theme, planManager: planManager) + } + case .log: + NavigationStack { + LogView(betterFit: betterFit, theme: theme) } } } } - #Preview { UserDefaults.standard.set(true, forKey: "betterfit.workoutHome.demoMode") let theme: AppTheme = .defaultTheme return RootTabView( - betterFit: BetterFit(), theme: theme, isGuest: false, + betterFit: BetterFit(), + theme: theme, + isGuest: false, user: nil, - onShowSignIn: { - print("Show sign in") - }, - onLogout: { - print("Logout") - } + onShowSignIn: {}, + onLogout: {} ) .preferredColorScheme(.dark) } diff --git a/Apps/iOS/BetterFitApp/Features/StreakSummary/StreakSummarySheetView.swift b/Apps/iOS/BetterFitApp/Features/StreakSummary/StreakSummarySheetView.swift index da2c900..474f368 100644 --- a/Apps/iOS/BetterFitApp/Features/StreakSummary/StreakSummarySheetView.swift +++ b/Apps/iOS/BetterFitApp/Features/StreakSummary/StreakSummarySheetView.swift @@ -474,7 +474,7 @@ extension HKWorkoutActivityType { return StreakSummarySheetView( betterFit: betterFit, selectedDate: $selectedDate, - theme: .midnight, + theme: .fitbod, openCalendar: {} ) } diff --git a/Apps/iOS/BetterFitApp/Features/Targets/TargetsView.swift b/Apps/iOS/BetterFitApp/Features/Targets/TargetsView.swift new file mode 100644 index 0000000..1c9dd33 --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/Targets/TargetsView.swift @@ -0,0 +1,317 @@ +import BetterFit +import SwiftUI + +// MARK: - Targets — weekly goals, week plan, streak, records + +struct TargetsView: View { + @Environment(\.colorScheme) private var colorScheme + let betterFit: BetterFit + let theme: AppTheme + var planManager: WorkoutPlanManager? + + // MARK: - Data + + private var weekStart: Date { + let cal = Calendar.current + return cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date())) ?? Date() + } + + private var weekWorkouts: [Workout] { + betterFit.getWorkoutHistory().filter { $0.isCompleted && $0.date >= weekStart } + } + + private var trailingWorkouts: [Workout] { + guard let start = Calendar.current.date(byAdding: .day, value: -28, to: weekStart) else { return [] } + return betterFit.getWorkoutHistory().filter { + $0.isCompleted && $0.date >= start && $0.date < weekStart + } + } + + private var workoutsDone: Int { + weekWorkouts.count + } + + private var plannedWorkouts: Int { + weekDays.filter { $0.workoutType != nil || !$0.exercises.isEmpty }.count + } + + private var workoutsTarget: Int? { + if plannedWorkouts > 0 { return plannedWorkouts } + let average = Double(trailingWorkouts.count) / 4 + return average.rounded() >= 1 ? Int(average.rounded()) : nil + } + + private var weekVolumeK: Double { + weekWorkouts.reduce(0.0) { $0 + loadVolume($1) } / 1000 + } + + private var weekTimeH: Double { + weekWorkouts.reduce(0.0) { $0 + ($1.duration ?? 0) } / 3600 + } + + private var volumeTargetK: Double? { + let total = trailingWorkouts.reduce(0.0) { $0 + loadVolume($1) } / 1000 + return total > 0 ? total / 4 : nil + } + + private var timeTargetH: Double? { + let total = trailingWorkouts.reduce(0.0) { $0 + ($1.duration ?? 0) } / 3600 + return total > 0 ? total / 4 : nil + } + + private var hasLoadData: Bool { + betterFit.getWorkoutHistory().contains { loadVolume($0) > 0 } + } + + private var hasDurationData: Bool { + betterFit.getWorkoutHistory().contains { ($0.duration ?? 0) > 0 } + } + + private func loadVolume(_ workout: Workout) -> Double { + workout.exercises.reduce(0.0) { total, exercise in + total + exercise.sets.reduce(0.0) { $0 + (Double($1.reps) * ($1.weight ?? 0)) } + } + } + + private var streakCurrent: Int { + betterFit.socialManager.getCurrentStreak() + } + + private var streakBest: Int { + max(betterFit.socialManager.getLongestStreak(), streakCurrent) + } + + private var weekDays: [WorkoutPlanDay] { + planManager?.getCurrentWeekDays() ?? [] + } + + // MARK: - Weekly targets + + private struct WeeklyTarget { + let label: String + let value: Double + let target: Double? + let unit: String + } + + private var targets: [WeeklyTarget] { + var rows = [ + WeeklyTarget( + label: "Workouts", + value: Double(workoutsDone), + target: workoutsTarget.map { Double($0) }, + unit: "" + ) + ] + if hasLoadData { + rows.append(WeeklyTarget( + label: "Volume", + value: weekVolumeK, + target: volumeTargetK, + unit: "k" + )) + } + if hasDurationData { + rows.append(WeeklyTarget( + label: "Time", + value: weekTimeH, + target: timeTargetH, + unit: "h" + )) + } + return rows + } + + private var targetsSectionMeta: String { + let fromPlan = plannedWorkouts > 0 + let fromAverage = volumeTargetK != nil + || timeTargetH != nil + || (!fromPlan && workoutsTarget != nil) + switch (fromPlan, fromAverage) { + case (true, true): return "Goals from your plan and 4-week averages" + case (true, false): return "Goals from your plan" + case (false, true): return "Goals from your 4-week averages" + default: return "This week so far" + } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + weekSlab + + VStack(alignment: .leading, spacing: 0) { + BFSectionRule(label: "Weekly targets", trailing: targetsSectionMeta) + ForEach(Array(targets.enumerated()), id: \.offset) { _, item in + targetRow(item) + } + + if !weekDays.isEmpty { + BFSectionRule(label: "The week", trailing: "Mon–Sun") + ForEach(weekDays) { day in + weekDayRow(day) + } + } + + BFSectionRule(label: "Streak") + BFLedgerRow( + systemImage: "flame.fill", + title: "Current streak", + meta: "Best is \(streakBest) days" + ) { + BFLedgerTrailing(value: "\(streakCurrent) days") + } + + BFSectionRule(label: "Records this month") + BFLedgerRow( + systemImage: "medal.fill", + title: "Keep training", + meta: "Personal records appear here" + ) { + EmptyView() + } + } + .padding(.horizontal, BFSpacing.pageHorizontal) + .padding(.bottom, 110) + } + } + .bfBackground(theme: theme) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Targets") + .font(BFTypography.screenTitle) + .tracking(BFTypography.displayTracking) + } + } + } + + // MARK: - Slab + + private var weekSlab: some View { + BFSlab { + VStack(alignment: .leading, spacing: 0) { + BFEyebrow(text: "This week", color: BFColors.identityInk.opacity(0.6)) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(workoutsDone)") + .font(BFTypography.readout) + .foregroundStyle(BFColors.identityInk) + if let workoutsTarget { + Text("/ \(workoutsTarget)") + .font(BFTypography.screenTitle) + .monospacedDigit() + .foregroundStyle(BFColors.identityInk.opacity(0.5)) + } + } + .padding(.top, 10) + + Text(slabMessage) + .font(BFTypography.bodyEmphasis) + .foregroundStyle(BFColors.identityInk) + .padding(.top, 14) + .fixedSize(horizontal: false, vertical: true) + + if let workoutsTarget { + HStack(spacing: 5) { + ForEach(0.. some View { + VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(item.label) + .font(BFTypography.bodyEmphasis) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Spacer() + Text(BFFormat.trimmed(item.value) + item.unit) + .font(BFTypography.subheadlineEmphasis) + .monospacedDigit() + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + if let target = item.target { + Text("/ \(BFFormat.trimmed(target))\(item.unit)") + .font(BFTypography.footnote) + .monospacedDigit() + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + } + } + if let target = item.target, target > 0 { + let pct = item.value / target + BFBar(progress: pct) + Text("\(Int((pct * 100).rounded()))% of the week") + .font(BFTypography.caption) + .monospacedDigit() + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + } + } + .padding(.vertical, 14) + .overlay(alignment: .top) { + Rectangle() + .fill(BFColors.separator(for: colorScheme)) + .frame(height: 1) + } + } + + // MARK: - Week day + + private func weekDayRow(_ day: WorkoutPlanDay) -> some View { + let name: String = { + if day.isRest { return "Rest" } + if let type = day.workoutType { return type.rawValue } + if day.exercises.isEmpty { return "Rest" } + return "Session" + }() + let isRest = day.isRest || (day.exercises.isEmpty && day.workoutType == nil) + return BFLedgerRow( + gutter: dayLabel(day.date), + gutterAccent: day.isToday, + title: name, + meta: day.isToday ? "Today" : day.isCompleted ? "Completed" : isRest ? "Scheduled rest" : "Scheduled", + dim: isRest + ) { + if day.isCompleted { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(BFColors.accentText(for: colorScheme)) + } + } + } + + private func dayLabel(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "EE" + return String(formatter.string(from: date).prefix(2)).uppercased() + } + +} + +#Preview { + NavigationStack { + TargetsView(betterFit: BetterFit(), theme: .defaultTheme) + } + .preferredColorScheme(.dark) +} diff --git a/Apps/iOS/BetterFitApp/Features/Theme/ThemePickerView.swift b/Apps/iOS/BetterFitApp/Features/Theme/ThemePickerView.swift index ba3ea48..f0e6cdd 100644 --- a/Apps/iOS/BetterFitApp/Features/Theme/ThemePickerView.swift +++ b/Apps/iOS/BetterFitApp/Features/Theme/ThemePickerView.swift @@ -10,7 +10,7 @@ struct ThemePickerView: View { NavigationStack { List { Section("Themes") { - ForEach(AppTheme.allCases) { theme in + ForEach(AppTheme.selectableThemes) { theme in Button { withAnimation(.snappy) { selectedTheme = theme @@ -140,7 +140,7 @@ private struct ThemeSwatch: View { } #Preview { - @Previewable @State var theme: AppTheme = .midnight + @Previewable @State var theme: AppTheme = .fitbod @Previewable @State var appearance: AppearancePreference = .system ThemePickerView(selectedTheme: $theme, appearance: $appearance) } diff --git a/Apps/iOS/BetterFitApp/Features/Trends/ExercisePickerView.swift b/Apps/iOS/BetterFitApp/Features/Trends/ExercisePickerView.swift index 9e1396b..04b8435 100644 --- a/Apps/iOS/BetterFitApp/Features/Trends/ExercisePickerView.swift +++ b/Apps/iOS/BetterFitApp/Features/Trends/ExercisePickerView.swift @@ -394,7 +394,7 @@ struct ExercisePickerView: View { } #Preview { - ExercisePickerView(theme: .forest) { exercises in + ExercisePickerView(theme: .fitbod) { exercises in print("Added: \(exercises.map(\.name))") } } diff --git a/Apps/iOS/BetterFitApp/Features/Trends/PlanView.swift b/Apps/iOS/BetterFitApp/Features/Trends/PlanView.swift index f83bda3..1d4cac3 100644 --- a/Apps/iOS/BetterFitApp/Features/Trends/PlanView.swift +++ b/Apps/iOS/BetterFitApp/Features/Trends/PlanView.swift @@ -348,6 +348,6 @@ struct PlanView: View { #Preview { UserDefaults.standard.set(true, forKey: "betterfit.workoutHome.demoMode") return NavigationStack { - PlanView(betterFit: BetterFit(), theme: .forest, planManager: WorkoutPlanManager()) + PlanView(betterFit: BetterFit(), theme: .fitbod, planManager: WorkoutPlanManager()) } } diff --git a/Apps/iOS/BetterFitApp/Features/Trends/TrendsView.swift b/Apps/iOS/BetterFitApp/Features/Trends/TrendsView.swift index 84228c0..9328e28 100644 --- a/Apps/iOS/BetterFitApp/Features/Trends/TrendsView.swift +++ b/Apps/iOS/BetterFitApp/Features/Trends/TrendsView.swift @@ -128,5 +128,5 @@ struct TrendsView: View { } #Preview { - TrendsView(theme: .midnight) + TrendsView(theme: .fitbod) } diff --git a/Apps/iOS/BetterFitApp/Features/WorkoutHome/SupersetStore.swift b/Apps/iOS/BetterFitApp/Features/WorkoutHome/SupersetStore.swift new file mode 100644 index 0000000..d07f18d --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/SupersetStore.swift @@ -0,0 +1,44 @@ +import Foundation + +// MARK: - Superset store +// +// Lightweight, app-wide registry of which exercises are paired as supersets. +// Owned by the Plan tab; consulted by the Live session to flip between +// paired exercises (A1 → B1 → rest → A2 → B2 …) and render the badge. + +@MainActor +final class BFSupersetStore: ObservableObject { + static let shared = BFSupersetStore() + + /// Exercise id → shared group id. + @Published private(set) var group: [UUID: UUID] = [:] + + func isLinked(_ id: UUID) -> Bool { group[id] != nil } + + func partner(of id: UUID) -> UUID? { + guard let groupID = group[id] else { return nil } + return group.first(where: { $0.key != id && $0.value == groupID })?.key + } + + /// Link two exercises into the same group. No-op if already in the same group. + func link(_ exerciseA: UUID, _ exerciseB: UUID) { + if exerciseA == exerciseB { return } + let existingA = group[exerciseA] + let existingB = group[exerciseB] + let newGroup = existingA ?? existingB ?? UUID() + group[exerciseA] = newGroup + group[exerciseB] = newGroup + } + + func unlink(_ id: UUID) { + guard let groupID = group[id] else { return } + for key in Array(group.keys) where group[key] == groupID { + group.removeValue(forKey: key) + } + } + + func reset() { group.removeAll() } + +/// Plain dictionary snapshot for read-only consumers (e.g. live session). +func snapshot() -> [UUID: UUID] { group } +} \ No newline at end of file diff --git a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeComponents.swift b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeComponents.swift index 88996ca..5cd4a67 100644 --- a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeComponents.swift +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeComponents.swift @@ -1,1169 +1,52 @@ import BetterFit import SwiftUI -extension WorkoutHomeView { - // MARK: - Compact Components - - struct CompactMuscleChip: View { - @Environment(\.colorScheme) var colorScheme - let muscle: String - let percent: Int - let theme: AppTheme - - var body: some View { - HStack(spacing: 4) { - Image(systemName: muscleIcon) - .font(BFTypography.captionEmphasis) - .foregroundStyle(theme.accent) - .frame(width: 14) - - Text(muscle) - .font(BFTypography.captionEmphasis) - .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(-1) - - Text("\(percent)%") - .font(BFTypography.caption) - .monospacedDigit() - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - .fixedSize() - } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(BFColors.surfaceRaised(for: colorScheme), in: Capsule()) - .overlay { Capsule().stroke(BFColors.border(for: colorScheme), lineWidth: 1) } - } - - private var muscleIcon: String { - switch muscle.lowercased() { - case "back", "lats": return "figure.strengthtraining.traditional" - case "hamstrings", "legs", "quadriceps", "calves": return "figure.walk" - case "chest": return "figure.arms.open" - case "shoulders": return "figure.wrestling" - case "core", "abs": return "figure.core.training" - case "biceps", "triceps", "arms": return "figure.boxing" - default: return "figure.mixed.cardio" - } - } - } - - struct CompactExerciseRow: View { - @Environment(\.colorScheme) var colorScheme - let exercise: WorkoutExercise - let index: Int - let theme: AppTheme - - var body: some View { - HStack(spacing: 10) { - // Index circle - ZStack { - Circle() - .fill(theme.accentSurface(0.15, for: colorScheme)) - .frame(width: 28, height: 28) - Text("\(index + 1)") - .font(BFTypography.captionEmphasis) - .monospacedDigit() - .foregroundStyle(theme.accent) - } - - // Exercise icon - ZStack { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill( - LinearGradient( - colors: gradientColors, - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 36, height: 36) - - Image(systemName: categoryIcon) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white.opacity(0.9)) - } - - // Name and category - VStack(alignment: .leading, spacing: 2) { - Text(exercise.exercise.name) - .font(BFTypography.subheadlineEmphasis) - .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - .lineLimit(1) - - Text(categoryName) - .font(BFTypography.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - - Spacer() - - // Sets info - Text(setsInfo) - .font(BFTypography.captionEmphasis) - .monospacedDigit() - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - - // Category icon - ZStack { - Circle() - .fill(categoryColor.opacity(0.15)) - .frame(width: 28, height: 28) - Image(systemName: categoryIcon) - .font(BFTypography.captionEmphasis) - .foregroundStyle(categoryColor) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(BFColors.surface(for: colorScheme)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(BFColors.border(for: colorScheme), lineWidth: 1) - } - } - } - - private var categoryName: String { - let name = exercise.exercise.name.lowercased() - if name.contains("run") || name.contains("cardio") { return "Cardio" } - if name.contains("press") || name.contains("push") { return "Push" } - if name.contains("row") || name.contains("pull") { return "Pull" } - if name.contains("squat") || name.contains("leg") { return "Legs" } - return "Compound" - } - - private var setsInfo: String { - let sets = exercise.sets.count - let reps = exercise.sets.first?.reps ?? 0 - return "\(sets) sets × \(reps)" - } - - private var gradientColors: [Color] { - let name = exercise.exercise.name.lowercased() - if name.contains("run") || name.contains("cardio") { return [.red, .orange] } - if name.contains("press") || name.contains("push") { return [.blue, .cyan] } - if name.contains("row") || name.contains("pull") { return [.purple, .pink] } - if name.contains("squat") || name.contains("leg") { return [.orange, .yellow] } - return [.green, .teal] - } - - private var categoryIcon: String { - let name = exercise.exercise.name.lowercased() - if name.contains("run") { return "figure.run" } - if name.contains("press") { return "arrow.up" } - if name.contains("row") || name.contains("pull") { return "arrow.down" } - if name.contains("squat") || name.contains("leg") { return "figure.walk" } - return "dumbbell" - } - - private var categoryColor: Color { - let name = exercise.exercise.name.lowercased() - if name.contains("run") || name.contains("cardio") { return .red } - if name.contains("press") || name.contains("push") { return .blue } - if name.contains("row") || name.contains("pull") { return .purple } - if name.contains("squat") || name.contains("leg") { return .orange } - return .green - } - } - - // MARK: - Components - - struct WorkoutSwipeCard: View { - @Environment(\.colorScheme) var colorScheme - let workout: Workout - let theme: AppTheme - let isTopCard: Bool - - var body: some View { - HStack(spacing: 16) { - // Workout icon/image placeholder - ZStack { - RoundedRectangle(cornerRadius: 16) - .fill(theme.accentSurface(0.15, for: colorScheme)) - .frame(width: 100, height: 140) - - VStack(spacing: 8) { - Image(systemName: iconForWorkout) - .font(.system(size: 36)) - .foregroundStyle(theme.accent) - - Text(workoutType) - .font(BFTypography.captionEmphasis) - .foregroundStyle(.white) - } - } - - // Workout details - VStack(alignment: .leading, spacing: 8) { - Text(workout.name) - .bfHeading(theme: theme, size: 18, relativeTo: .headline) - .lineLimit(2) - - // Quick stats row - HStack(spacing: 16) { - Label("1h", systemImage: "clock") - Label("Equipment", systemImage: "dumbbell") - } - .font(BFTypography.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - - HStack(spacing: 8) { - workoutPill(workoutType) - workoutPill("Intermediate") - } - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 172) - .background { - RoundedRectangle(cornerRadius: BFRadius.card, style: .continuous) - .fill(BFColors.surface(for: colorScheme)) - .overlay { - RoundedRectangle(cornerRadius: BFRadius.card, style: .continuous) - .stroke(BFColors.border(for: colorScheme), lineWidth: 1) - } - } - } - - private var iconForWorkout: String { - let name = workout.name.lowercased() - if name.contains("run") { return "figure.run" } - if name.contains("yoga") { return "figure.yoga" } - if name.contains("strength") || name.contains("upper") { return "dumbbell.fill" } - if name.contains("hiit") { return "flame.fill" } - if name.contains("push") { return "figure.strengthtraining.traditional" } - if name.contains("pull") { return "dumbbell.fill" } - if name.contains("leg") { return "figure.walk" } - return "figure.mixed.cardio" - } - - private var workoutType: String { - let name = workout.name.lowercased() - if name.contains("cardio") || name.contains("run") { return "Cardio" } - if name.contains("yoga") { return "Flexibility" } - if name.contains("hiit") { return "HIIT" } - return "Circuit Training" - } - - func workoutPill(_ text: String) -> some View { - Text(text) - .font(BFTypography.captionEmphasis) - .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(BFColors.surfaceRaised(for: colorScheme), in: Capsule()) - .overlay { Capsule().stroke(BFColors.border(for: colorScheme), lineWidth: 1) } - } - } - - struct TargetMuscleView: View { - @Environment(\.colorScheme) var colorScheme - let muscle: String - let percent: Int - let theme: AppTheme - - var body: some View { - VStack(spacing: 8) { - // Body silhouette placeholder - ZStack { - RoundedRectangle(cornerRadius: 12) - .fill(theme.accentSurface(0.12, for: colorScheme)) - .frame(width: 60, height: 70) - - Image(systemName: muscleIcon) - .font(.title2) - .foregroundStyle(theme.accent) - } - - VStack(spacing: 2) { - Text(muscle) - .font(BFTypography.captionEmphasis) - .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - - Text("\(percent)%") - .font(BFTypography.caption) - .monospacedDigit() - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(BFColors.surfaceRaised(for: colorScheme), in: Capsule()) - } - } - } - - private var muscleIcon: String { - switch muscle.lowercased() { - case "back", "lats": return "figure.strengthtraining.traditional" - case "hamstrings", "legs", "quadriceps": return "figure.walk" - case "chest": return "figure.arms.open" - case "shoulders": return "figure.wrestling" - case "core", "abs": return "figure.core.training" - case "biceps", "triceps", "arms": return "figure.boxing" - default: return "figure.mixed.cardio" - } - } - } - - struct ExercisePreviewRow: View { - @Environment(\.colorScheme) var colorScheme - let exercise: WorkoutExercise - let theme: AppTheme - - var body: some View { - HStack(spacing: 12) { - // Exercise image placeholder - ZStack { - RoundedRectangle(cornerRadius: 12) - .fill(theme.accentSurface(0.12, for: colorScheme)) - .frame(width: 64, height: 64) - - Image(systemName: exerciseIcon) - .font(.title2) - .foregroundStyle(theme.accent) - } - - VStack(alignment: .leading, spacing: 4) { - Text(exercise.exercise.name) - .font(BFTypography.subheadlineEmphasis) - .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - - Text(exerciseDetails) - .font(BFTypography.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - - Spacer() - - Menu { - Button("Swap Exercise") {} - Button("Adjust Sets") {} - } label: { - Image(systemName: "ellipsis") - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - .frame(width: 30, height: 30) - } - } - .padding(12) - .background { - let shape = RoundedRectangle(cornerRadius: 14, style: .continuous) - shape - .fill(BFColors.surface(for: colorScheme)) - .overlay { shape.stroke(BFColors.border(for: colorScheme), lineWidth: 1) } - } - } - - private var exerciseIcon: String { - let name = exercise.exercise.name.lowercased() - if name.contains("row") { return "dumbbell.fill" } - if name.contains("curl") { return "dumbbell.fill" } - if name.contains("press") { return "figure.strengthtraining.traditional" } - if name.contains("squat") { return "figure.walk" } - if name.contains("run") { return "figure.run" } - return "dumbbell.fill" - } - - private var exerciseDetails: String { - let sets = exercise.sets.count - let reps = exercise.sets.first?.reps ?? 0 - let weight = exercise.sets.first?.weight - - if let weightVal = weight, weightVal > 0 { - return "\(sets) sets • \(reps) reps • \(Int(weightVal)) kg" - } else { - return "\(sets) sets • \(reps) reps" - } - } - } - - struct SemiCircularGauge: View { - @Environment(\.colorScheme) var colorScheme - let value: Double - let theme: AppTheme - - var body: some View { - let clamped = min(max(value, 0), 1) - - ZStack { - Circle() - .trim(from: 0, to: 0.5) - .stroke( - BFColors.border(for: colorScheme).opacity(0.5), - style: StrokeStyle(lineWidth: 10, lineCap: .round) - ) - - Circle() - .trim(from: 0, to: 0.5 * clamped) - .stroke( - theme.accent, - style: StrokeStyle(lineWidth: 10, lineCap: .round) - ) - - Rectangle() - .fill(theme.accentSurface(0.9, for: colorScheme)) - .frame(width: 44, height: 2) - .offset(x: 22) - .rotationEffect(.degrees(180 * clamped)) - - Circle() - .fill(BFColors.surface(for: colorScheme)) - .frame(width: 10, height: 10) - .overlay { Circle().stroke(BFColors.border(for: colorScheme), lineWidth: 1) } - } - .padding(.top, 2) - .padding(.horizontal, 2) - } - } - - struct OverviewStat: View { - @Environment(\.colorScheme) var colorScheme - let title: String - let value: String - let systemImage: String - let theme: AppTheme - - var body: some View { - HStack(spacing: 6) { - Image(systemName: systemImage) - .font(BFTypography.captionEmphasis) - .foregroundStyle(theme.accent) - - VStack(alignment: .leading, spacing: 0) { - Text(value) - .font(BFTypography.captionEmphasis) - .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - .monospacedDigit() - Text(title) - .font(BFTypography.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - } - } - } - - struct CategoryLegendDot: View { - @Environment(\.colorScheme) var colorScheme - let label: String - let percent: Int - let color: Color - let theme: AppTheme - - var body: some View { - HStack(spacing: 6) { - Circle() - .fill(color) - .frame(width: 7, height: 7) - Text("\(label) \(percent)%") - .font(BFTypography.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - .monospacedDigit() - } - } - } - - struct GoalStat: View { - let icon: String - let value: String - let theme: AppTheme - - var body: some View { - HStack(spacing: 6) { - Image(systemName: icon) - .font(BFTypography.caption) - Text(value) - .font(BFTypography.headline) - } - .foregroundStyle(.white) - } - } - - struct WorkoutSuggestionCard: View { - @Environment(\.colorScheme) var colorScheme - let workout: Workout - let theme: AppTheme - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: 12) { - // Preview image placeholder - ZStack { - RoundedRectangle(cornerRadius: 16) - .fill(theme.accentSurface(0.3, for: colorScheme)) - .frame(height: 140) - - Image(systemName: iconForWorkout) - .font(.system(size: 48)) - .foregroundStyle(theme.accent) - } - - VStack(alignment: .leading, spacing: 4) { - Text(workout.name) - .font(BFTypography.headline) - .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - .lineLimit(2) - - HStack(spacing: 12) { - Label( - "\(workout.exercises.count) exercises", systemImage: "list.bullet") - Label("30min", systemImage: "clock") - } - .font(BFTypography.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - - Spacer() - } - .frame(width: 200) - .padding(12) - .background { - RoundedRectangle(cornerRadius: BFRadius.card, style: .continuous) - .fill(BFColors.surface(for: colorScheme)) - .overlay { - RoundedRectangle(cornerRadius: BFRadius.card, style: .continuous) - .stroke(BFColors.border(for: colorScheme), lineWidth: 1) - } - } - } - .buttonStyle(.plain) - } - - private var iconForWorkout: String { - let name = workout.name.lowercased() - if name.contains("run") { return "figure.run" } - if name.contains("yoga") { return "figure.yoga" } - if name.contains("strength") || name.contains("upper") { return "dumbbell.fill" } - if name.contains("hiit") { return "flame.fill" } - return "figure.mixed.cardio" - } - } - - struct FeatureRow: View { - let icon: String - let title: String - let theme: AppTheme - - var body: some View { - HStack(spacing: 16) { - Image(systemName: icon) - .font(.title2) - .foregroundStyle(theme.accent) - .frame(width: 40) - - Text(title) - .font(BFTypography.headline) - - Spacer() - } - .padding(.horizontal) - } - } - - struct SubscriptionView: View { - let theme: AppTheme - @Environment(\.dismiss) private var dismiss - @Environment(\.colorScheme) var colorScheme - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 24) { - ZStack { - Circle() - .fill(theme.accent) - .frame(width: 120, height: 120) - - Text("GO") - .font(.system(size: 48, weight: .black, design: .rounded)) - .foregroundStyle(.white) - } - .padding(.top, 40) - - Text("Start your fitness journey") - .bfHeading(theme: theme, size: 32, relativeTo: .largeTitle) - .multilineTextAlignment(.center) - - Text( - "First week free, then just $19.99/year\nLess than your monthly coffee habit!" - ) - .font(BFTypography.headline) - .multilineTextAlignment(.center) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - - VStack(spacing: 16) { - FeatureRow(icon: "sparkles", title: "AI-Powered Workouts", theme: theme) - FeatureRow(icon: "heart.fill", title: "Recovery Tracking", theme: theme) - FeatureRow( - icon: "chart.line.uptrend.xyaxis", title: "Progress Analytics", - theme: theme) - FeatureRow( - icon: "trophy.fill", title: "Achievements & Streaks", theme: theme) - } - .padding(.vertical) - - Button { - // Start trial - dismiss() - } label: { - Text("Start Free Trial") - .font(BFTypography.headline) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(theme.accent) - .clipShape(Capsule()) - } - .padding(.horizontal) - - Button("Maybe Later") { - dismiss() - } - .font(BFTypography.subheadline) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - } - .padding() - } - .bfBackground(theme: theme) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Close") { - dismiss() - } - } - } - } - } - } - - struct CustomHeatmapRangeSheet: View { - let theme: AppTheme - @Binding var start: Date - @Binding var end: Date - - @Environment(\.dismiss) private var dismiss - - @State private var draftStart: Date = Date.now - @State private var draftEnd: Date = Date.now - - var body: some View { - NavigationStack { - Form { - Section { - DatePicker("Start", selection: $draftStart, displayedComponents: [.date]) - DatePicker("End", selection: $draftEnd, displayedComponents: [.date]) - } - - Section { - Button("Preset: Last 3 Years") { - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date.now) - draftEnd = today - draftStart = - calendar.date(byAdding: .year, value: -3, to: today) ?? today - } - } - } - .scrollContentBackground(.hidden) - .bfBackground(theme: theme) - .navigationTitle("Custom Range") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { - dismiss() - } - } - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { - start = draftStart - end = draftEnd - dismiss() - } - .font(BFTypography.headline) - } - } - } - .onAppear { - draftStart = start - draftEnd = end - - // If the stored custom range is effectively unset, default to last 3 years. - if abs(draftStart.timeIntervalSince(draftEnd)) < 1 { - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date.now) - draftEnd = today - draftStart = calendar.date(byAdding: .year, value: -3, to: today) ?? today - } - } - } - } - - struct ContributionHeatmap: View { - let startDate: Date - let endDate: Date - let valuesByDay: [Date: Int] - let theme: AppTheme - - @State private var didAutoScrollToEnd = false - - private let cell: CGFloat = 11 - private let gap: CGFloat = 4 - - var body: some View { - let calendar = Calendar.current - let rangeStart = calendar.startOfDay(for: startDate) - let rangeEnd = calendar.startOfDay(for: endDate) - - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 14) { - ForEach( - monthStarts( - rangeStart: rangeStart, rangeEnd: rangeEnd, calendar: calendar), - id: \.self - ) { monthStart in - MonthBlock( - monthStart: monthStart, - rangeStart: rangeStart, - rangeEnd: rangeEnd, - valuesByDay: valuesByDay, - cell: cell, - gap: gap, - theme: theme - ) - .id(monthStart) - } - - Color.clear - .frame(width: 1, height: 1) - .id("heatmap-end") - } - .padding(.vertical, 2) - } - .onAppear { - guard !didAutoScrollToEnd else { return } - didAutoScrollToEnd = true - DispatchQueue.main.async { - proxy.scrollTo("heatmap-end", anchor: .trailing) - } - } - .onChange(of: endDate) { - DispatchQueue.main.async { - proxy.scrollTo("heatmap-end", anchor: .trailing) - } - } - .onChange(of: startDate) { - DispatchQueue.main.async { - proxy.scrollTo("heatmap-end", anchor: .trailing) - } - } - } - .mask { RoundedRectangle(cornerRadius: 16, style: .continuous) } - } - - private func monthStarts(rangeStart: Date, rangeEnd: Date, calendar: Calendar) -> [Date] { - guard rangeStart <= rangeEnd else { return [] } - guard - let startOfStartMonth = calendar.date( - from: calendar.dateComponents([.year, .month], from: rangeStart)), - let startOfEndMonth = calendar.date( - from: calendar.dateComponents([.year, .month], from: rangeEnd)) - else { - return [] - } - - var months: [Date] = [] - var cursor = startOfStartMonth - while cursor <= startOfEndMonth { - months.append(cursor) - guard let next = calendar.date(byAdding: .month, value: 1, to: cursor) else { - break - } - cursor = next - } - return months - } - - private struct MonthBlock: View { - @Environment(\.colorScheme) var colorScheme - let monthStart: Date - let rangeStart: Date - let rangeEnd: Date - let valuesByDay: [Date: Int] - let cell: CGFloat - let gap: CGFloat - let theme: AppTheme - - var body: some View { - let calendar = Calendar.current - let monthEnd = endOfMonth(for: monthStart, calendar: calendar) - let clampedEnd = min(monthEnd, rangeEnd) - let clampedStart = max(monthStart, rangeStart) - - VStack(alignment: .leading, spacing: 8) { - Text(monthStart.formatted(.dateTime.month(.abbreviated))) - .font(BFTypography.captionEmphasis) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - - HStack(alignment: .top, spacing: gap) { - ForEach( - weekStarts( - forMonthStart: monthStart, monthEnd: clampedEnd, calendar: calendar), - id: \.self - ) { weekStart in - VStack(spacing: gap) { - ForEach(0..<7, id: \.self) { dayOffset in - let date = - calendar.date( - byAdding: .day, value: dayOffset, to: weekStart) - ?? weekStart - DayCell( - date: date, - rangeStart: clampedStart, - rangeEnd: clampedEnd, - valuesByDay: valuesByDay, - cell: cell, - theme: theme - ) - } - } - } - } - } - } - - private func endOfMonth(for date: Date, calendar: Calendar) -> Date { - guard - let start = calendar.date( - from: calendar.dateComponents([.year, .month], from: date)), - let next = calendar.date(byAdding: .month, value: 1, to: start), - let end = calendar.date(byAdding: .day, value: -1, to: next) - else { - return date - } - return end - } - - private func weekStarts( - forMonthStart monthStart: Date, monthEnd: Date, calendar: Calendar - ) -> [Date] { - guard - let firstWeekStart = calendar.dateInterval(of: .weekOfYear, for: monthStart)? - .start, - let lastWeekStart = calendar.dateInterval(of: .weekOfYear, for: monthEnd)?.start - else { - return [] - } - - var weeks: [Date] = [] - var cursor = firstWeekStart - while cursor <= lastWeekStart { - weeks.append(cursor) - guard let next = calendar.date(byAdding: .day, value: 7, to: cursor) else { - break - } - cursor = next - } - return weeks - } - } - - private struct DayCell: View { - @Environment(\.colorScheme) var colorScheme - let date: Date - let rangeStart: Date - let rangeEnd: Date - let valuesByDay: [Date: Int] - let cell: CGFloat - let theme: AppTheme - - var body: some View { - let calendar = Calendar.current - let day = calendar.startOfDay(for: date) - let isInRange = - day >= calendar.startOfDay(for: rangeStart) - && day <= calendar.startOfDay(for: rangeEnd) - - Group { - if isInRange { - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill(color(for: valuesByDay[day, default: 0])) - .overlay { - RoundedRectangle(cornerRadius: 3, style: .continuous) - .stroke(BFColors.border(for: colorScheme).opacity(0.7), lineWidth: 0.5) - } - .accessibilityLabel( - accessibilityText(day: day, count: valuesByDay[day, default: 0])) - } else { - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill(Color.clear) - } - } - .frame(width: cell, height: cell) - } - - private func color(for count: Int) -> Color { - switch count { - case 0: - return theme.accentSurface(0, for: colorScheme) - case 1: - return theme.accentSurface(0.18, for: colorScheme) - case 2: - return theme.accentSurface(0.34, for: colorScheme) - case 3: - return theme.accentSurface(0.52, for: colorScheme) - default: - return theme.accentSurface(0.75, for: colorScheme) - } - } - - private func accessibilityText(day: Date, count: Int) -> String { - if count == 0 { - return "\(day.formatted(date: .abbreviated, time: .omitted)): no workouts" - } - if count == 1 { - return "\(day.formatted(date: .abbreviated, time: .omitted)): 1 workout" - } - return "\(day.formatted(date: .abbreviated, time: .omitted)): \(count) workouts" - } - } - } - - // MARK: - Equipment Swap Sheet - struct EquipmentSwapSheet: View { - let theme: AppTheme - @Binding var availableEquipment: Set - let onApply: () -> Void - - @Environment(\.dismiss) private var dismiss - @Environment(\.colorScheme) var colorScheme - - // Gym location presets - private let gymPresets: [(name: String, icon: String, equipment: Set)] = [ - ( - "Full Gym", - "building.2.fill", - Set(Equipment.allCases) - ), - ( - "Home Gym", - "house.fill", - [.dumbbell, .kettlebell, .bands, .bodyweight] - ), - ( - "Hotel / Travel", - "suitcase.fill", - [.bodyweight, .bands] - ), - ( - "Outdoor / Park", - "leaf.fill", - [.bodyweight] - ), - ( - "Cable Only", - "cable.coaxial", - [.cable, .machine, .bodyweight] - ), - ] - - var body: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - // Location presets - VStack(alignment: .leading, spacing: 12) { - Text("Quick Select") - .font(BFTypography.headline) - - LazyVGrid( - columns: [ - GridItem(.flexible()), - GridItem(.flexible()), - ], - spacing: 12 - ) { - ForEach(gymPresets, id: \.name) { preset in - GymPresetButton( - name: preset.name, - icon: preset.icon, - isSelected: availableEquipment == preset.equipment, - theme: theme - ) { - withAnimation(.spring(response: 0.3)) { - availableEquipment = preset.equipment - } - } - } +// MARK: - Equipment swap sheet + +struct EquipmentSwapSheet: View { + let theme: AppTheme + @Binding var availableEquipment: Set + let onApply: () -> Void + + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + NavigationStack { + List { + ForEach(Equipment.allCases, id: \.self) { eq in + Button { + if availableEquipment.contains(eq) { + availableEquipment.remove(eq) + } else { + availableEquipment.insert(eq) + } + } label: { + HStack { + Text(eq.rawValue.capitalized) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Spacer() + if availableEquipment.contains(eq) { + Image(systemName: "checkmark") + .foregroundStyle(BFColors.accentText(for: colorScheme)) } } - - BFDivider() - - // Individual equipment toggles - VStack(alignment: .leading, spacing: 12) { - Text("Available Equipment") - .font(BFTypography.headline) - - ForEach(Equipment.allCases, id: \.self) { equipment in - EquipmentToggleRow( - equipment: equipment, - isSelected: availableEquipment.contains(equipment), - theme: theme - ) { - withAnimation(.spring(response: 0.2)) { - if availableEquipment.contains(equipment) { - availableEquipment.remove(equipment) - } else { - availableEquipment.insert(equipment) - } - } - } - } - } - - // Info text - Text( - "Exercises will be automatically swapped to alternatives based on your available equipment." - ) - .font(BFTypography.caption) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - .padding(.top, 8) - } - .padding() - } - .bfBackground(theme: theme) - .navigationTitle("Swap Equipment") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { - dismiss() - } - } - ToolbarItem(placement: .topBarTrailing) { - Button("Apply") { - onApply() - dismiss() - } - .font(BFTypography.headline) } } } - } - } - - struct GymPresetButton: View { - @Environment(\.colorScheme) var colorScheme - let name: String - let icon: String - let isSelected: Bool - let theme: AppTheme - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 8) { - Image(systemName: icon) - .font(.title2) - .foregroundStyle( - isSelected ? theme.accent : BFColors.textSecondary(for: colorScheme)) - - Text(name) - .font(BFTypography.captionEmphasis) - .foregroundStyle( - isSelected - ? BFColors.textPrimary(for: colorScheme) - : BFColors.textSecondary(for: colorScheme)) + .navigationTitle("Equipment") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background { - let shape = RoundedRectangle(cornerRadius: 14, style: .continuous) - if isSelected { - shape - .fill(theme.accentSurface(0.15, for: colorScheme)) - .overlay { - shape.stroke(theme.accent, lineWidth: 2) - } - } else { - shape - .fill(BFColors.surface(for: colorScheme)) - .overlay { - shape.stroke(BFColors.border(for: colorScheme), lineWidth: 1) - } + ToolbarItem(placement: .confirmationAction) { + Button("Apply") { + onApply() + dismiss() } } } - .buttonStyle(.plain) - } - } - - struct EquipmentToggleRow: View { - @Environment(\.colorScheme) var colorScheme - let equipment: Equipment - let isSelected: Bool - let theme: AppTheme - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 14) { - Image(systemName: iconForEquipment) - .font(.title3) - .foregroundStyle( - isSelected ? theme.accent : BFColors.textSecondary(for: colorScheme)) - .frame(width: 32) - - Text(equipment.rawValue.capitalized) - .font(BFTypography.body) - .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - - Spacer() - - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.title2) - .foregroundStyle( - isSelected - ? theme.accent - : BFColors.textTertiary(for: colorScheme).opacity(0.5)) - } - .padding(.vertical, 10) - .padding(.horizontal, 14) - .background { - let shape = RoundedRectangle(cornerRadius: 12, style: .continuous) - shape - .fill(BFColors.surface(for: colorScheme)) - .overlay { - shape.stroke( - isSelected ? theme.accentSurface(0.5, for: colorScheme) : BFColors.border(for: colorScheme), - lineWidth: 1 - ) - } - } - } - .buttonStyle(.plain) - } - - private var iconForEquipment: String { - switch equipment { - case .barbell: return "figure.strengthtraining.traditional" - case .dumbbell: return "dumbbell.fill" - case .kettlebell: return "figure.highintensity.intervaltraining" - case .machine: return "gearshape.2.fill" - case .cable: return "cable.coaxial" - case .bodyweight: return "figure.core.training" - case .bands: return "circle.dotted" - case .other: return "ellipsis.circle" - } } } } diff --git a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Helpers.swift b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Helpers.swift index f899f15..b73996b 100644 --- a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Helpers.swift +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Helpers.swift @@ -1,7 +1,9 @@ -import BetterFit import SwiftUI +import BetterFit +import Auth + +// MARK: - Workout lifecycle notifications -// MARK: - Workout Notifications extension Notification.Name { static let workoutStarted = Notification.Name("BetterFit.workoutStarted") static let workoutCompleted = Notification.Name("BetterFit.workoutCompleted") @@ -9,967 +11,393 @@ extension Notification.Name { static let workoutResumed = Notification.Name("BetterFit.workoutResumed") } -extension WorkoutHomeView { - // MARK: - Helper Functions - - func refreshActiveWorkout() { - activeWorkoutId = bf.getActiveWorkout()?.id - } - - func startWorkoutTimerIfNeeded() { - guard hasActiveWorkout else { - stopWorkoutTimer() - return - } - - // Stop existing timer if any - workoutTimer?.invalidate() - - // Start timer that updates every 10ms for smooth animation - workoutTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { [self] _ in - self.elapsedTimeUpdateTrigger.toggle() - } - } - - func stopWorkoutTimer() { - workoutTimer?.invalidate() - workoutTimer = nil - } - - func loadGameStats() { - currentStreak = bf.socialManager.getCurrentStreak() - longestStreak = bf.socialManager.getLongestStreak() - lastWorkoutDate = bf.socialManager.getLastWorkoutDate() - - // Get username from authenticated user or fallback to profile - if let user = user { - // First try to get full_name from user metadata - if let fullName = user.userMetadata["full_name"]?.stringValue { - username = fullName - } - // Fall back to extracting from email (e.g., "johnny@example.com" -> "Johnny") - else if let email = user.email { - let name = email.components(separatedBy: "@").first ?? "User" - username = name.prefix(1).uppercased() + name.dropFirst() - } else { - username = "User" - } - } else { - username = bf.socialManager.getUserProfile().username - } - } - - func refreshVitals() { - let range = heatmapDateRange() - activityByDay = makeDailyWorkoutCounts(startDate: range.start, endDate: range.end) - } - - func heatmapDateRange() -> (start: Date, end: Date) { - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date.now) - - switch heatmapRange { - case .week: - let start = calendar.date(byAdding: .day, value: -6, to: today) ?? today - return (start, today) - case .month: - let start = calendar.date(byAdding: .month, value: -1, to: today) ?? today - return (calendar.startOfDay(for: start), today) - case .year: - let start = calendar.date(byAdding: .year, value: -1, to: today) ?? today - return (calendar.startOfDay(for: start), today) - case .custom: - let start = calendar.startOfDay(for: min(customRangeStart, customRangeEnd)) - let end = calendar.startOfDay(for: min(max(customRangeStart, customRangeEnd), today)) - return (start, end) - } - } - - func heatmapRangeLabel() -> String { - switch heatmapRange { - case .week: return "1W" - case .month: return "1M" - case .year: return "1Y" - case .custom: return "Custom" - } - } - - var suggestedWorkout: Workout { - // First check if planManager has a workout for today - if let planDay = planManager?.getTodayPlan(), !planDay.isRest, !planDay.exercises.isEmpty { - return planDay.toWorkout() - } - return bf.getRecommendedWorkout() ?? defaultWorkout - } - - var suggestedWorkouts: [Workout] { - // Get multiple workout suggestions from real data - var workouts: [Workout] = [] - - // First, use today's planned workout if available from planManager - if let planDay = planManager?.getTodayPlan(), !planDay.isRest, !planDay.exercises.isEmpty { - workouts.append(planDay.toWorkout()) - } else if let recommended = bf.getRecommendedWorkout() { - workouts.append(recommended) - } +// MARK: - Plan helpers (Workout tab) - // Add workouts from templates if available - let templates = bf.templateManager.getAllTemplates() - for template in templates where !workouts.contains(where: { $0.name == template.name }) { - workouts.append(template.createWorkout()) - if workouts.count >= 4 { break } // Limit to 4 suggested workouts - } - - // If we still have few workouts, add upcoming planned workouts from this week - if workouts.count < 4, let manager = planManager { - let weekDays = manager.getCurrentWeekDays() - for day in weekDays where !day.isRest && !day.isToday && !day.exercises.isEmpty { - let workout = day.toWorkout() - if !workouts.contains(where: { $0.name == workout.name }) { - workouts.append(workout) - if workouts.count >= 4 { break } +extension WorkoutHomeView { + func pairSuperset(currentIndex: Int, withIndex nextIndex: Int) { + guard nextIndex < exercises.count, currentIndex >= 0 else { return } + // Group both into a single workout. We use a UUID shared between the two rows. + let group = exercises[currentIndex].id + // Assign same supersetGroup via a side-channel via the session-model approach: + // in this codebase we mark via `BFSupersetStore.shared`. + BFSupersetStore.shared.link(exercises[currentIndex].id, exercises[nextIndex].id) + } + + func removePlanExercise(_ exercise: PlannedExercise) { + exercises.removeAll { $0.id == exercise.id } + persistExercises() + } + + func planInput( + label: String, + text: Binding, + focus: PlanField, + keyboard: UIKeyboardType, + onCommit: @escaping () -> Void + ) -> some View { + HStack(spacing: 4) { + TextField("—", text: text) + .keyboardType(keyboard) + .font(BFTypography.setValue) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + .multilineTextAlignment(.trailing) + .frame(minWidth: 56) + .focused($focusedPlanField, equals: focus) + .onSubmit(onCommit) + .onChange(of: focusedPlanField) { _, newValue in + // Only commit on focus-loss if the user actually typed a + // draft. The displayed value may be a model fallback, so + // checking the draft directly avoids wiping stored kg. + guard newValue != focus else { return } + if hasDraft(for: focus) { onCommit() } } - } - } - - // Fallback to default workouts if we still have none - if workouts.isEmpty { - workouts.append(defaultWorkout) - } - - return workouts - } - - var defaultWorkout: Workout { - Workout( - name: "Steady Running", - exercises: [ - WorkoutExercise( - exercise: Exercise( - name: "Running", equipmentRequired: .bodyweight, - muscleGroups: [.quads, .calves]), - sets: [ExerciseSet(reps: 1, weight: 0)] - ) - ] + Text(label) + .font(BFTypography.captionEmphasis) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: BFRadius.medium, style: .continuous) + .fill(BFColors.surfaceRaised(for: colorScheme)) ) - } - - func startWorkout() { - let workout = suggestedWorkout - planManager?.setSelectedWorkoutForToday(workout) - bf.startWorkout(workout) - activeWorkoutId = workout.id - NotificationCenter.default.post(name: .workoutStarted, object: workout.id) - } - - func selectWorkout(_ workout: Workout) { - planManager?.setSelectedWorkoutForToday(workout) - bf.startWorkout(workout) - activeWorkoutId = workout.id - NotificationCenter.default.post(name: .workoutStarted, object: workout.id) - } - - func overviewSummaryText(weeklyWorkouts: Int, recovery: Int) -> String { - if weeklyWorkouts == 0 { - return "Start with something light today — your body will thank you." - } - - if recovery < 35 { - return "You’re training consistently — consider keeping intensity moderate." - } - - if weeklyWorkouts >= 4 { - return "Strong week so far. Keep the momentum going." - } - - return "Nice progress. A quick session today keeps the streak alive." - } - - func workoutsThisWeek(date: Date) -> Int { - let calendar = Calendar.current - guard let interval = calendar.dateInterval(of: .weekOfYear, for: date) else { - return 0 - } - - let history = bf.getWorkoutHistory() - let completed = history.filter { $0.date >= interval.start && $0.date < interval.end }.count - - if let active = bf.getActiveWorkout(), - active.date >= interval.start && active.date < interval.end - { - return completed + 1 - } - - return completed - } - - func workoutRangeStats(start: Date, end: Date) -> WorkoutRangeStats { - let calendar = Calendar.current - let startDay = calendar.startOfDay(for: start) - let endDay = calendar.startOfDay(for: end) - guard startDay <= endDay else { return WorkoutRangeStats(totalWorkouts: 0, activeDays: 0) } - - let counts = makeDailyWorkoutCounts(startDate: startDay, endDate: endDay) - let total = counts.values.reduce(0, +) - let activeDays = counts.values.filter { $0 > 0 }.count - return WorkoutRangeStats(totalWorkouts: total, activeDays: activeDays) - } - - func workoutCategorySplit(start: Date, end: Date) -> WorkoutCategorySplit { - let calendar = Calendar.current - let startDay = calendar.startOfDay(for: start) - let endDay = calendar.startOfDay(for: end) - guard startDay <= endDay else { - return WorkoutCategorySplit(cardioPercent: 0, strengthPercent: 0, liftingPercent: 0) - } - - let history = bf.getWorkoutHistory().filter { $0.date >= startDay && $0.date <= endDay } - let workouts: [Workout] = { - if let active = bf.getActiveWorkout(), active.date >= startDay && active.date <= endDay - { - return history + [active] - } - return history - }() - - guard !workouts.isEmpty else { - return WorkoutCategorySplit(cardioPercent: 0, strengthPercent: 0, liftingPercent: 0) - } - - var counts: [WorkoutCategory: Int] = [.cardio: 0, .strength: 0, .lifting: 0] - for workout in workouts { - counts[workoutCategory(for: workout), default: 0] += 1 - } - - let total = max(1, workouts.count) - func pct(_ n: Int) -> Int { Int(round((Double(n) / Double(total)) * 100)) } - - let cardio = pct(counts[.cardio, default: 0]) - let strength = pct(counts[.strength, default: 0]) - let lifting = max(0, 100 - cardio - strength) - return WorkoutCategorySplit( - cardioPercent: cardio, strengthPercent: strength, liftingPercent: lifting) - } - - func workoutCategory(for workout: Workout) -> WorkoutCategory { - var score: [WorkoutCategory: Int] = [.cardio: 0, .strength: 0, .lifting: 0] - - for we in workout.exercises { - let name = we.exercise.name.lowercased() - if name.contains("run") || name.contains("treadmill") || name.contains("bike") - || name.contains("cycle") - { - score[.cardio, default: 0] += 2 - } - if name.contains("row") || name.contains("erg") || name.contains("jump") { - score[.cardio, default: 0] += 1 - } - - switch we.exercise.equipmentRequired { - case .barbell, .dumbbell, .kettlebell, .machine, .cable: - score[.lifting, default: 0] += 2 - case .bands, .bodyweight: - score[.strength, default: 0] += 2 - case .other: - break - } - } - - return score.max { $0.value < $1.value }?.key ?? .strength - } - - var streakDays: [Date] { - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date.now) - let end = calendar.startOfDay(for: min(selectedDate, today)) - let count = 21 - - return (0.. String { - if isOngoing { - let seconds = max(0, Date.now.timeIntervalSince(workout.date)) - return formatElapsed(seconds) - } - - if let duration = workout.duration, duration > 0 { - return formatElapsed(duration) + .overlay { + RoundedRectangle(cornerRadius: BFRadius.medium, style: .continuous) + .stroke( + focusedPlanField == focus ? BFColors.accent : BFColors.border(for: colorScheme), + lineWidth: focusedPlanField == focus ? 1.5 : 1 + ) } - - // Best-effort fallback: show relative time since last workout. - return workout.date.formatted(.relative(presentation: .numeric, unitsStyle: .abbreviated)) } - func recapSubtitle(for workout: Workout) -> String { - let groups = primaryMuscleGroups(for: workout) - if groups.isEmpty { - return workout.name + func hasDraft(for focus: PlanField) -> Bool { + switch focus { + case .weight(let id): return draftWeight[id] != nil + case .reps(let id): return draftReps[id] != nil } - return groups.joined(separator: ", ") } - func primaryMuscleGroups(for workout: Workout) -> [String] { - let groups = workout.exercises - .flatMap { $0.exercise.muscleGroups } - .map { $0.rawValue } - - var counts: [String: Int] = [:] - for g in groups { - counts[g, default: 0] += 1 - } - - return - counts - .sorted { $0.value > $1.value } - .map { prettifyMuscleGroup($0.key) } - .prefix(3) - .map { $0 } + func weightDraft(for exercise: PlannedExercise) -> Binding { + Binding( + get: { + if let cached = draftWeight[exercise.id] { return cached } + return exercise.weightValue > 0 ? BFFormat.trimmed(exercise.weightValue) : "" + }, + set: { draftWeight[exercise.id] = $0 } + ) } - func prettifyMuscleGroup(_ raw: String) -> String { - switch raw { - case "abs": return "Core" - case "quads": return "Quadriceps" - default: - return raw.prefix(1).uppercased() + raw.dropFirst() - } + func repsDraft(for exercise: PlannedExercise) -> Binding { + Binding( + get: { + if let cached = draftReps[exercise.id] { return cached } + return exercise.repsValue > 0 ? "\(exercise.repsValue)" : exercise.reps + }, + set: { draftReps[exercise.id] = $0 } + ) } - func recapExerciseThumb(_ exercise: Exercise) -> some View { - ZStack { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(theme.accentSurface(0.10, for: colorScheme)) - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous).stroke( - theme.cardStroke(for: colorScheme), lineWidth: 1) - } - - Image(systemName: thumbIcon(for: exercise)) - .font(.title3.weight(.semibold)) - .foregroundStyle(theme.accent) + func commitFocusedPlanField() { + switch focusedPlanField { + case .weight(let id): + commitWeight(id) + case .reps(let id): + commitReps(id) + case nil: + break } - .frame(width: 64, height: 52) - .accessibilityLabel(exercise.name) } - func recapExerciseRow(_ workoutExercise: WorkoutExercise) -> some View { - HStack(spacing: 12) { - ZStack { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(theme.accentSurface(0.12, for: colorScheme)) - .frame(width: 44, height: 44) - Image(systemName: thumbIcon(for: workoutExercise.exercise)) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(theme.accent) - } - - VStack(alignment: .leading, spacing: 2) { - Text(workoutExercise.exercise.name) - .font(.subheadline.weight(.semibold)) - - Text(recapExerciseDetail(workoutExercise)) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer(minLength: 0) - } - .padding(12) - .background { - let shape = RoundedRectangle(cornerRadius: 16, style: .continuous) - shape - .fill(.regularMaterial) - .overlay { shape.stroke(theme.cardStroke(for: colorScheme), lineWidth: 1) } - .shadow( - color: Color.black.opacity(colorScheme == .dark ? 0.22 : 0.08), - radius: colorScheme == .dark ? 14 : 10, - x: 0, - y: 6 - ) + func commitWeight(_ id: UUID) { + guard let exerciseIndex = exercises.firstIndex(where: { $0.id == id }) else { return } + let raw = draftWeight[id] ?? "" + let cleaned = raw.replacingOccurrences(of: ",", with: ".").filter { $0.isNumber || $0 == "." } + // Empty string clears the target weight (no zero placeholder). + guard !cleaned.isEmpty, cleaned != ".", let value = Double(cleaned), value > 0 else { + // Either empty or 0 — treat as "not set" and clear both draft + store. + exercises[exerciseIndex].targetWeight = nil + draftWeight[id] = "" + persistExercises() + return } + exercises[exerciseIndex].targetWeight = "\(BFFormat.trimmed(value)) kg" + draftWeight[id] = BFFormat.trimmed(value) + persistExercises() } - func recapExerciseDetail(_ workoutExercise: WorkoutExercise) -> String { - let groups = workoutExercise.exercise.muscleGroups.prefix(2).map { - prettifyMuscleGroup($0.rawValue) - } - if !workoutExercise.sets.isEmpty { - let sets = workoutExercise.sets.count - return "\(groups.joined(separator: ", ")) • \(sets) sets" + func commitReps(_ id: UUID) { + guard let exerciseIndex = exercises.firstIndex(where: { $0.id == id }) else { return } + let raw = draftReps[id] ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + // Revert to the stored value rather than guessing. + draftReps[id] = "\(exercises[exerciseIndex].repsValue)" + return } - return groups.joined(separator: ", ") + // Accept only the first whole number — drop ranges like "8-10". + let digits = trimmed.filter { $0.isNumber } + let value = Int(digits.prefix(4)) ?? exercises[exerciseIndex].repsValue + let stored = "\(max(1, value))" + exercises[exerciseIndex].reps = stored + draftReps[id] = stored + persistExercises() } - func thumbIcon(for exercise: Exercise) -> String { + /// Exercise → SF Symbol for the work-list thumbnail tile. + /// Name heuristics first (same convention as the historical `thumbIcon`), + /// falling back to the shared `ExerciseCategory.icon` mapping. + func thumbIcon(for exercise: PlannedExercise) -> String { let name = exercise.name.lowercased() if name.contains("run") || name.contains("treadmill") { return "figure.run" } if name.contains("bench") || name.contains("press") { return "dumbbell.fill" } if name.contains("row") { return "figure.strengthtraining.traditional" } if name.contains("yoga") { return "figure.yoga" } - - switch exercise.equipmentRequired { - case .barbell, .dumbbell, .kettlebell: - return "dumbbell.fill" - case .machine, .cable: - return "figure.strengthtraining.traditional" - case .bands: - return "circle.dotted" - case .bodyweight: - return "figure.core.training" - case .other: - return "bolt.fill" - } - } - - func formatElapsed(_ seconds: TimeInterval) -> String { - let total = Int(seconds) - let minutes = total / 60 - let hrs = minutes / 60 - let mins = minutes % 60 - let secs = total % 60 - let milliseconds = Int((seconds.truncatingRemainder(dividingBy: 1)) * 100) - - if hrs > 0 { - return String(format: "%d:%02d:%02d.%02d", hrs, mins, secs, milliseconds) - } - return String(format: "%02d:%02d.%02d", mins, secs, milliseconds) + return exercise.category.icon } - func makeDailyWorkoutCounts(startDate: Date, endDate: Date) -> [Date: Int] { - let calendar = Calendar.current - let start = calendar.startOfDay(for: startDate) - let end = calendar.startOfDay(for: endDate) - guard start <= end else { return [:] } - - var counts: [Date: Int] = [:] - - for workout in bf.getWorkoutHistory() { - let day = calendar.startOfDay(for: workout.date) - guard day >= start, day <= end else { continue } - counts[day, default: 0] += 1 - } - - if bf.getActiveWorkout() != nil { - // Best-effort: count today's in-progress session if it's inside the selected range. - let today = calendar.startOfDay(for: Date.now) - if today >= start, today <= end { - counts[today, default: 0] += 1 - } + func meta(for exercise: PlannedExercise, index: Int, focus: Bool) -> String { + var parts: [String] = [] + if focus { parts.append("Focus exercise") } + if let first = exercise.muscleGroups.first { + parts.append(first.capitalized) + } else { + parts.append(exercise.category.rawValue.capitalized) } - - return counts + return parts.joined(separator: " · ") } - func streakDayPill(for date: Date) -> some View { - let calendar = sundayFirstCalendar - let today = Calendar.current.startOfDay(for: Date.now) - let isToday = Calendar.current.isDate(date, inSameDayAs: today) - let isFuture = date > today - let isSelected = calendar.isDate(date, inSameDayAs: selectedDate) - let isInStreak = isDateInCurrentStreak(date) - - let selectedFill: Color = colorScheme == .dark ? .white : .black - let selectedText: Color = colorScheme == .dark ? .black : .white - - return Button { - selectedDate = date - } label: { - VStack(spacing: 6) { - Text(date.formatted(.dateTime.weekday(.abbreviated))) - .font(.caption.weight(.semibold)) - - HStack(spacing: 4) { - Text(date.formatted(.dateTime.month(.abbreviated))) - .font(.caption.weight(.semibold)) - - Text(date.formatted(.dateTime.day())) - .font(.subheadline.weight(.bold)) - .monospacedDigit() - } - } - .frame(width: 60, height: 64) - .foregroundStyle( - isSelected ? selectedText : (isFuture ? Color.secondary : Color.primary) - ) - .background { - let shape = RoundedRectangle(cornerRadius: 16, style: .continuous) - if isSelected { - shape.fill(selectedFill) - } else { - shape.fill(theme.cardBackground(for: colorScheme)) - .overlay { shape.stroke(theme.cardStroke(for: colorScheme), lineWidth: 1) } - } - } - .overlay { - if isToday, !isSelected { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(theme.accentSurface(0.45, for: colorScheme), lineWidth: 1) - } - } - .overlay(alignment: .topTrailing) { - if isInStreak { - Circle() - .fill(theme.accent) - .frame(width: 8, height: 8) - .offset(x: 4, y: -4) - } - } - .opacity(isFuture ? 0.55 : 1) + func replaceWorkoutPreview(_ preview: WorkoutPreview) { + exercises = preview.exercises + sessionName = preview.name + if let type = workoutTypeMatching(preview.name) { + workoutType = type } - .buttonStyle(.plain) - .disabled(isFuture) - } - - var displayRegions: [BodyRegion] { - BodyRegion.allCases.filter { $0 != .other } + persistExercises() } - var weekDays: [Date] { - let calendar = Calendar.current - let start = calendar.dateInterval(of: .weekOfYear, for: selectedDate)?.start ?? selectedDate - return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: start) } + func workoutTypeMatching(_ name: String) -> WorkoutType? { + let lowered = name.lowercased() + if lowered.contains("pull") { return .pull } + if lowered.contains("push") { return .push } + if lowered.contains("leg") { return .legs } + if lowered.contains("upper") { return .upper } + if lowered.contains("lower") { return .lower } + if lowered.contains("condition") || lowered.contains("cardio") { return .cardio } + if lowered.contains("full") { return .fullBody } + return nil } - func dayBackground(for date: Date) -> AnyShapeStyle { - let calendar = Calendar.current - if calendar.isDate(date, inSameDayAs: selectedDate) { - return AnyShapeStyle(theme.accentSurface(0.25, for: colorScheme)) - } - return AnyShapeStyle(theme.cardBackground(for: colorScheme)) + func showPreview(named name: String) { + guard let preview = previewForName(name) else { return } + workoutPreview = preview } - func isDateInCurrentStreak(_ date: Date) -> Bool { - guard currentStreak > 0, let lastWorkoutDate else { - return false + func previewForName(_ name: String) -> WorkoutPreview? { + if name == sessionName { + return WorkoutPreview( + name: sessionName, + duration: plannedMinutes, + exercises: exercises + ) } - - let calendar = Calendar.current - let day = calendar.startOfDay(for: date) - let lastDay = calendar.startOfDay(for: lastWorkoutDate) - let difference = calendar.dateComponents([.day], from: day, to: lastDay).day ?? Int.max - - return difference >= 0 && difference < currentStreak + return WorkoutPreviewLibrary.sampleWorkouts.first(where: { $0.name == name }) + ?? WorkoutPreview( + name: name, + duration: 45, + exercises: WorkoutHomeView.demoExercises(named: name) + ) } - func refreshStatuses() { - var next: [BodyRegion: RecoveryStatus] = [:] - for region in displayRegions { - next[region] = bf.bodyMapManager.getRecoveryStatus(for: region) - } - statuses = next - - if statuses[selectedRegion] == nil { - selectedRegion = displayRegions.first ?? .core + static func demoExercises(named name: String) -> [PlannedExercise] { + let lowered = name.lowercased() + if lowered.contains("leg") { + return WorkoutPreviewLibrary.sampleWorkouts.first(where: { $0.name == "Legs A" })?.exercises ?? [] } - } - - func statusPercent(_ status: RecoveryStatus) -> Double { - switch status { - case .recovered: return 100 - case .slightlyFatigued: return 75 - case .fatigued: return 50 - case .sore: return 25 + if lowered.contains("push") { + return WorkoutPreviewLibrary.sampleWorkouts.first(where: { $0.name == "Push A" })?.exercises ?? [] } - } - - func statusLabel(_ status: RecoveryStatus) -> String { - switch status { - case .recovered: return "Recovered" - case .slightlyFatigued: return "Slightly fatigued" - case .fatigued: return "Fatigued" - case .sore: return "Sore" + if lowered.contains("condition") { + return WorkoutPreviewLibrary.sampleWorkouts.first(where: { $0.name == "Conditioning" })?.exercises ?? [] } + return WorkoutPreviewLibrary.sampleWorkouts.first(where: { $0.name == "Pull Day" })?.exercises ?? [] } - func statusSubtitle(_ status: RecoveryStatus) -> String { - switch status { - case .recovered: return "Fresh muscle group" - case .slightlyFatigued: return "Trainable with moderation" - case .fatigued: return "Consider reduced intensity" - case .sore: return "Rest recommended" - } - } + // MARK: - Data - func regionDisplayName(_ region: BodyRegion) -> String { - switch region { - case .chest: return "Chest" - case .back: return "Back" - case .shoulders: return "Shoulders" - case .arms: return "Arms" - case .core: return "Core" - case .legs: return "Legs" - case .other: return "Other" + func loadPlan() { + if let plan = todayPlan, !plan.isRest, !plan.exercises.isEmpty { + exercises = plan.exercises + workoutType = plan.workoutType + sessionName = plan.workoutType.map { "\($0.rawValue) Day" } ?? "Today's session" + } else if exercises.isEmpty { + // Seed demo plan into the manager so Start workout picks it up. + exercises = Self.demoPullDay + workoutType = .pull + sessionName = "Pull Day" } - } - /// Remove an exercise from the active workout (or today's plan when no session is running). - func deletePlannedExercise(_ exercise: PlannedExercise) { - if var active = bf.getActiveWorkout() { - guard let index = active.exercises.firstIndex(where: { $0.exercise.name == exercise.name }) - else { return } - active.exercises.remove(at: index) - bf.updateActiveWorkout(active) - planManager?.setSelectedWorkoutForToday(active) - } else if let manager = planManager, let plan = manager.getTodayPlan() { - guard let index = plan.exercises.firstIndex(where: { - $0.id == exercise.id || $0.name == exercise.name - }) else { return } - manager.removeExercise(at: index, from: Date.now) - } - } + // Always keep planManager in sync with what Plan is showing. + persistExercises() - /// Apply edits (sets/reps/weight) to an exercise in the active workout or today's plan. - func updatePlannedExercise(_ updated: PlannedExercise) { - if var active = bf.getActiveWorkout() { - guard let index = active.exercises.firstIndex(where: { $0.exercise.name == updated.name }) - else { return } - active.exercises[index] = updated.toWorkoutExercise() - bf.updateActiveWorkout(active) - planManager?.setSelectedWorkoutForToday(active) - } else if let manager = planManager, let plan = manager.getTodayPlan() { - guard let index = plan.exercises.firstIndex(where: { - $0.id == updated.id || $0.name == updated.name - }) else { return } - manager.replaceExercise(at: index, with: updated, on: Date.now) + // Seed demo history once so Log/Targets have data + if demoModeOverride ?? demoModeEnabled { + seedLightDemoIfNeeded() } } - func addExerciseToCurrentWorkout(_ plannedExercise: PlannedExercise) { - // Convert PlannedExercise to WorkoutExercise and add to current workout - let muscleGroups: [MuscleGroup] = plannedExercise.muscleGroups.compactMap { name in - MuscleGroup.allCases.first { $0.rawValue.lowercased() == name.lowercased() } - } - - let exercise = Exercise( - name: plannedExercise.name, - equipmentRequired: inferEquipment(from: plannedExercise.name), - muscleGroups: muscleGroups.isEmpty ? [.chest] : muscleGroups + func persistExercises() { + guard let manager = planManager else { return } + let date = Calendar.current.startOfDay(for: Date()) + let existing = manager.getPlanDay(for: date) + manager.replaceDay( + WorkoutPlanDay( + id: existing?.id ?? UUID(), + date: date, + workoutType: workoutType ?? existing?.workoutType ?? .fullBody, + exercises: exercises, + isRest: exercises.isEmpty, + isCompleted: existing?.isCompleted ?? false + ) ) - - // Parse reps and weight - let repsValue = Int(plannedExercise.reps.components(separatedBy: "-").first ?? "10") ?? 10 - let weightValue = plannedExercise.targetWeight.flatMap { str -> Double? in - let numbers = str.components(separatedBy: CharacterSet.decimalDigits.inverted).joined() - return Double(numbers) - } - - // Create sets - let sets = (0.. Equipment { - let lowercased = name.lowercased() - if lowercased.contains("dumbbell") { return .dumbbell } - if lowercased.contains("barbell") || lowercased.contains("bench press") { return .barbell } - if lowercased.contains("cable") { return .cable } - if lowercased.contains("machine") - || lowercased.contains("press") - && !lowercased.contains("dumbbell") - { - return .machine - } - if lowercased.contains("pull up") || lowercased.contains("plank") - || lowercased.contains("hanging") - { - return .bodyweight - } - return .other } - func quickAddWorkout() { - // Placeholder action; we can later wire this to a workout builder. - generateWorkout() + func remove(_ exercise: PlannedExercise) { + exercises.removeAll { $0.id == exercise.id } + persistExercises() } - func startEmptyWorkout() { - // Placeholder. For now, just refresh recovery so the screen feels alive. - refreshStatuses() + func moveToTop(_ exercise: PlannedExercise) { + guard let exerciseIndex = exercises.firstIndex(where: { $0.id == exercise.id }), exerciseIndex > 0 else { return } + let item = exercises.remove(at: exerciseIndex) + exercises.insert(item, at: 0) + persistExercises() } - func generateWorkout() { - // Keep a tiny demo flow: use BetterFit's API path where possible. - if let workout = bf.getRecommendedWorkout() { - bf.startWorkout(workout) - bf.completeWorkout(workout) + func cycleSession() { + let cycle: [WorkoutType] = [.pull, .push, .legs, .upper] + let next: WorkoutType + if let current = workoutType, let idx = cycle.firstIndex(of: current) { + next = cycle[(idx + 1) % cycle.count] } else { - // Fall back to a minimal sample. - let benchPress = Exercise( - name: "Bench Press", - equipmentRequired: .barbell, - muscleGroups: [.chest, .triceps] - ) - - let workout = Workout( - name: "Quick Session", - exercises: [ - WorkoutExercise(exercise: benchPress, sets: [ExerciseSet(reps: 8, weight: 60)]) - ] + next = .pull + } + workoutType = next + sessionName = "\(next.rawValue) Day" + if let manager = planManager { + exercises = manager.getTodayPlan()?.exercises ?? Self.demoFor(next) + } else { + exercises = Self.demoFor(next) + } + if exercises.isEmpty { exercises = Self.demoFor(next) } + persistExercises() + } + + func adoptSession(named name: String) { + sessionName = name + if name.localizedCaseInsensitiveContains("leg") { + workoutType = .legs + exercises = Self.demoFor(.legs) + } else if name.localizedCaseInsensitiveContains("push") { + workoutType = .push + exercises = Self.demoFor(.push) + } else if name.localizedCaseInsensitiveContains("condition") { + workoutType = .cardio + exercises = Self.demoFor(.cardio) + } else { + workoutType = .pull + exercises = Self.demoPullDay + } + persistExercises() + } + + func repeatLast() { + let history = betterFit.getWorkoutHistory().sorted { $0.date > $1.date } + guard let last = history.first else { return } + sessionName = last.name + exercises = last.exercises.map { we in + PlannedExercise( + name: we.exercise.name, + sets: max(we.sets.count, 3), + reps: "\(we.sets.first?.reps ?? 8)", + targetWeight: we.sets.first?.weight.map { "\(Int($0)) kg" }, + muscleGroups: we.exercise.muscleGroups.map(\.rawValue) ) - - bf.startWorkout(workout) - bf.completeWorkout(workout) } - - refreshStatuses() + persistExercises() } - func ensureDemoSeededIfNeeded() { - guard isDemoMode else { return } - guard !didSeedDemoData else { return } - - // Seed into the same BetterFit instance shared with RootTabView so - // Start Workout and home preview see one consistent source of truth. - seedDemoData(into: betterFit) - didSeedDemoData = true + func trimToThirty() { + // Keep focus lifts first, cap around 5 movements + if exercises.count > 5 { + exercises = Array(exercises.prefix(5)) + persistExercises() + } } - func seedDemoData(into bf: BetterFit) { - // Profile - var profile = bf.socialManager.getUserProfile() - profile.username = "Demo" - bf.socialManager.updateUserProfile(profile) - - // Templates + plan so the built-in recommendation path returns something. - let bench = Exercise( - name: "Bench Press", - equipmentRequired: .barbell, - muscleGroups: [.chest, .triceps] - ) - let row = Exercise( - name: "Cable Row", - equipmentRequired: .cable, - muscleGroups: [.back, .lats] - ) - let squat = Exercise( - name: "Back Squat", - equipmentRequired: .barbell, - muscleGroups: [.quads, .glutes] - ) - let press = Exercise( - name: "Overhead Press", - equipmentRequired: .dumbbell, - muscleGroups: [.shoulders, .triceps] - ) - let curl = Exercise( - name: "Dumbbell Curl", - equipmentRequired: .dumbbell, - muscleGroups: [.biceps] - ) - let hinge = Exercise( - name: "Romanian Deadlift", - equipmentRequired: .barbell, - muscleGroups: [.hamstrings, .glutes] - ) - let core = Exercise( - name: "Plank", - equipmentRequired: .bodyweight, - muscleGroups: [.abs] - ) - - let pushDay = WorkoutTemplate( - name: "Push Day", - description: "Chest + shoulders + triceps", + func seedLightDemoIfNeeded() { + // Only seed if history is empty — avoid fighting real data + guard betterFit.getWorkoutHistory().isEmpty else { return } + let demo = Workout( + name: "Push A", exercises: [ - TemplateExercise( - exercise: bench, - targetSets: [ - TargetSet(reps: 8, weight: 60), - TargetSet(reps: 8, weight: 60), - TargetSet(reps: 6, weight: 65), + WorkoutExercise( + exercise: Exercise( + name: "Bench press", + equipmentRequired: .barbell, + muscleGroups: [.chest, .triceps] + ), + sets: [ + ExerciseSet(reps: 8, weight: 60, isCompleted: true), + ExerciseSet(reps: 8, weight: 60, isCompleted: true), + ExerciseSet(reps: 6, weight: 65, isCompleted: true), ] - ), - TemplateExercise( - exercise: press, - targetSets: [TargetSet(reps: 10, weight: 18), TargetSet(reps: 10, weight: 18)] - ), - ], - tags: ["strength", "upper"] - ) - - let pullDay = WorkoutTemplate( - name: "Pull Day", - description: "Back + biceps", - exercises: [ - TemplateExercise( - exercise: row, - targetSets: [TargetSet(reps: 12, weight: 45), TargetSet(reps: 10, weight: 50)] - ), - TemplateExercise( - exercise: curl, - targetSets: [TargetSet(reps: 12, weight: 12), TargetSet(reps: 10, weight: 12)] - ), - ], - tags: ["strength", "upper"] - ) - - let legsDay = WorkoutTemplate( - name: "Leg Day", - description: "Quads + hamstrings + glutes", - exercises: [ - TemplateExercise( - exercise: squat, - targetSets: [TargetSet(reps: 5, weight: 90), TargetSet(reps: 5, weight: 90)] - ), - TemplateExercise( - exercise: hinge, - targetSets: [TargetSet(reps: 8, weight: 80), TargetSet(reps: 8, weight: 80)] - ), - TemplateExercise(exercise: core, targetSets: [TargetSet(reps: 60, weight: nil)]), - ], - tags: ["strength", "lower"] - ) - - [pushDay, pullDay, legsDay].forEach { bf.templateManager.addTemplate($0) } - - let plan = TrainingPlan( - name: "Demo Plan", - description: "A simple 3-day split with realistic history.", - weeks: [ - TrainingWeek(weekNumber: 1, workouts: [pushDay.id, pullDay.id, legsDay.id]) + ) ], - goal: .hypertrophy + date: Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date(), + duration: 48 * 60, + isCompleted: true ) - bf.planManager.addPlan(plan) - bf.planManager.setActivePlan(plan.id) - - // Workout history (3x/week for ~12 weeks, with occasional doubles). - let calendar = Calendar.current - let today = calendar.startOfDay(for: Date.now) - let start = calendar.date(byAdding: .day, value: -84, to: today) ?? today - - func demoMix(_ x: UInt64) -> UInt64 { - var z = x &+ 0x9E37_79B9_7F4A_7C15 - z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 - z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB - return z ^ (z >> 31) - } - - func demoUnit(_ a: Int, _ b: Int = 0) -> Double { - let mixed = demoMix(UInt64(bitPattern: Int64(a * 1_000_003 + b * 97))) - return Double(mixed % 10_000) / 10_000.0 - } - - let run = Exercise( - name: "Easy Run", - equipmentRequired: .bodyweight, - muscleGroups: [.quads, .calves] - ) - - var workoutEvents: [Workout] = [] - for dayOffset in 0...84 { - guard let day = calendar.date(byAdding: .day, value: dayOffset, to: start) else { - continue - } - - let r = demoUnit(dayOffset) - let isStrengthDay = r < 0.42 - let isCardioDay = !isStrengthDay && r < 0.57 - guard isStrengthDay || isCardioDay else { continue } - - let hour = 6 + Int(demoUnit(dayOffset, 1) * 11) // 6...16 - let minute = Int(demoUnit(dayOffset, 2) * 60) - let dateWithTime = - calendar.date(bySettingHour: hour, minute: minute, second: 0, of: day) - ?? day - - if isCardioDay { - let minutes = 22 + Int(demoUnit(dayOffset, 3) * 35) // 22...56 - let workout = Workout( - name: demoUnit(dayOffset, 4) < 0.5 ? "Easy Run" : "Bike Ride", - exercises: [ - WorkoutExercise(exercise: run, sets: [ExerciseSet(reps: 1, weight: nil)]) - ], - date: dateWithTime, - duration: TimeInterval(minutes * 60), - isCompleted: true - ) - workoutEvents.append(workout) - } else { - let templatePick = Int(demoUnit(dayOffset, 5) * 3) % 3 - let template = [pushDay, pullDay, legsDay][templatePick] - - var workout = template.createWorkout() - workout.date = dateWithTime - workout.duration = TimeInterval((32 + Int(demoUnit(dayOffset, 6) * 28)) * 60) // 32...60m - workout.isCompleted = true - workoutEvents.append(workout) - - // Occasionally add a second shorter session on the same day. - if demoUnit(dayOffset, 7) < 0.12 { - let extraMinute = Int(demoUnit(dayOffset, 8) * 50) - let extraDate = - calendar.date(byAdding: .minute, value: 90 + extraMinute, to: dateWithTime) - ?? dateWithTime - - let bonus = Workout( - name: demoUnit(dayOffset, 9) < 0.5 ? "Core Finisher" : "Mobility", - exercises: [ - WorkoutExercise(exercise: core, sets: [ExerciseSet(reps: 60)]), - WorkoutExercise( - exercise: Exercise( - name: "Hollow Hold", - equipmentRequired: .bodyweight, - muscleGroups: [.abs] - ), - sets: [ExerciseSet(reps: 45)] - ), - ], - date: extraDate, - duration: TimeInterval(15 * 60), - isCompleted: true - ) - workoutEvents.append(bonus) - } - } + betterFit.completeWorkout(demo) + } + + // MARK: - Demo data + + static let demoPullDay: [PlannedExercise] = [ + PlannedExercise(name: "Lat pulldown", category: .pull, sets: 3, reps: "8", targetWeight: "50 kg", muscleGroups: ["Lats"]), + PlannedExercise(name: "Cable row", category: .pull, sets: 4, reps: "8", targetWeight: "64 kg", muscleGroups: ["Lats"]), + PlannedExercise(name: "Barbell curl", category: .pull, sets: 4, reps: "8", targetWeight: "30 kg", muscleGroups: ["Biceps"]), + PlannedExercise(name: "Hammer curls", category: .pull, sets: 3, reps: "12", targetWeight: "15 kg", muscleGroups: ["Biceps"]), + PlannedExercise(name: "Face pull", category: .pull, sets: 3, reps: "15", targetWeight: "18 kg", muscleGroups: ["Rear delts"]), + PlannedExercise(name: "Reverse fly", category: .pull, sets: 3, reps: "12", targetWeight: "10 kg", muscleGroups: ["Rear delts"]), + PlannedExercise(name: "Cable wood chop", category: .compound, sets: 3, reps: "15", targetWeight: "20 kg", muscleGroups: ["Core"]), + ] + + private static func demoFor(_ type: WorkoutType) -> [PlannedExercise] { + switch type { + case .push: + return [ + PlannedExercise(name: "Bench press", category: .push, sets: 4, reps: "6", targetWeight: "70 kg", muscleGroups: ["Chest"]), + PlannedExercise(name: "Overhead press", category: .push, sets: 3, reps: "8", targetWeight: "40 kg", muscleGroups: ["Shoulders"]), + PlannedExercise(name: "Incline dumbbell press", category: .push, sets: 3, reps: "10", targetWeight: "28 kg", muscleGroups: ["Chest"]), + PlannedExercise(name: "Lateral raise", category: .push, sets: 3, reps: "12", targetWeight: "12 kg", muscleGroups: ["Shoulders"]), + PlannedExercise(name: "Tricep pushdown", category: .push, sets: 3, reps: "12", targetWeight: "25 kg", muscleGroups: ["Triceps"]), + ] + case .legs: + return [ + PlannedExercise(name: "Back squat", category: .legs, sets: 4, reps: "5", targetWeight: "100 kg", muscleGroups: ["Quads"]), + PlannedExercise(name: "Romanian deadlift", category: .legs, sets: 3, reps: "8", targetWeight: "80 kg", muscleGroups: ["Hamstrings"]), + PlannedExercise(name: "Leg press", category: .legs, sets: 3, reps: "10", targetWeight: "160 kg", muscleGroups: ["Quads"]), + PlannedExercise(name: "Walking lunge", category: .legs, sets: 3, reps: "12", targetWeight: "20 kg", muscleGroups: ["Glutes"]), + PlannedExercise(name: "Calf raise", category: .legs, sets: 4, reps: "15", targetWeight: "60 kg", muscleGroups: ["Calves"]), + ] + case .cardio: + return [ + PlannedExercise(name: "Treadmill intervals", category: .cardio, sets: 8, reps: "1", targetWeight: nil, muscleGroups: ["Cardio"]), + PlannedExercise(name: "Row erg", category: .cardio, sets: 4, reps: "500m", targetWeight: nil, muscleGroups: ["Cardio"]), + PlannedExercise(name: "Core circuit", category: .compound, sets: 3, reps: "12", targetWeight: nil, muscleGroups: ["Core"]), + ] + default: + return demoPullDay } - - workoutEvents.forEach { bf.completeWorkout($0, saveToHealthKit: false) } } } diff --git a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift index aff2bc8..52f6bf7 100644 --- a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift @@ -1,1168 +1,367 @@ -import BetterFit import SwiftUI +import BetterFit +import Auth -extension WorkoutHomeView { - // MARK: - Sections - - var welcomeSection: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .top, spacing: 14) { - BetterFitIdentityBadge(size: 58) - - VStack(alignment: .leading, spacing: 4) { - Text("Hello, \(username)") - .bfHeading(theme: theme, size: 18, relativeTo: .headline) - - Text("Ready to train. Your week is on track.") - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - } - } - - // MARK: - Compact Welcome (Active Workout) - var compactWelcomeSection: some View { - HStack(spacing: 12) { - BetterFitIdentityBadge(size: 42) - - VStack(alignment: .leading, spacing: 2) { - Text("Workout in progress") - .font(.subheadline.weight(.semibold)) - Text(username) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - // Elapsed time (animated) - isolated view to prevent parent re-renders - ElapsedTimeDisplay( - betterFit: bf, - theme: theme, - elapsedTimeUpdateTrigger: $elapsedTimeUpdateTrigger, - formatElapsed: formatElapsed - ) - } - } - - // MARK: - Compact Streak (Active Workout) - var compactStreakSection: some View { - HStack(spacing: 14) { - Label { - Text("\(currentStreak) day streak") - .font(.subheadline.weight(.semibold)) - } icon: { - Image(systemName: "flame.fill") - .foregroundStyle(theme.accent) - } +// MARK: - Plan sections (Workout tab) - Spacer() +extension WorkoutHomeView { + // MARK: - Top bar (title only) - Button { - showStreakSummary = true - } label: { - Text("View Details") - .font(.caption.weight(.medium)) - .foregroundStyle(theme.accent) + var topBar: some View { + HStack(spacing: 10) { + Text("Plan") + .font(BFTypography.screenTitle) + .tracking(BFTypography.displayTracking) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Spacer(minLength: 0) + BFIconButton(systemImage: "magnifyingglass", accessibilityLabel: "Search", action: onSearch) + Button(action: onProfile) { + Text(initials) + .font(BFTypography.captionEmphasis) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + .frame(width: 40, height: 40) + .background(Circle().fill(BFColors.surfaceRaised(for: colorScheme))) + .overlay { + Circle().stroke(BFColors.border(for: colorScheme), lineWidth: 1) + } } + .buttonStyle(.plain) + .accessibilityLabel("Profile") } - .padding(.vertical, 8) + .padding(.horizontal, BFSpacing.pageHorizontal) + .padding(.bottom, 14) + .padding(.top, 8) + .background(BFColors.background(for: colorScheme)) } - // MARK: - Workout Overview Section - var workoutOverviewSection: some View { - let overallRecovery = bf.bodyMapManager.getOverallRecoveryPercentage() - let recoveryValue = Int(overallRecovery) - let weekCount = workoutsThisWeek(date: selectedDate) - let weeklyGoalTarget = 5 - let weeklyProgress = - weeklyGoalTarget > 0 - ? Double(min(weekCount, weeklyGoalTarget)) / Double(weeklyGoalTarget) - : 0 - - return VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("\(Int(weeklyProgress * 100))%") - .font(.system(size: 30, weight: .black, design: .rounded)) - .foregroundStyle(.primary) - .monospacedDigit() - - Text("weekly goal") - .font(.caption) - .foregroundStyle(.secondary) - - Spacer(minLength: 0) - - Text("\(weekCount)/\(weeklyGoalTarget) this week") - .font(.caption.weight(.semibold)) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - .monospacedDigit() - } - - Text(overviewSummaryText(weeklyWorkouts: weekCount, recovery: recoveryValue)) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - - HStack(spacing: 16) { - Label( - title: { Text("Recovery").font(.caption2).foregroundStyle(.secondary) }, - icon: { EmptyView() } - ) - .opacity(0) - - Text("\(recoveryValue)%") - .font(.subheadline.weight(.bold)) - .monospacedDigit() - .foregroundStyle(recoveryTint(recoveryValue)) + // MARK: - Header - Text("overall") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - - private func recoveryTint(_ value: Int) -> Color { - switch value { - case ..<35: return BFColors.danger - case 35..<70: return BFColors.warning - default: return BFColors.success - } + var header: some View { + BFHeaderBlock( + eyebrow: eyebrow, + title: sessionName, + specs: [ + BFSpecItem(value: "\(exercises.count)", label: "Exercises"), + BFSpecItem(value: "\(plannedMinutes)'", label: "Planned"), + BFSpecItem(value: "\(max(muscleCount, 1))", label: "Muscles"), + ], + yellow: theme.prefersYellowHeaders + ) } - var streakVitalsSection: some View { - let overallRecovery = bf.bodyMapManager.getOverallRecoveryPercentage() - let _ = "\(Int(overallRecovery))%" - let range = heatmapDateRange() - - return VStack(alignment: .leading, spacing: 12) { - // Compact streak header with expand/collapse - Button { - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { - isHeatmapExpanded.toggle() - } - } label: { - HStack(alignment: .center, spacing: 10) { - Image(systemName: "flame.fill") - .foregroundStyle(theme.accent) - - Text("\(currentStreak)") - .font(.title3.weight(.bold)) - .monospacedDigit() - - Text("day streak") - .font(.subheadline) - .foregroundStyle(.secondary) - - if longestStreak > currentStreak { - Text("• Best: \(longestStreak)") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: isHeatmapExpanded ? "chevron.up" : "chevron.down") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - .padding(.vertical, 8) - } - .buttonStyle(.plain) - - // Expanded content - if isHeatmapExpanded { - VStack(alignment: .leading, spacing: 12) { - // Day pills row - HStack(spacing: 10) { - let today = Calendar.current.startOfDay(for: Date.now) - - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(streakVisibleDays, id: \.self) { date in - streakDayPill(for: date) - .id(date) - } - } - .padding(.vertical, 2) - } - .onAppear { - guard !didAutoScrollStreakToToday else { return } - didAutoScrollStreakToToday = true - DispatchQueue.main.async { - proxy.scrollTo(today, anchor: .center) - } - } - } - .scrollClipDisabled() - .frame(maxWidth: .infinity, alignment: .leading) - - BFChromeIconButton( - systemImage: "ellipsis", - accessibilityLabel: "Streak summary", - theme: theme - ) { - showStreakSummary = true + // MARK: - Quick actions + + var quickActions: some View { + BFQuickActions(items: [ + BFQuickAction(systemImage: "arrow.left.arrow.right", label: "Swap session") { + cycleSession() + }, + BFQuickAction(systemImage: "pencil", label: "Edit") {}, + BFQuickAction(systemImage: "arrow.counterclockwise", label: "Repeat last") { + repeatLast() + }, + BFQuickAction(systemImage: "clock", label: "Trim to 30 min") { + trimToThirty() + }, + BFQuickAction(systemImage: "dumbbell.fill", label: "Gym") { + showEquipment = true + }, + ]) + } + + // MARK: - The work + + var workList: some View { + VStack(alignment: .leading, spacing: 0) { + BFSectionRule(label: "The work", trailing: "Swipe left to pair · right to replace/remove") + + ForEach(Array(exercises.enumerated()), id: \.element.id) { index, exercise in + exerciseRow(exercise, index: index) + .contextMenu { + Button { + moveToTop(exercise) + } label: { + Label("To top", systemImage: "arrow.up.to.line") } - } - - // Range selector - HStack { - Menu { - Button("1 Week") { - heatmapRange = .week - } - Button("1 Month") { - heatmapRange = .month - } - Button("1 Year") { - heatmapRange = .year - } - Divider() - Button("Custom…") { - heatmapRange = .custom - showCustomRangeSheet = true - } + Button { + selectedExercise = exercise } label: { - HStack(spacing: 6) { - Text(heatmapRangeLabel()) - .font(BFTypography.captionEmphasis) - Image(systemName: "chevron.down") - .font(.caption2.weight(.semibold)) + Label("Edit", systemImage: "pencil") + } + if BFSupersetStore.shared.isLinked(exercise.id) { + Button(role: .destructive) { + BFSupersetStore.shared.unlink(exercise.id) + } label: { + Label("Break superset", systemImage: "link.badge.plus") } - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background { - Capsule().fill(BFColors.surfaceRaised(for: colorScheme)) + } else if index + 1 < exercises.count { + Button { + BFSupersetStore.shared.link(exercise.id, exercises[index + 1].id) + } label: { + Label("Superset with next", systemImage: "link") } - .overlay { Capsule().stroke(BFColors.border(for: colorScheme), lineWidth: 1) } - } - - Spacer() - } - - // Heatmap - ContributionHeatmap( - startDate: range.start, - endDate: range.end, - valuesByDay: activityByDay, - theme: theme - ) - .frame(height: 86) - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - - Divider() - .opacity(0.6) - } - } - - var workoutRecapCard: some View { - let active = bf.getActiveWorkout() - let history = bf.getWorkoutHistory().sorted(by: { $0.date > $1.date }) - let last = history.first - - let workout = active ?? last ?? suggestedWorkout - let isOngoing = active != nil - let headline = isOngoing ? "Ongoing Workout" : (last == nil ? "Suggested" : "Last Workout") - let primaryCTA = isOngoing ? "Resume" : "Start Workout" - - return BFCard(theme: theme) { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .firstTextBaseline) { - Text(headline.uppercased()) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - - Spacer(minLength: 0) - - Button { - startWorkout() - } label: { - HStack(spacing: 6) { - Text(primaryCTA) - .font(.subheadline.weight(.semibold)) - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - } - } - .buttonStyle(.plain) - .foregroundStyle(theme.accent) - } - - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(recapTimeText(for: workout, isOngoing: isOngoing)) - .font(.system(size: 34, weight: .black, design: .rounded)) - .monospacedDigit() - - if isOngoing { - Text("elapsed") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Text(recapSubtitle(for: workout)) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(2) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(Array(workout.exercises.prefix(6)), id: \.id) { exercise in - recapExerciseThumb(exercise.exercise) - } - - Image(systemName: "chevron.right") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - } - } - - VStack(alignment: .leading, spacing: 10) { - Text("Exercises (\(workout.exercises.count))") - .font(.subheadline.weight(.semibold)) - - VStack(spacing: 10) { - ForEach(Array(workout.exercises.prefix(3)), id: \.id) { we in - recapExerciseRow(we) } - } - } - } - } - } - - // MARK: - Suggested Workouts Section - var suggestedWorkoutsSection: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Suggested Workouts") - .bfHeading(theme: theme, size: 24, relativeTo: .title2) - .foregroundStyle(.white) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 16) { - ForEach(suggestedWorkouts, id: \.name) { workout in - WorkoutSuggestionCard(workout: workout, theme: theme) { - selectWorkout(workout) + Button(role: .destructive) { + remove(exercise) + } label: { + Label("Remove", systemImage: "trash") } } - } } - } - } - - // MARK: - Swipeable Workout Card Stack - var workoutCardStack: some View { - // Isolated child view prevents cardSwipeOffset updates from re-rendering target muscles - WorkoutCardStackContainer( - suggestedWorkouts: suggestedWorkouts, - selectedWorkoutIndex: $selectedWorkoutIndex, - showEquipmentSwapSheet: $showEquipmentSwapSheet, - theme: theme, - planManager: planManager, - generateWorkout: generateWorkout - ) - } - - func swapToNextWorkout() { - let workouts = suggestedWorkouts - withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) { - selectedWorkoutIndex = (selectedWorkoutIndex + 1) % workouts.count - } - } - - func applyEquipmentSwaps() { - // Update the equipment swap manager with available equipment - bf.equipmentSwapManager.setAvailableEquipment(availableEquipment) - } - // MARK: - Workout Preview Section - var workoutPreviewSection: some View { - // Active workout is the source of truth during a session; otherwise the - // currently-displayed suggested workout keeps the preview in sync with Up Next. - let plannedExercises: [PlannedExercise] - if let active = bf.getActiveWorkout() { - plannedExercises = active.toPlannedExercises() - } else if !suggestedWorkouts.isEmpty { - let safeIndex = min(selectedWorkoutIndex, max(0, suggestedWorkouts.count - 1)) - plannedExercises = suggestedWorkouts[safeIndex].toPlannedExercises() - } else { - plannedExercises = planManager?.getTodayPlan()?.exercises ?? [] + BFAddRow { showAddExercise = true } } - - return AnyView( - VStack(alignment: .leading, spacing: 24) { - compactTargetMusclesSection(for: plannedExercises) - staticExercisesTimeline(for: plannedExercises) - } - .padding(.top, 8) - ) } - func compactTargetMusclesSection(for exercises: [PlannedExercise]) -> some View { - // Aggregate muscle groups from planned exercises - let muscleGroupCounts = - exercises - .flatMap { $0.muscleGroups } - .reduce(into: [String: Int]()) { counts, group in - counts[group, default: 0] += 1 + func exerciseRow(_ exercise: PlannedExercise, index: Int) -> some View { + let focus = index == 0 + return planRowContent(exercise, index: index, focus: focus) + .padding(.vertical, 12) + .overlay(alignment: .top) { + Rectangle() + .fill(BFColors.separator(for: colorScheme)) + .frame(height: 1) } - .sorted { $0.value > $1.value } - .prefix(4) - - let totalCount = max(1, muscleGroupCounts.reduce(0) { $0 + $1.value }) - - return VStack(alignment: .leading, spacing: 12) { - BFSectionHeader(title: "Target Muscles") - - if muscleGroupCounts.isEmpty { - Text("No exercises planned") - .font(.caption) - .foregroundStyle(.secondary) - } else { - HStack(spacing: 12) { - ForEach(Array(muscleGroupCounts), id: \.key) { group, count in - let percent = Int((Double(count) / Double(totalCount)) * 100) - CompactMuscleChip( - muscle: prettifyMuscleGroup(group), - percent: percent, - theme: theme - ) - } + .overlay(alignment: .leading) { + if focus { + Rectangle() + .fill(BFColors.accent) + .frame(width: 3) + .offset(x: -BFSpacing.pageHorizontal) } } - } - } - - /// Static (non-scrollable) exercise timeline using PlannedExercise from planManager - func staticExercisesTimeline(for exercises: [PlannedExercise]) -> some View { - VStack(alignment: .leading, spacing: 12) { - // Header - BFSectionHeader(title: "Exercises") { - HStack(spacing: 10) { - Text("\(exercises.count)") - .font(BFTypography.captionEmphasis) - .foregroundStyle(BFColors.textTertiary(for: colorScheme)) - .monospacedDigit() - - Button { - showAddExerciseSheet = true - } label: { - Image(systemName: "plus.circle.fill") - .font(.title3) - .foregroundStyle(theme.accent) + .swipeActions(edge: .leading) { + Button { + if index + 1 < exercises.count { + pairSuperset(currentIndex: index, withIndex: index + 1) } - .accessibilityLabel("Add exercise") + } label: { + Label("Superset", systemImage: "link") } + .tint(BFColors.accent) } - - // Static timeline rows (non-scrollable) - if exercises.isEmpty { - emptyExercisesState - } else { - VStack(spacing: 0) { - ForEach(Array(exercises.prefix(5).enumerated()), id: \.element.id) { - index, exercise in - StaticTimelineRow( - exercise: exercise, - index: index, - isFirst: index == 0, - isLast: index == min(exercises.count, 5) - 1, - theme: theme, - onTap: { tappedExercise in - selectedExerciseForDetail = tappedExercise - }, - onDelete: { _ in - // Handle delete - }, - onReplace: { _ in - // Handle replace - }, - onSuperset: { _ in - // Handle superset - }, - onHistory: { _ in - // Handle history - } - ) - } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + removePlanExercise(exercise) + } label: { + Label("Remove", systemImage: "trash") } - - if exercises.count > 5 { - Button { - // Could expand to show all exercises - } label: { - HStack(spacing: 4) { - Text("+\(exercises.count - 5) more") - Image(systemName: "chevron.down") - } - .font(.caption.weight(.medium)) - .foregroundStyle(theme.accent) - } - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 8) + Button { + selectedExercise = exercise + } label: { + Label("Replace", systemImage: "arrow.triangle.2.circlepath") } + .tint(BFColors.accent) } - } - } - - private var emptyExercisesState: some View { - VStack(spacing: 12) { - Image(systemName: "list.bullet.clipboard") - .font(.system(size: 32, weight: .semibold)) - .foregroundStyle(theme.accentSurface(0.5, for: colorScheme)) - - Text("No exercises planned") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) - - Button { - showAddExerciseSheet = true - } label: { - Text("Add Exercise") - .font(.caption.weight(.semibold)) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Capsule().fill(theme.accent)) - .foregroundStyle(.white) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 24) } - func targetMusclesSection(for workout: Workout) -> some View { - let muscleGroups = workout.exercises - .flatMap { $0.exercise.muscleGroups } - .reduce(into: [MuscleGroup: Int]()) { counts, group in - counts[group, default: 0] += 1 - } - .sorted { $0.value > $1.value } - .prefix(3) - - let totalCount = max(1, muscleGroups.reduce(0) { $0 + $1.value }) - - return VStack(alignment: .leading, spacing: 12) { - BFSectionHeader(title: "Target Muscles") - - HStack(spacing: 20) { - ForEach(Array(muscleGroups), id: \.key) { group, count in - let percent = Int((Double(count) / Double(totalCount)) * 100) - TargetMuscleView( - muscle: prettifyMuscleGroup(group.rawValue), - percent: percent, - theme: theme + private func planRowContent(_ exercise: PlannedExercise, index: Int, focus: Bool) -> some View { + let superset = BFSupersetStore.shared.partner(of: exercise.id) + return VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 12) { + Text(String(format: "%02d", index + 1)) + .font(BFTypography.gutter) + .foregroundStyle( + focus + ? BFColors.accentText(for: colorScheme) + : BFColors.textTertiary(for: colorScheme) ) - } - } - } - } - - func exercisesPreviewSection(for workout: Workout) -> some View { - let displayCount = min(workout.exercises.count, 5) - let rowHeight: CGFloat = 88 - let headerHeight: CGFloat = 32 - let emptyStateHeight: CGFloat = 120 // Height for empty state (icon + text + button) - let listHeight: CGFloat = - displayCount > 0 - ? CGFloat(displayCount) * rowHeight + headerHeight - : headerHeight + emptyStateHeight - - return VStack(alignment: .leading, spacing: 0) { - UnifiedExerciseTimeline( - exercises: workout.exercises, - selectedIndex: nil, - theme: theme, - showHeader: true, - headerTitle: "Exercises", - onSelect: { index in - guard workout.exercises.indices.contains(index) else { - return - } + .frame(width: 28, alignment: .leading) - let we = workout.exercises[index] - let name = we.exercise.name.lowercased() - let category: ExerciseCategory - if name.contains("run") || name.contains("cardio") { - category = .cardio - } else if name.contains("press") || name.contains("push") { - category = .push - } else if name.contains("row") || name.contains("pull") || name.contains("curl") - { - category = .pull - } else if name.contains("squat") || name.contains("leg") - || name.contains("lunge") - { - category = .legs - } else if name.contains("plank") || name.contains("crunch") - || name.contains("ab") - { - category = .core - } else { - category = .compound - } - - selectedExerciseForDetail = PlannedExercise( - name: we.exercise.name, - category: category, - sets: max(1, we.sets.count), - reps: we.sets.first.map { "\($0.reps)" } ?? "N/A", - targetWeight: we.sets.first.map { "\($0.weight) lbs" } ?? "N/A", - muscleGroups: we.exercise.muscleGroups.map { - prettifyMuscleGroup($0.rawValue) - } - ) - }, - onDelete: { _ in }, - onReplace: nil, - onSuperset: nil, - onComplete: nil, - onMove: nil, - onAdd: { showAddExerciseSheet = true } - ) - .frame(height: listHeight) + exerciseThumbTile(exercise) - if workout.exercises.count > 5 { Button { - // Could expand to show all exercises + selectedExercise = exercise } label: { - HStack(spacing: 4) { - Text("+\(workout.exercises.count - 5) more") - Image(systemName: "chevron.down") + VStack(alignment: .leading, spacing: 3) { + Text(exercise.name) + .font(BFTypography.bodyEmphasis) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + .multilineTextAlignment(.leading) + Text(meta(for: exercise, index: index, focus: focus)) + .font(BFTypography.footnote) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) } - .font(.caption.weight(.medium)) - .foregroundStyle(theme.accent) + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 8) - } - } - } - - func shouldShowSupersetDivider(at index: Int, in workout: Workout) -> Bool { - // Show divider every 2 exercises to simulate superset grouping - return index % 2 == 0 - } - - func supersetDivider(at index: Int, in workout: Workout) -> some View { - let roundCount = (workout.exercises.count - index + 1) / 2 - return HStack { - Text("Superset • \(max(1, roundCount)) Rounds") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - Spacer() - Image(systemName: "ellipsis") - .foregroundStyle(.secondary) - } - .padding(.top, 8) - } - - func startWorkoutFromSelection() { - let workouts = suggestedWorkouts - guard !workouts.isEmpty else { return } - let safeIndex = min(selectedWorkoutIndex, workouts.count - 1) - selectWorkout(workouts[safeIndex]) - } - - var titleRow: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Workout") - .bfHeading(theme: theme, size: 36, relativeTo: .largeTitle) - - Text(selectedDate.formatted(date: .complete, time: .omitted)) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .padding(.top, 8) - } + .buttonStyle(.plain) - func recoveryStatusCard(for region: BodyRegion) -> some View { - let status = statuses[region] ?? .recovered - let percent = Int(statusPercent(status)) - - return VStack(spacing: 12) { - ZStack { - ProgressRing(progress: statusPercent(status) / 100.0, lineWidth: 18, theme: theme) - .frame(width: 280, height: 280) - - VStack(spacing: 6) { - Text(regionDisplayName(region)) - .bfHeading(theme: theme, size: 24, relativeTo: .title2) - - Text("\(percent)%") - .font(.system(size: 60, weight: .bold, design: .rounded)) - .monospacedDigit() + Spacer(minLength: 0) - Text(statusLabel(status)) - .bfHeading(theme: theme, size: 18, relativeTo: .headline) - .foregroundStyle(theme.accent) + Text("\(exercise.sets) sets") + .font(BFTypography.caption) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) - Text(statusSubtitle(status)) - .font(.subheadline) - .foregroundStyle(.secondary) + if superset != nil { + // Neutral chip — yellow stays reserved for the single + // primary action/hero (design.md "Yellow versus amber"). + Text("SS") + .font(BFTypography.captionEmphasis) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(BFColors.surfaceRaised(for: colorScheme))) + .overlay { + Capsule().stroke(BFColors.border(for: colorScheme), lineWidth: 1) + } } } + + planEditFields(for: exercise) } - .frame(maxWidth: .infinity) } - var actionButtons: some View { - HStack(spacing: 12) { - Button { - startEmptyWorkout() - } label: { - Text("Empty Workout") - .bfHeading(theme: theme, size: 17, relativeTo: .headline) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - } - .buttonStyle(.bordered) - - Button { - generateWorkout() - } label: { - Label("Generate", systemImage: "sparkles") - .bfHeading(theme: theme, size: 17, relativeTo: .headline) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - } - .buttonStyle(.borderedProminent) + /// Small leading thumbnail tile — an SF Symbol glyph in a rounded tinted tile + /// (no photo assets exist; reuses the `thumbIcon` mapping convention). + private func exerciseThumbTile(_ exercise: PlannedExercise) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(theme.accentSurface(0.12, for: colorScheme)) + Image(systemName: thumbIcon(for: exercise)) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(BFColors.accentText(for: colorScheme)) + } + .frame(width: 40, height: 40) + .accessibilityHidden(true) + } + + private func planEditFields(for exercise: PlannedExercise) -> some View { + HStack(spacing: 10) { + planInput( + label: "kg", + text: weightDraft(for: exercise), + focus: .weight(exercise.id), + keyboard: .decimalPad, + onCommit: { commitWeight(exercise.id) } + ) + Text("\u{00D7}") + .font(BFTypography.subheadlineEmphasis) + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + planInput( + label: "reps", + text: repsDraft(for: exercise), + focus: .reps(exercise.id), + keyboard: .numberPad, + onCommit: { commitReps(exercise.id) } + ) + Spacer(minLength: 0) } + // Align under the text column: 28pt gutter + 40pt thumbnail + 2×12pt gaps. + .padding(.leading, 92) } - var myWeekCard: some View { - BFCard(theme: theme) { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("My Week") - .bfHeading(theme: theme, size: 18, relativeTo: .headline) - - Spacer(minLength: 0) + // MARK: - Start something else - Image(systemName: "chevron.right") - .foregroundStyle(.secondary) - } - - HStack(spacing: 10) { - ForEach(weekDays, id: \.self) { date in - VStack(spacing: 6) { - Text(date.formatted(.dateTime.weekday(.narrow))) - .font(.caption) - .foregroundStyle(.secondary) + var otherSessions: some View { + let list = source == "suggested" ? suggested : frequent + return VStack(alignment: .leading, spacing: 0) { + BFSectionRule( + label: "Start something else", + trailing: source == "suggested" ? "Based on recovery" : "Most used" + ) + BFSourceSwitch( + selection: $source, + options: [("suggested", "Suggested"), ("frequent", "Frequent")] + ) - Text(date.formatted(.dateTime.day())) - .font(.subheadline.weight(.semibold)) - .frame(width: 34, height: 34) - .background(dayBackground(for: date)) - .clipShape(Circle()) - .overlay { Circle().stroke(theme.cardStroke(for: colorScheme), lineWidth: 1) } + VStack(spacing: 0) { + ForEach(Array(list.enumerated()), id: \.element.name) { _, item in + let preview = previewForName(item.name) + BFLedgerRow( + systemImage: "dumbbell.fill", + title: item.name, + meta: item.meta, + dim: item.dim + ) { + Button { + adoptSession(named: item.name) + } label: { + Image(systemName: "play.fill") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(BFColors.accentText(for: colorScheme)) + .frame(width: 44, height: 44) + .background(Circle().fill(BFColors.surfaceRaised(for: colorScheme))) + .overlay { + Circle().stroke(BFColors.border(for: colorScheme), lineWidth: 1) + } } + .buttonStyle(.plain) + .accessibilityLabel("Start \(item.name)") } - } - } - } - } -} -// MARK: - Isolated Elapsed Time Display -/// Separate view component to prevent parent hierarchy re-renders on timer updates -private struct ElapsedTimeDisplay: View { - let betterFit: BetterFit - let theme: AppTheme - @Binding var elapsedTimeUpdateTrigger: Bool - let formatElapsed: (TimeInterval) -> String - - var body: some View { - if let active = betterFit.getActiveWorkout() { - let _ = elapsedTimeUpdateTrigger // Force update on timer tick - let elapsed = Date.now.timeIntervalSince(active.date) - Text(formatElapsed(elapsed)) - .font(.subheadline.weight(.bold).monospacedDigit()) - .foregroundStyle(theme.accent) - .animation(.none, value: elapsed) - } - } -} - -// MARK: - Isolated Workout Card Stack Container -/// Separate view component to prevent cardSwipeOffset from triggering re-renders of target muscles -private struct WorkoutCardStackContainer: View { - @Environment(\.colorScheme) var colorScheme - let suggestedWorkouts: [Workout] - @Binding var selectedWorkoutIndex: Int - @Binding var showEquipmentSwapSheet: Bool - let theme: AppTheme - let planManager: WorkoutPlanManager? - let generateWorkout: () -> Void - - @State private var cardSwipeOffset: CGFloat = 0 - - var body: some View { - let workouts = suggestedWorkouts - let safeIndex = min(selectedWorkoutIndex, max(0, workouts.count - 1)) - - VStack(alignment: .leading, spacing: 16) { - BFSectionHeader(title: "Up Next") { - HStack(spacing: 10) { - if !workouts.isEmpty { - Text("\(workouts[safeIndex].exercises.count) exercises") - .font(BFTypography.captionEmphasis) - .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + .contentShape(Rectangle()) + .onTapGesture { + if let preview { workoutPreview = preview } } - - Button { - showEquipmentSwapSheet = true - } label: { - HStack(spacing: 4) { - Image(systemName: "arrow.left.arrow.right") - Text("Swap") + .swipeActions(edge: .leading) { + Button { + // Superset this one with the next in the active plan + guard let preview else { return } + if let exerciseIndex = exercises.firstIndex(where: { $0.name == preview.name }), + exerciseIndex + 1 < exercises.count + { + BFSupersetStore.shared.link(exercises[exerciseIndex].id, exercises[exerciseIndex + 1].id) + } + } label: { + Label("Superset next", systemImage: "link") } - .font(BFTypography.captionEmphasis) - .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Capsule().fill(BFColors.surfaceRaised(for: colorScheme))) - .overlay { Capsule().stroke(BFColors.border(for: colorScheme), lineWidth: 1) } - } - .buttonStyle(.plain) - - Menu { - Button("Customize") {} - Button("Generate New") { generateWorkout() } - } label: { - Image(systemName: "ellipsis") - .font(.caption.weight(.semibold)) - .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - .frame(width: 28, height: 28) - .background(Circle().fill(BFColors.surfaceRaised(for: colorScheme))) - .overlay { Circle().stroke(BFColors.border(for: colorScheme), lineWidth: 1) } + .tint(BFColors.accent) } - } - } - - // Playing card stack with offset and rotation - ZStack { - // Show up to 3 cards behind the current one - ForEach(Array(workouts.enumerated().reversed()), id: \.offset) { index, workout in - let relativeIndex = index - safeIndex - let isTop = index == safeIndex - let isVisible = relativeIndex >= 0 && relativeIndex <= 2 - - if isVisible { - PlayingCardWorkoutCard( - workout: workout, - theme: theme, - isTopCard: isTop - ) - .offset(x: isTop ? cardSwipeOffset : CGFloat(relativeIndex) * 4) - .offset(y: CGFloat(relativeIndex) * -8) - .rotationEffect(.degrees(Double(relativeIndex) * 1), anchor: .bottom) - .scaleEffect(1 - Double(relativeIndex) * 0.05) - .opacity(1 - Double(relativeIndex) * 0.25) - .zIndex(Double(workouts.count - index)) - .gesture(isTop ? swipeGesture : nil) + .swipeActions(edge: .trailing) { + Button { + // Adopt this preview as the current plan + if let preview { replaceWorkoutPreview(preview) } + } label: { + Label("Replace", systemImage: "arrow.triangle.2.circlepath") + } + .tint(BFColors.accent) + Button(role: .destructive) { + // Drop the suggested workout and fall back to the default demo. + exercises = Self.demoPullDay + sessionName = "Pull Day" + workoutType = .pull + persistExercises() + } label: { + Label("Remove", systemImage: "trash") + } } } } - .frame(height: 172) .padding(.top, 8) - - // Page indicator - only show if more than 1 workout - if workouts.count > 1 { - HStack(spacing: 6) { - ForEach(0.. 5 { - Text("+\(workouts.count - 5)") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity) - } } - .padding(.bottom, 8) } - private var swipeGesture: some Gesture { - DragGesture() - .onChanged { value in - cardSwipeOffset = value.translation.width - } - .onEnded { value in - let threshold: CGFloat = 100 - let oldIndex = selectedWorkoutIndex + // MARK: - Equipment sheet - withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) { - if value.translation.width < -threshold { - // Swipe left - next workout - if selectedWorkoutIndex < suggestedWorkouts.count - 1 { - selectedWorkoutIndex += 1 + var equipmentSheet: some View { + NavigationStack { + List { + ForEach(Equipment.allCases, id: \.self) { eq in + Button { + if availableEquipment.contains(eq) { + availableEquipment.remove(eq) + } else { + availableEquipment.insert(eq) } - } else if value.translation.width > threshold { - // Swipe right - previous workout - if selectedWorkoutIndex > 0 { - selectedWorkoutIndex -= 1 + } label: { + HStack { + Text(eq.rawValue.capitalized) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Spacer() + if availableEquipment.contains(eq) { + Image(systemName: "checkmark") + .foregroundStyle(BFColors.accentText(for: colorScheme)) + } } } - cardSwipeOffset = 0 - } - - // Update plan when workout selection changes - if oldIndex != selectedWorkoutIndex, let manager = planManager { - let newIndex = selectedWorkoutIndex - if newIndex < suggestedWorkouts.count { - let selectedWorkout = suggestedWorkouts[newIndex] - manager.setSelectedWorkoutForToday(selectedWorkout) - } } } - } -} - -// MARK: - Playing Card Style Workout Card -private struct PlayingCardWorkoutCard: View { - @Environment(\.colorScheme) var colorScheme - let workout: Workout - let theme: AppTheme - let isTopCard: Bool - - var body: some View { - HStack(spacing: 16) { - ZStack(alignment: .bottomLeading) { - WorkoutMovementIllustration( - pose: WorkoutMovementPose.from(workoutName: workout.name), - accent: theme.accent - ) - - Text(workoutType) - .font(BFTypography.captionEmphasis) - .foregroundStyle(.white) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.black.opacity(0.26), in: Capsule()) - .padding(8) - } - .frame(width: 140, height: 140) - - // Workout details - VStack(alignment: .leading, spacing: 8) { - Text(workout.name) - .bfHeading(theme: theme, size: 18, relativeTo: .headline) - .lineLimit(2) - - // Quick stats row - HStack(spacing: 16) { - Label("1h", systemImage: "clock") - Label("Equipment", systemImage: "dumbbell") + .navigationTitle("Equipment") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showEquipment = false } } - .font(.caption) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - workoutPill(workoutType) - workoutPill("Intermediate") - } - - Spacer(minLength: 0) } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 172) - .background { - let shape = RoundedRectangle(cornerRadius: BFRadius.card, style: .continuous) - shape - .fill( - isTopCard - ? BFColors.surface(for: colorScheme) - : BFColors.surfaceRaised(for: colorScheme) - ) - .overlay { shape.stroke(BFColors.border(for: colorScheme), lineWidth: 1) } - } - } - - private var workoutType: String { - let name = workout.name.lowercased() - if name.contains("cardio") || name.contains("run") { return "Cardio" } - if name.contains("yoga") { return "Flexibility" } - if name.contains("hiit") { return "HIIT" } - return "Circuit Training" - } - - func workoutPill(_ text: String) -> some View { - Text(text) - .font(BFTypography.captionEmphasis) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Capsule().fill(BFColors.surfaceRaised(for: colorScheme))) - .overlay { Capsule().stroke(BFColors.border(for: colorScheme), lineWidth: 1) } - } -} - -// MARK: - Static Timeline Row - -/// A static (non-scrollable) timeline row for PlannedExercise -private struct StaticTimelineRow: View { - @Environment(\.colorScheme) var colorScheme - let exercise: PlannedExercise - let index: Int - let isFirst: Bool - let isLast: Bool - let theme: AppTheme - var onTap: ((PlannedExercise) -> Void)? - var onDelete: ((PlannedExercise) -> Void)? - var onReplace: ((PlannedExercise) -> Void)? - var onSuperset: ((PlannedExercise) -> Void)? - var onHistory: ((PlannedExercise) -> Void)? - - var body: some View { - HStack(alignment: .center, spacing: 0) { - // Timeline indicator (left side) - timelineIndicator - .frame(width: 48) - - // Exercise card - exerciseCard - } - .contentShape(Rectangle()) - .onTapGesture { - onTap?(exercise) - } - .contextMenu { - Button { - onHistory?(exercise) - } label: { - Label("History", systemImage: "clock.arrow.circlepath") - } - - Button { - onReplace?(exercise) - } label: { - Label("Replace", systemImage: "arrow.triangle.2.circlepath") - } - - Button { - onSuperset?(exercise) - } label: { - Label("Superset", systemImage: "link") - } - - Divider() - - Button(role: .destructive) { - onDelete?(exercise) - } label: { - Label("Delete", systemImage: "trash") - } - } - } - - // MARK: - Timeline Indicator - - private var timelineIndicator: some View { - VStack(spacing: 0) { - // Top connecting line — stretches to fill available height - Rectangle() - .fill(isFirst ? Color.clear : theme.accentSurface(0.3, for: colorScheme)) - .frame(width: 2) - .frame(maxHeight: .infinity) - - // Circle with number - ZStack { - Circle() - .fill(theme.accentSurface(0.15, for: colorScheme)) - .frame(width: 28, height: 28) - - Text("\(index + 1)") - .font(.caption.weight(.bold)) - .foregroundStyle(theme.accent) - } - .frame(width: 32, height: 32) - - // Bottom connecting line - Rectangle() - .fill(isLast ? Color.clear : theme.accentSurface(0.3, for: colorScheme)) - .frame(width: 2) - .frame(maxHeight: .infinity) - } - .frame(width: 48) - } - - // MARK: - Exercise Card - - private var exerciseCard: some View { - HStack(spacing: 12) { - // Gradient preview thumbnail - exercisePreviewImage - .frame(width: 52, height: 52) - - // Exercise info - VStack(alignment: .leading, spacing: 4) { - Text(exercise.displayName) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) - - Text(exercise.displayCategory.rawValue) - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - - // Sets info — single line keeps the row compact and gives the name room to breathe. - // Weight lives in the detail sheet, not the row (Fitbod convention). - Text(exercise.displaySetsInfo) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - .fixedSize() - } - .padding(.horizontal, 10) - .padding(.vertical, 10) - .background { - RoundedRectangle(cornerRadius: BFRadius.medium, style: .continuous) - .fill(BFColors.surface(for: colorScheme)) - .overlay { - RoundedRectangle(cornerRadius: BFRadius.medium, style: .continuous) - .stroke(BFColors.border(for: colorScheme), lineWidth: 1) - } - } - } - - // MARK: - Exercise Preview Image - - private var exercisePreviewImage: some View { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(theme.accentSurface(0.16, for: colorScheme)) - - Image(systemName: categorySystemImage) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - } - } - - private var categorySystemImage: String { - switch exercise.displayCategory { - case .push: return "arrow.up" - case .pull: return "arrow.down" - case .legs: return "figure.walk" - case .core: return "circle.circle" - case .cardio: return "heart" - case .compound: return "dumbbell" - case .all: return "figure.mixed.cardio" } } } diff --git a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift index 4b4f50e..78a9f27 100644 --- a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift @@ -1,70 +1,64 @@ import Auth import BetterFit import SwiftUI +#if canImport(UIKit) + import UIKit +#endif -// swiftlint:disable file_length type_body_length identifier_name +// MARK: - Plan (Workout tab) — Ledger layout struct WorkoutHomeView: View { @Environment(\.colorScheme) var colorScheme + let betterFit: BetterFit let theme: AppTheme let healthKitManager: HealthKitManager? let planManager: WorkoutPlanManager? let isGuest: Bool let user: Auth.User? - let demoModeOverride: Bool? + var onSearch: () -> Void = {} + var onProfile: () -> Void = {} + + @State var exercises: [PlannedExercise] = [] + @State var sessionName = "Pull Day" + @State var workoutType: WorkoutType? = .pull + @State var source = "suggested" + @State var showAddExercise = false + @State var showEquipment = false + @State var availableEquipment: Set = Set(Equipment.allCases) + @State var selectedExercise: PlannedExercise? + @State var workoutPreview: WorkoutPreview? + /// When set, the add-exercise sheet swaps this exercise in place (Replace flow). + @State var replaceTargetId: UUID? + /// Collapses the large title into a compact search nav when the user scrolls. + @State var isScrolled = false + /// Draft text for plan-mode weight/reps editing. + @State var draftWeight: [UUID: String] = [:] + @State var draftReps: [UUID: String] = [:] + @FocusState var focusedPlanField: PlanField? + + enum PlanField: Hashable { + case weight(UUID) + case reps(UUID) + } #if DEBUG - @AppStorage("betterfit.workoutHome.demoMode") var demoModeEnabled = false + @AppStorage("betterfit.workoutHome.demoMode") var demoModeEnabled = true #else var demoModeEnabled: Bool { false } #endif - @State var didSeedDemoData = false - - @State var selectedRegion: BodyRegion = .core - @State var statuses: [BodyRegion: RecoveryStatus] = [:] - - @State var showCalendar = false - @State var selectedDate = Date.now - - @State var showStreakSummary = false - - @State var didAutoScrollStreakToToday = false - - // Workout card selection - @State var selectedWorkoutIndex: Int = 0 - @State var showEquipmentSwapSheet = false - @State var availableEquipment: Set = Set(Equipment.allCases) - @State var activeWorkoutId: UUID? // Track active workout for view updates - - // Gamification - @State var currentStreak = 0 - @State var longestStreak = 0 - @State var lastWorkoutDate: Date? - @State var username: String = "Guest" - - // Activity heatmap (GitHub-style) - @State var activityByDay: [Date: Int] = [:] - - @State var heatmapRange: HeatmapRange = .year - @State var isHeatmapExpanded: Bool = false - - // Timer for animating elapsed time - @State var elapsedTimeUpdateTrigger = false - @State var workoutTimer: Timer? - @State var showCustomRangeSheet = false - @State var customRangeStart: Date = - Calendar.current.date(byAdding: .year, value: -3, to: Date.now) ?? Date.now - @State var customRangeEnd: Date = Date.now - @State var showAddExerciseSheet = false - @State var selectedExerciseForDetail: PlannedExercise? - init( - betterFit: BetterFit, theme: AppTheme, healthKitManager: HealthKitManager? = nil, - planManager: WorkoutPlanManager? = nil, isGuest: Bool = false, - user: Auth.User? = nil, demoMode: Bool? = nil + betterFit: BetterFit, + theme: AppTheme, + healthKitManager: HealthKitManager? = nil, + planManager: WorkoutPlanManager? = nil, + isGuest: Bool = false, + user: Auth.User? = nil, + demoMode: Bool? = nil, + onSearch: @escaping () -> Void = {}, + onProfile: @escaping () -> Void = {} ) { self.betterFit = betterFit self.theme = theme @@ -73,196 +67,223 @@ struct WorkoutHomeView: View { self.isGuest = isGuest self.user = user self.demoModeOverride = demoMode + self.onSearch = onSearch + self.onProfile = onProfile } - var isDemoMode: Bool { - demoModeOverride ?? demoModeEnabled + // MARK: - Derived + + var todayPlan: WorkoutPlanDay? { + planManager?.getTodayPlan() } - var bf: BetterFit { - betterFit + var plannedMinutes: Int { + max(20, exercises.count * 6) } - var hasActiveWorkout: Bool { - bf.getActiveWorkout() != nil + var muscleCount: Int { + Set(exercises.flatMap(\.muscleGroups)).count } - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: hasActiveWorkout ? 12 : 24) { - // Welcome Section (compact when active workout) - if hasActiveWorkout { - compactWelcomeSection - } else { - welcomeSection - } + var dateLabel: String { + Date.now.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated)) + } - // Apple Health Connection Reminder - if let hkManager = healthKitManager, hkManager.shouldShowConnectionPrompt { - AppleHealthReminderBanner(theme: theme, healthKitManager: hkManager) - } + var initials: String { + let name = user?.email?.prefix(2) ?? (isGuest ? "GU" : "BF") + return String(name).uppercased() + } - // Overview (summary + gauge) - hide when active workout - if !hasActiveWorkout { - workoutOverviewSection - } + var eyebrow: String { + let type = workoutType?.rawValue ?? "Train" + return "Today · \(type)" + } - // Streak + Vitals (compact when active workout) - if hasActiveWorkout { - compactStreakSection - } else { - streakVitalsSection - // Swipeable Workout Cards - workoutCardStack - } + var suggested: [(name: String, meta: String, dim: Bool)] { + [ + (sessionName, "Recommended · \(plannedMinutes) min · based on recovery", false), + ("Legs A", "52 min · quads and glutes", false), + ("Push B", "44 min · chest and shoulders", true), + ] + } - // Workout Preview for selected card - workoutPreviewSection + var frequent: [(name: String, meta: String, dim: Bool)] { + [ + ("Pull B", "Done 14 times · last Tuesday", false), + ("Push A", "Done 12 times · last Monday", false), + ("Legs A", "Done 9 times · last Thursday", false), + ("Conditioning", "Done 6 times · last Saturday", false), + ] + } + + var otherSessionItems: [(name: String, meta: String, dim: Bool)] { + source == "suggested" ? suggested : frequent + } + + // MARK: - Body + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // Anchor used to detect scroll past the large title. + Color.clear + .frame(height: 0) + .id("plan-scroll-top") + + header + quickActions + .padding(.top, 14) + .padding(.bottom, 4) - Spacer(minLength: 8) + VStack(alignment: .leading, spacing: 0) { + workList + // Hide alternate-session picker while a workout is in progress. + if betterFit.getActiveWorkout() == nil { + otherSessions + } + } + .padding(.horizontal, BFSpacing.pageHorizontal) + // Clearance for the floating Start workout pill + tab bar. + .padding(.bottom, 120) } - .padding(.horizontal, 20) - .padding(.top, 8) - .padding(.bottom, 100) // Space for floating nav bar } - .bfPageBackground() - .sheet(isPresented: $showCalendar) { - CalendarSheetView(selectedDate: $selectedDate, theme: theme) - .presentationDetents([PresentationDetent.medium, PresentationDetent.large]) - } - .sheet(isPresented: $showStreakSummary) { - StreakSummarySheetView( - betterFit: bf, - selectedDate: $selectedDate, - theme: theme, - openCalendar: { showCalendar = true } - ) - .presentationDetents([PresentationDetent.medium, PresentationDetent.large]) - } - .sheet(isPresented: $showCustomRangeSheet) { - CustomHeatmapRangeSheet( - theme: theme, - start: $customRangeStart, - end: $customRangeEnd - ) - .presentationDetents([PresentationDetent.medium]) + .modifier(PlanScrollCollapseModifier(isScrolled: $isScrolled)) + .bfBackground(theme: theme) + .toolbar(.hidden, for: .navigationBar) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + commitFocusedPlanField() + focusedPlanField = nil + } + .fontWeight(.semibold) + } } - .sheet(isPresented: $showEquipmentSwapSheet) { - EquipmentSwapSheet( - theme: theme, - availableEquipment: $availableEquipment, - onApply: { applyEquipmentSwaps() } - ) - .presentationDetents([PresentationDetent.medium, PresentationDetent.large]) + .safeAreaInset(edge: .top, spacing: 0) { + topBar } - .sheet(isPresented: $showAddExerciseSheet) { - AddExerciseSheet( - theme: theme, - onAdd: { exercise in - addExerciseToCurrentWorkout(exercise) + .onAppear(perform: loadPlan) + .sheet(isPresented: $showAddExercise) { + AddExerciseSheet(theme: theme) { planned in + if let targetId = replaceTargetId, + let targetIndex = exercises.firstIndex(where: { $0.id == targetId }) + { + exercises[targetIndex] = planned + replaceTargetId = nil + } else { + exercises.append(planned) } - ) - .presentationDetents([PresentationDetent.medium, PresentationDetent.large]) + persistExercises() + } + .presentationDetents([.medium, .large]) + } + .sheet(isPresented: $showEquipment) { + equipmentSheet + .presentationDetents([.medium]) } - .sheet(item: $selectedExerciseForDetail) { exercise in + .sheet(item: $selectedExercise) { exercise in ExerciseDetailSheet( exercise: exercise, theme: theme, onDelete: { - deletePlannedExercise(exercise) + exercises.removeAll { $0.id == exercise.id } + persistExercises() + selectedExercise = nil }, onReplace: { - // Handle replace + // Swap this exercise in place via the add-exercise picker. + replaceTargetId = exercise.id + showAddExercise = true }, onSuperset: { - // Handle superset + // Pair with the next row (or the previous one at the end), + // matching the work-list swipe/context-menu behaviour. + guard let exerciseIndex = exercises.firstIndex(where: { $0.id == exercise.id }) else { return } + if exerciseIndex + 1 < exercises.count { + pairSuperset(currentIndex: exerciseIndex, withIndex: exerciseIndex + 1) + } else if exerciseIndex > 0 { + pairSuperset(currentIndex: exerciseIndex - 1, withIndex: exerciseIndex) + } }, onUpdate: { updated in - updatePlannedExercise(updated) + if let exerciseIndex = exercises.firstIndex(where: { $0.id == exercise.id }) { + exercises[exerciseIndex] = updated + persistExercises() + } } ) - .presentationDetents([PresentationDetent.large]) - } - .onAppear { - ensureDemoSeededIfNeeded() - refreshStatuses() - loadGameStats() - refreshVitals() - refreshActiveWorkout() - startWorkoutTimerIfNeeded() - } - .onDisappear { - stopWorkoutTimer() - } - .onReceive(NotificationCenter.default.publisher(for: .workoutStarted)) { _ in - refreshActiveWorkout() - startWorkoutTimerIfNeeded() + .presentationDetents([.medium, .large]) } - .onReceive(NotificationCenter.default.publisher(for: .workoutCompleted)) { _ in - stopWorkoutTimer() - refreshActiveWorkout() - refreshStatuses() - loadGameStats() - refreshVitals() + .sheet(item: $workoutPreview) { preview in + WorkoutPreviewScreen( + theme: theme, + workout: preview, + onAdopt: { + adoptSession(named: preview.name) + if preview.name != sessionName { + // If we not a different plan, keep current exercises. + } + exercises = preview.exercises + sessionName = preview.name + persistExercises() + }, + onStart: { + adoptSession(named: preview.name) + exercises = preview.exercises + sessionName = preview.name + persistExercises() + } + ) } - #if DEBUG - .onChange(of: demoModeEnabled) { - ensureDemoSeededIfNeeded() - refreshStatuses() - loadGameStats() - refreshVitals() + } +} +// MARK: - Scroll collapse (search nav) + +private struct PlanScrollCollapseModifier: ViewModifier { + @Binding var isScrolled: Bool + + func body(content: Content) -> some View { + if #available(iOS 18.0, *) { + content.onScrollGeometryChange(for: Bool.self) { geo in + geo.contentOffset.y + geo.contentInsets.top > 28 + } action: { _, scrolled in + guard scrolled != isScrolled else { return } + withAnimation(.snappy(duration: 0.22)) { + isScrolled = scrolled + } } - #endif - .onChange(of: heatmapRange) { - refreshVitals() - } - .onChange(of: customRangeStart) { - if heatmapRange == .custom { - refreshVitals() + } else { + content.background { + GeometryReader { geo in + Color.clear.preference( + key: PlanScrollOffsetKey.self, + value: -geo.frame(in: .named("plan-scroll")).minY + ) + } } - } - .onChange(of: customRangeEnd) { - if heatmapRange == .custom { - refreshVitals() + .coordinateSpace(name: "plan-scroll") + .onPreferenceChange(PlanScrollOffsetKey.self) { offset in + let scrolled = offset > 28 + guard scrolled != isScrolled else { return } + withAnimation(.easeInOut(duration: 0.2)) { + isScrolled = scrolled + } } } } +} - @ViewBuilder - private var toolbarContent: some View { - toolbarButtons +private struct PlanScrollOffsetKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() } +} - @ViewBuilder - private var toolbarButtons: some View { - HStack(spacing: 10) { - #if DEBUG - if demoModeOverride == nil { - Menu { - Toggle(isOn: $demoModeEnabled) { - Label("Demo Mode", systemImage: "testtube.2") - } - } label: { - Image(systemName: isDemoMode ? "testtube.2" : "testtube.2") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(isDemoMode ? theme.accent : .secondary) - .frame(width: 34, height: 34) - .background { - Circle().fill(theme.cardBackground(for: colorScheme)) - } - .overlay { - Circle().stroke(theme.cardStroke(for: colorScheme), lineWidth: 1) - } - } - } - #endif - BFChromeIconButton( - systemImage: "chart.bar.fill", - accessibilityLabel: "Stats", - theme: theme - ) { - // Show stats - } - } +#Preview { + NavigationStack { + WorkoutHomeView(betterFit: BetterFit(), theme: .defaultTheme, demoMode: true) } + .preferredColorScheme(.dark) } diff --git a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutPreviewScreen.swift b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutPreviewScreen.swift new file mode 100644 index 0000000..9d7e875 --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutPreviewScreen.swift @@ -0,0 +1,180 @@ +import BetterFit +import SwiftUI + +// MARK: - Workout Preview +// +// Detail screen for a single suggested/frequent workout — lists its exercises +// and lets the user adopt or start it. + +struct WorkoutPreviewScreen: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + let theme: AppTheme + let workout: WorkoutPreview + + var onAdopt: () -> Void + var onStart: () -> Void + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + header + + BFSectionRule(label: "The work", trailing: "\(workout.exercises.count) exercises") + + ForEach(Array(workout.exercises.enumerated()), id: \.element.id) { index, exercise in + BFLedgerRow( + gutter: String(format: "%02d", index + 1), + systemImage: "dumbbell.fill", + title: exercise.name, + meta: meta(for: exercise), + accentEdge: index == 0 + ) { + Text("\(exercise.sets)×\(exercise.repsValue)") + .font(BFTypography.subheadlineEmphasis) + .monospacedDigit() + } + } + + Color.clear.frame(height: 80) + } + .padding(.horizontal, BFSpacing.pageHorizontal) + } + .background(BFColors.background(for: colorScheme)) + .navigationTitle(workout.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button("Adopt") { + onAdopt() + dismiss() + } + .fontWeight(.semibold) + } + } + .safeAreaInset(edge: .bottom) { + Button { + onStart() + dismiss() + } label: { + HStack(spacing: 8) { + Image(systemName: "play.fill") + .font(.system(size: 15, weight: .bold)) + Text("Start workout") + .font(BFTypography.headline) + } + .foregroundStyle(BFColors.accentInk) + .frame(maxWidth: .infinity) + .frame(height: BFControlSize.buttonLarge) + .background(Capsule(style: .continuous).fill(BFColors.accent)) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(BFColors.background(for: colorScheme)) + } + } + } + + private var header: some View { + let total = max(workout.exercises.count, 1) + let muscles = Set(workout.exercises.flatMap(\.muscleGroups)).count + return BFHeaderBlock( + eyebrow: "Suggested workout", + title: workout.name, + specs: [ + BFSpecItem(value: "\(total)", label: "Exercises"), + BFSpecItem(value: "\(workout.duration)'", label: "Planned"), + BFSpecItem(value: "\(max(muscles, 1))", label: "Muscles"), + ], + yellow: theme.prefersYellowHeaders + ) + } + + private func meta(for exercise: PlannedExercise) -> String { + var parts: [String] = [] + parts.append("\(exercise.sets) sets × \(exercise.repsValue)") + if let first = exercise.muscleGroups.first { + parts.append(first.capitalized) + } + if let targetWeight = exercise.targetWeight, !targetWeight.isEmpty { + parts.append(targetWeight) + } + return parts.joined(separator: " · ") + } +} + +// MARK: - Lightweight preview model +// +// Mirrors the data needed to render a workout card before the user adopts it. +// Built from the demo workouts + history. + +struct WorkoutPreview: Identifiable { + var id: String { name } + let name: String + let duration: Int + let exercises: [PlannedExercise] +} + +// MARK: - Builders + +enum WorkoutPreviewLibrary { + + /// Suggested preview derived from the active Plan + Suggested/Frequent lists. + static func previews(planExercises: [PlannedExercise]) -> [WorkoutPreview] { + var out: [WorkoutPreview] = [] + if !planExercises.isEmpty { + out.append( + WorkoutPreview( + name: "Pull Day", + duration: 42, + exercises: planExercises + ) + ) + } + for preview in sampleWorkouts where !out.contains(where: { $0.name == preview.name }) { + out.append(preview) + } + return out + } + + /// Default sample workouts used by Suggested / Frequent lists. + static let sampleWorkouts: [WorkoutPreview] = [ + WorkoutPreview( + name: "Push A", + duration: 48, + exercises: [ + PlannedExercise(name: "Bench press", category: .push, sets: 4, reps: "6", targetWeight: "70 kg", muscleGroups: ["Chest"]), + PlannedExercise(name: "Overhead press", category: .push, sets: 3, reps: "8", targetWeight: "40 kg", muscleGroups: ["Shoulders"]), + PlannedExercise(name: "Incline dumbbell press", category: .push, sets: 3, reps: "10", targetWeight: "28 kg", muscleGroups: ["Chest"]), + PlannedExercise(name: "Lateral raise", category: .push, sets: 3, reps: "12", targetWeight: "12 kg", muscleGroups: ["Shoulders"]), + PlannedExercise(name: "Tricep pushdown", category: .push, sets: 3, reps: "12", targetWeight: "25 kg", muscleGroups: ["Triceps"]), + ] + ), + WorkoutPreview( + name: "Legs A", + duration: 52, + exercises: [ + PlannedExercise(name: "Back squat", category: .legs, sets: 4, reps: "5", targetWeight: "100 kg", muscleGroups: ["Quads"]), + PlannedExercise(name: "Romanian deadlift", category: .legs, sets: 3, reps: "8", targetWeight: "80 kg", muscleGroups: ["Hamstrings"]), + PlannedExercise(name: "Leg press", category: .legs, sets: 3, reps: "10", targetWeight: "160 kg", muscleGroups: ["Quads"]), + PlannedExercise(name: "Walking lunge", category: .legs, sets: 3, reps: "12", targetWeight: "20 kg", muscleGroups: ["Glutes"]), + PlannedExercise(name: "Calf raise", category: .legs, sets: 4, reps: "15", targetWeight: "60 kg", muscleGroups: ["Calves"]), + ] + ), + WorkoutPreview( + name: "Conditioning", + duration: 28, + exercises: [ + PlannedExercise(name: "Treadmill intervals", category: .cardio, sets: 8, reps: "1", targetWeight: nil, muscleGroups: ["Cardio"]), + PlannedExercise(name: "Row erg", category: .cardio, sets: 4, reps: "500m", targetWeight: nil, muscleGroups: ["Cardio"]), + PlannedExercise(name: "Core circuit", category: .compound, sets: 3, reps: "12", targetWeight: nil, muscleGroups: ["Core"]), + ] + ), + ] +} \ No newline at end of file diff --git a/Apps/iOS/BetterFitApp/Models/WorkoutModels.swift b/Apps/iOS/BetterFitApp/Models/WorkoutModels.swift index 179951e..f28c35a 100644 --- a/Apps/iOS/BetterFitApp/Models/WorkoutModels.swift +++ b/Apps/iOS/BetterFitApp/Models/WorkoutModels.swift @@ -72,14 +72,28 @@ struct ExerciseDefinition: Identifiable, Equatable { // MARK: - Planned Exercise +/// A single planned set (one row in the per-set editor). +struct PlannedSet: Codable, Equatable, Hashable { + var load: Double + var reps: Int + + init(load: Double = 0, reps: Int = 8) { + self.load = max(0, load) + self.reps = max(1, reps) + } +} + /// A planned exercise for a workout day struct PlannedExercise: Identifiable, ExerciseDisplayable { let id: UUID let name: String let category: ExerciseCategory - let sets: Int - let reps: String - let targetWeight: String? + var sets: Int + var reps: String + var targetWeight: String? + /// Per-set kg/reps. When non-nil, the per-set editor uses these directly + /// instead of expanding from `sets × reps` at `targetWeight`. + var individualSets: [PlannedSet]? let muscleGroups: [String] init( @@ -98,6 +112,59 @@ struct PlannedExercise: Identifiable, ExerciseDisplayable { self.reps = reps self.targetWeight = targetWeight self.muscleGroups = muscleGroups + self.individualSets = nil + } + + init( + id: UUID = UUID(), + name: String, + category: ExerciseCategory = .push, + sets: Int, + reps: String, + targetWeight: String? = nil, + individualSets: [PlannedSet]?, + muscleGroups: [String] = [] + ) { + self.id = id + self.name = name + self.category = category + self.sets = sets + self.reps = reps + self.targetWeight = targetWeight + self.individualSets = individualSets + self.muscleGroups = muscleGroups + } + + /// Numeric weight parsed from `targetWeight` (e.g. "185 lbs" → 185). + var weightValue: Double { + Double( + targetWeight? + .replacingOccurrences(of: ",", with: ".") + .components(separatedBy: CharacterSet(charactersIn: "0123456789.").inverted) + .joined() ?? "" + ) ?? 0 + } + + /// Primary reps number from `reps` (e.g. "6-8" → 6, "10" → 10). + var repsValue: Int { + Int(reps.components(separatedBy: CharacterSet.decimalDigits.inverted).first ?? "") ?? 0 + } + + /// Effective per-set list — falls back to expanding sets/reps/targetWeight + /// when no individual sets have been authored. + func resolvedSets() -> [PlannedSet] { + if let individual = individualSets, !individual.isEmpty { + return individual + } + let reps = max(1, repsValue == 0 ? 8 : repsValue) + let load = weightValue + return (0.. WorkoutExerciseState { - let repsValue = - Int(reps.components(separatedBy: CharacterSet.decimalDigits.inverted).first ?? "10") - ?? 10 - let weightValue = - Double( - targetWeight?.components(separatedBy: CharacterSet.decimalDigits.inverted).first - ?? "0") ?? 0 + let repsValue = max(1, repsValue == 0 ? 8 : repsValue) + let weightValue = weightValue > 0 ? weightValue : 0 return WorkoutExerciseState( id: id, @@ -139,13 +201,8 @@ struct PlannedExercise: Identifiable, ExerciseDisplayable { /// Convert to a WorkoutExercise for starting a workout func toWorkoutExercise() -> WorkoutExercise { - let repsValue = - Int(reps.components(separatedBy: CharacterSet.decimalDigits.inverted).first ?? "10") - ?? 10 - let weightValue = - Double( - targetWeight?.components(separatedBy: CharacterSet.decimalDigits.inverted).first - ?? "0") ?? 0 + let repsValue = max(1, repsValue == 0 ? 8 : repsValue) + let weightValue = weightValue > 0 ? weightValue : 0 // Map ExerciseCategory to MuscleGroup let muscleGroupsMapped: [MuscleGroup] = muscleGroups.compactMap { groupName in diff --git a/Apps/iOS/BetterFitApp/Models/WorkoutPlanModels.swift b/Apps/iOS/BetterFitApp/Models/WorkoutPlanModels.swift index 2cd00ad..8d27594 100644 --- a/Apps/iOS/BetterFitApp/Models/WorkoutPlanModels.swift +++ b/Apps/iOS/BetterFitApp/Models/WorkoutPlanModels.swift @@ -52,7 +52,7 @@ struct WorkoutPlanDay: Identifiable, Equatable { /// Convert the plan day to a Workout for starting/executing func toWorkout() -> Workout { - let workoutName = workoutType?.rawValue ?? "Today's Workout" + let workoutName = workoutType.map { "\($0.rawValue) Day" } ?? "Today's workout" let workoutExercises = exercises.map { $0.toWorkoutExercise() } return Workout( name: workoutName, @@ -198,10 +198,33 @@ final class WorkoutPlanManager { let startOfDay = calendar.startOfDay(for: date) if var planDay = planDays[startOfDay] { planDay.exercises = exercises + planDay.isRest = exercises.isEmpty planDays[startOfDay] = planDay + } else { + planDays[startOfDay] = WorkoutPlanDay( + date: startOfDay, + workoutType: .fullBody, + exercises: exercises, + isRest: exercises.isEmpty + ) } } + /// Replace an entire plan day (type + exercises). + func replaceDay(_ day: WorkoutPlanDay) { + let startOfDay = calendar.startOfDay(for: day.date) + var copy = day + // Keep date keyed consistently. + planDays[startOfDay] = WorkoutPlanDay( + id: copy.id, + date: startOfDay, + workoutType: copy.workoutType, + exercises: copy.exercises, + isRest: copy.isRest, + isCompleted: copy.isCompleted + ) + } + /// Add exercise to a date func addExercise(_ exercise: PlannedExercise, to date: Date) { let startOfDay = calendar.startOfDay(for: date) @@ -298,16 +321,16 @@ final class WorkoutPlanManager { case .push: return [ PlannedExercise( - name: "Bench Press", category: .push, sets: 4, reps: "8-10", + name: "Bench Press", category: .push, sets: 4, reps: "8", targetWeight: "135 lbs", muscleGroups: ["Chest", "Triceps"]), PlannedExercise( - name: "Overhead Press", category: .push, sets: 3, reps: "8-10", + name: "Overhead Press", category: .push, sets: 3, reps: "8", targetWeight: "95 lbs", muscleGroups: ["Shoulders", "Triceps"]), PlannedExercise( - name: "Incline Dumbbell Press", category: .push, sets: 3, reps: "10-12", + name: "Incline Dumbbell Press", category: .push, sets: 3, reps: "10", targetWeight: "50 lbs", muscleGroups: ["Upper Chest"]), PlannedExercise( - name: "Tricep Pushdowns", category: .push, sets: 3, reps: "12-15", + name: "Tricep Pushdowns", category: .push, sets: 3, reps: "12", targetWeight: "40 lbs", muscleGroups: ["Triceps"]), ] case .pull: @@ -316,7 +339,7 @@ final class WorkoutPlanManager { name: "Deadlift", category: .pull, sets: 4, reps: "5", targetWeight: "225 lbs", muscleGroups: ["Back", "Hamstrings", "Glutes"]), PlannedExercise( - name: "Pull-ups", category: .pull, sets: 4, reps: "8-10", + name: "Pull-ups", category: .pull, sets: 4, reps: "8", muscleGroups: ["Lats", "Biceps"]), PlannedExercise( name: "Barbell Row", category: .pull, sets: 3, reps: "8", @@ -328,61 +351,61 @@ final class WorkoutPlanManager { case .legs: return [ PlannedExercise( - name: "Squat", category: .legs, sets: 4, reps: "6-8", targetWeight: "185 lbs", + name: "Squat", category: .legs, sets: 4, reps: "6", targetWeight: "185 lbs", muscleGroups: ["Quads", "Glutes"]), PlannedExercise( - name: "Romanian Deadlift", category: .legs, sets: 3, reps: "10-12", + name: "Romanian Deadlift", category: .legs, sets: 3, reps: "10", targetWeight: "135 lbs", muscleGroups: ["Hamstrings", "Glutes"]), PlannedExercise( - name: "Leg Press", category: .legs, sets: 3, reps: "12-15", + name: "Leg Press", category: .legs, sets: 3, reps: "12", targetWeight: "270 lbs", muscleGroups: ["Quads"]), PlannedExercise( - name: "Calf Raises", category: .legs, sets: 4, reps: "15-20", + name: "Calf Raises", category: .legs, sets: 4, reps: "15", targetWeight: "100 lbs", muscleGroups: ["Calves"]), ] case .upper: return [ PlannedExercise( - name: "Bench Press", category: .push, sets: 3, reps: "8-10", + name: "Bench Press", category: .push, sets: 3, reps: "8", targetWeight: "135 lbs", muscleGroups: ["Chest"]), PlannedExercise( - name: "Barbell Row", category: .pull, sets: 3, reps: "8-10", + name: "Barbell Row", category: .pull, sets: 3, reps: "8", targetWeight: "135 lbs", muscleGroups: ["Back"]), PlannedExercise( - name: "Shoulder Press", category: .push, sets: 3, reps: "10-12", + name: "Shoulder Press", category: .push, sets: 3, reps: "10", targetWeight: "65 lbs", muscleGroups: ["Shoulders"]), PlannedExercise( - name: "Lat Pulldown", category: .pull, sets: 3, reps: "10-12", + name: "Lat Pulldown", category: .pull, sets: 3, reps: "10", targetWeight: "100 lbs", muscleGroups: ["Lats"]), ] case .lower: return [ PlannedExercise( - name: "Squat", category: .legs, sets: 4, reps: "6-8", targetWeight: "185 lbs", + name: "Squat", category: .legs, sets: 4, reps: "6", targetWeight: "185 lbs", muscleGroups: ["Quads", "Glutes"]), PlannedExercise( - name: "Leg Curl", category: .legs, sets: 3, reps: "12-15", + name: "Leg Curl", category: .legs, sets: 3, reps: "12", targetWeight: "80 lbs", muscleGroups: ["Hamstrings"]), PlannedExercise( - name: "Hip Thrust", category: .legs, sets: 3, reps: "10-12", + name: "Hip Thrust", category: .legs, sets: 3, reps: "10", targetWeight: "135 lbs", muscleGroups: ["Glutes"]), PlannedExercise( - name: "Calf Raises", category: .legs, sets: 3, reps: "15-20", + name: "Calf Raises", category: .legs, sets: 3, reps: "15", targetWeight: "100 lbs", muscleGroups: ["Calves"]), ] case .fullBody: return [ PlannedExercise( - name: "Squat", category: .legs, sets: 3, reps: "8-10", targetWeight: "155 lbs", + name: "Squat", category: .legs, sets: 3, reps: "8", targetWeight: "155 lbs", muscleGroups: ["Quads", "Glutes"]), PlannedExercise( - name: "Bench Press", category: .push, sets: 3, reps: "8-10", + name: "Bench Press", category: .push, sets: 3, reps: "8", targetWeight: "135 lbs", muscleGroups: ["Chest"]), PlannedExercise( - name: "Barbell Row", category: .pull, sets: 3, reps: "8-10", + name: "Barbell Row", category: .pull, sets: 3, reps: "8", targetWeight: "115 lbs", muscleGroups: ["Back"]), PlannedExercise( - name: "Overhead Press", category: .push, sets: 3, reps: "8-10", + name: "Overhead Press", category: .push, sets: 3, reps: "8", targetWeight: "85 lbs", muscleGroups: ["Shoulders"]), ] case .cardio: diff --git a/Apps/iOS/BetterFitAppUITests/AdjustSetsUITests.swift b/Apps/iOS/BetterFitAppUITests/AdjustSetsUITests.swift index 069d998..247fe2c 100644 --- a/Apps/iOS/BetterFitAppUITests/AdjustSetsUITests.swift +++ b/Apps/iOS/BetterFitAppUITests/AdjustSetsUITests.swift @@ -1,6 +1,8 @@ import XCTest -/// UI tests for core workout planning journeys +/// UI tests for core workout planning journeys: +/// today's plan on the Workout tab, the exercise set editor (adjust sets), +/// and the plan insights that moved to the Body and Targets tabs. final class AdjustSetsUITests: XCTestCase { var app: XCUIApplication! @@ -17,128 +19,115 @@ final class AdjustSetsUITests: XCTestCase { app = nil } - // MARK: - Core Navigation Tests + // MARK: - Plan Navigation func testLaunchAndNavigateToPlan() throws { // Verify app launches with tab bar XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 5)) - // Tap Plan tab (2nd position in tab bar) - let tabs = app.tabBars.buttons.allElementsBoundByIndex - XCTAssertGreaterThan(tabs.count, 1, "Expected at least 2 tabs") - tabs[1].tap() + // The plan lives on the Workout tab + app.tabBars.buttons["Workout"].tap() - // Verify Plan view loaded - let planTitle = app.staticTexts["Your Training Plan"] - XCTAssertTrue(planTitle.waitForExistence(timeout: 3)) + // Verify today's plan loaded + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'The work'") + ).firstMatch.waitForExistence(timeout: 3), + "Workout tab should show today's plan") - // Verify week schedule is visible - let weekSection = app.staticTexts["This Week"] - XCTAssertTrue(weekSection.exists, "Week schedule should be visible") + // Verify the work list is present + XCTAssertTrue( + app.buttons["Add exercise"].waitForExistence(timeout: 2), + "Work list should offer adding exercises") } - func testPlanViewShowsWorkoutDays() throws { + func testPlanShowsPlannedExercises() throws { navigateToPlan() - // Find day cards by looking for workout types - let workoutTypes = ["Push", "Pull", "Legs", "Upper", "Rest"] - var foundWorkouts = 0 - - for type in workoutTypes { - if app.staticTexts[type].exists { - foundWorkouts += 1 - } - } - - XCTAssertGreaterThan(foundWorkouts, 0, "Expected at least one workout type visible") + // Each planned exercise renders its set count ("3 sets" …) + let setRows = app.staticTexts.matching( + NSPredicate(format: "label ENDSWITH 'sets'") + ) + XCTAssertGreaterThan(setRows.count, 0, "Expected at least one planned exercise with sets") } - // MARK: - Workout Day Selection + // MARK: - Exercise Detail (adjust sets) - func testSelectWorkoutDay() throws { + func testSelectExerciseOpensDetails() throws { navigateToPlan() - // Look for Push day (first workout day in split) - let pushDay = app.staticTexts["Push"] - if pushDay.waitForExistence(timeout: 2) { - // Tap its parent container (day card) - let dayCard = pushDay.firstMatch - dayCard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() - - // Wait for content to update - sleep(1) - - // Verify exercises or content appears - let hasExercises = app.otherElements["exercise-timeline-row"].exists - let hasEmptyState = app.staticTexts["No exercises planned"].exists - let hasExerciseHeader = app.staticTexts.matching( - NSPredicate(format: "label CONTAINS 'Exercises'") - ).firstMatch.exists - - XCTAssertTrue( - hasExercises || hasEmptyState || hasExerciseHeader, - "Expected exercise content or empty state after selecting day") - } + // The first planned exercise is marked as the focus row; tapping it + // opens the exercise detail (set editor) sheet. + let focusRow = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS 'Focus exercise'") + ).firstMatch + XCTAssertTrue( + focusRow.waitForExistence(timeout: 3), + "First planned exercise should be marked as the focus exercise") + focusRow.tap() + + let sheetTitle = app.navigationBars["Exercise Details"].firstMatch + let setEditor = app.staticTexts["Tap a value to edit"] + XCTAssertTrue( + sheetTitle.waitForExistence(timeout: 3) || setEditor.waitForExistence(timeout: 2), + "Tapping an exercise row should open its detail sheet") } - func testFindWorkoutWithExercises() throws { + func testExerciseDetailShowsSetControls() throws { navigateToPlan() - // Try clicking through different workout days to find one with exercises - let workoutTypes = ["Push", "Pull", "Legs", "Upper"] - var foundExercises = false - - for type in workoutTypes { - let dayTexts = app.staticTexts[type] - if dayTexts.exists { - dayTexts.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) - .tap() - sleep(1) - - // Check if exercises appeared - if app.otherElements["exercise-timeline-row"].waitForExistence(timeout: 1) { - foundExercises = true - print("Found exercises on \(type) day") - break - } - } - } - - if !foundExercises { - print("⚠️ No exercises found on any workout day - plan may not be generated") - } - - // This test passes as long as we can navigate between days - XCTAssertTrue(true, "Navigation between workout days works") + let focusRow = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS 'Focus exercise'") + ).firstMatch + XCTAssertTrue(focusRow.waitForExistence(timeout: 3)) + focusRow.tap() + + // Set editor: sets section with editable kg/reps columns and save controls + XCTAssertTrue( + app.staticTexts["Sets"].waitForExistence(timeout: 3), + "Detail sheet should list the sets section") + XCTAssertTrue(app.staticTexts["Tap a value to edit"].exists, "Set editor should be editable") + XCTAssertTrue(app.staticTexts["KG"].exists, "Set editor should show the weight column") + XCTAssertTrue(app.staticTexts["REPS"].exists, "Set editor should show the reps column") + XCTAssertTrue(app.buttons["Save"].exists, "Detail sheet should offer Save") + XCTAssertTrue(app.buttons["Cancel"].exists, "Detail sheet should offer Cancel") } // MARK: - Recovery Insights func testRecoveryInsightsVisible() throws { - navigateToPlan() + // Recovery insights moved off the plan screen onto the Body tab. + app.tabBars.buttons["Body"].tap() - // Scroll down to recovery section - app.swipeUp() + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'By muscle group'") + ).firstMatch.waitForExistence(timeout: 3), + "Per-muscle recovery insights should be visible on the Body tab") - // Check for recovery section - let recoveryTitle = app.staticTexts["Recovery Status"] + // Every tracked region gets a recovery row XCTAssertTrue( - recoveryTitle.waitForExistence(timeout: 2), - "Recovery insights section should be visible") + app.staticTexts["Chest"].exists, + "Recovery rows should list muscle groups") } + // MARK: - Weekly Stats + func testWeeklyStatsVisible() throws { - navigateToPlan() + // Weekly progress moved off the plan screen onto the Targets tab. + app.tabBars.buttons["Targets"].tap() - // Scroll to bottom - app.swipeUp() - app.swipeUp() + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'This week'") + ).firstMatch.waitForExistence(timeout: 3), + "This week's progress should be visible on the Targets tab") - // Check for stats section - let statsTitle = app.staticTexts["This Week's Progress"] XCTAssertTrue( - statsTitle.waitForExistence(timeout: 2), - "Weekly stats section should be visible") + app.staticTexts.matching( + NSPredicate(format: "label ENDSWITH '% of the week'") + ).firstMatch.exists, + "Weekly target rows should show their weekly progress") } // MARK: - Helper Methods @@ -146,11 +135,13 @@ final class AdjustSetsUITests: XCTestCase { private func navigateToPlan() { XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 5)) - // Tap Plan tab (2nd position) - let tabs = app.tabBars.buttons.allElementsBoundByIndex - tabs[1].tap() + // The plan lives on the Workout tab + app.tabBars.buttons["Workout"].tap() - let planTitle = app.staticTexts["Your Training Plan"] - XCTAssertTrue(planTitle.waitForExistence(timeout: 3)) + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'The work'") + ).firstMatch.waitForExistence(timeout: 3), + "Workout tab should load today's plan") } } diff --git a/Apps/iOS/BetterFitAppUITests/LogViewUITests.swift b/Apps/iOS/BetterFitAppUITests/LogViewUITests.swift new file mode 100644 index 0000000..251c5a4 --- /dev/null +++ b/Apps/iOS/BetterFitAppUITests/LogViewUITests.swift @@ -0,0 +1,92 @@ +import XCTest + +/// UI tests for the Log tab: streak readout, consistency heatmap, month ledger +final class LogViewUITests: XCTestCase { + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + + // Enable demo mode for consistent test data + app.launchArguments = ["UI_TESTING", "DEMO_MODE"] + app.launch() + } + + override func tearDownWithError() throws { + app = nil + } + + // MARK: - Streak and Ledger + + func testLogShowsStreakConsistencyAndStats() throws { + navigateToLog() + + let streakLabel = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'Current streak'") + ).firstMatch + XCTAssertTrue(streakLabel.waitForExistence(timeout: 3), "Streak readout should be visible") + XCTAssertTrue(app.staticTexts["days"].exists, "Streak readout should show the days unit") + + let streakNote = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS 'Consistency compounds'") + ).firstMatch + let emptyNote = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS 'Start a session today'") + ).firstMatch + XCTAssertTrue(streakNote.exists || emptyNote.exists, "Streak note should be visible") + + XCTAssertTrue(app.staticTexts["26 weeks"].exists, "Consistency section should cover 26 weeks") + + let heatmap = app.descendants(matching: .any).matching( + NSPredicate(format: "label CONTAINS[c] 'Training consistency heatmap'") + ).firstMatch + XCTAssertTrue(heatmap.exists, "Training consistency heatmap should be visible") + + XCTAssertTrue(app.staticTexts["Sessions"].exists, "All-time sessions row should be visible") + XCTAssertTrue(app.staticTexts["Volume lifted"].exists, "All-time volume row should be visible") + XCTAssertTrue(app.staticTexts["Time training"].exists, "All-time time row should be visible") + } + + // MARK: - Log a Past Workout + + func testLogPastWorkoutRowOpensSheet() throws { + navigateToLog() + + let addRow = app.buttons["Log a past workout"] + XCTAssertTrue(addRow.waitForExistence(timeout: 3), "Log a past workout row should be visible") + scrollUntilHittable(addRow) + XCTAssertTrue(addRow.isHittable, "Log a past workout row should be tappable") + addRow.tap() + + let sheet = app.sheets.firstMatch + XCTAssertTrue(sheet.waitForExistence(timeout: 3), "Log a past workout sheet should present") + XCTAssertTrue( + sheet.staticTexts["Log a past workout"].waitForExistence(timeout: 2), + "Sheet should show the log-a-past-workout form") + + app.swipeDown(velocity: .fast) + sleep(1) + } + + // MARK: - Helper Methods + + private func navigateToLog() { + let tabBar = app.tabBars.firstMatch + XCTAssertTrue(tabBar.waitForExistence(timeout: 5)) + + let tabs = tabBar.buttons.allElementsBoundByIndex + XCTAssertGreaterThan(tabs.count, 3, "Expected at least 4 tabs") + tabs[3].tap() // Log tab + + XCTAssertTrue(app.staticTexts["Log"].waitForExistence(timeout: 3)) + } + + private func scrollUntilHittable(_ element: XCUIElement, maxSwipes: Int = 8) { + for _ in 0.. Test ``` +## Tab Structure + +Tests navigate via the tab bar titles defined by `AppTab` in +`Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift`: + +| Tab | Screen | Contents | +|-----|--------|----------| +| **Workout** | `WorkoutHomeView` | Today's plan ("The work"), quick actions, "Start something else", floating Start workout dock | +| **Body** | `RecoveryView` | Overall recovery readout, per-muscle recovery rows | +| **Targets** | `TargetsView` | This week slab, weekly targets, the week, streak, records | +| **Log** | `LogView` | Streak readout, consistency heatmap, month ledger, all-time stats | + +Prefer `app.tabBars.buttons[""]` over index-based tab access. + ### Test Coverage #### `AdjustSetsUITests.swift` -Tests for the Adjust Sets functionality in PlanView: +Core workout planning journeys: + +- **Plan Navigation** — Workout tab loads today's plan; the work list renders planned exercises with set counts +- **Exercise Detail (adjust sets)** — tapping the focus exercise row opens the `ExerciseDetailSheet` set editor (sets section, KG/REPS columns, Save/Cancel) +- **Recovery Insights** — recovery rows live on the Body tab +- **Weekly Stats** — weekly progress lives on the Targets tab + +#### `TabNavigationUITests.swift` +- All four tabs are reachable and show distinct content +- The Start workout dock only appears on the Workout tab +- The dock does not overlap scrollable content on other tabs +- Starting a workout transitions to the active session + +#### `ProfileJourneyUITests.swift` +Content that moved off the old Profile tab: -- **Navigation Tests** - - `testNavigateToPlanView` - Verifies tab navigation to Plan view - - `testPlanViewShowsWeekDays` - Checks week schedule rendering - - `testTodaysPlanSectionExists` - Validates exercises or empty state +- Health/recovery overview → Body tab +- Weekly targets → Targets tab +- Personal records / empty state → Targets tab -- **Swipe Actions** - - `testSwipeToRevealAdjustSets` - Swipe left reveals "Adjust Sets" button - - `testAdjustSetsSheetOpens` - Tapping action opens modal sheet +#### `WorkoutPreviewUITests.swift` +The workout preview sheet opened from "Start something else" (Legs A renders its work, Close dismisses). -- **Sheet Controls** - - `testAdjustSetsSheetControls` - Verifies all controls exist (sets stepper, reps field, weight field, save button) - - `testAdjustSetsIncrement` - Tests + button increments sets - - `testAdjustSetsDecrement` - Tests - button decrements sets - - `testAdjustSetsSaveAndDismiss` - Save button dismisses sheet - - `testAdjustSetsCancelDismisses` - Cancel button dismisses sheet +#### `WorkoutSummaryUITests.swift` +Finishing a demo session shows the summary (session complete, what you lifted, next row) and hides the recovery section when no sets were logged. -- **Context Menu** - - `testLongPressShowsAdjustSets` - Long press shows "Adjust Sets" in menu +#### `LogViewUITests.swift` +Streak readout, consistency heatmap (26 weeks), all-time stats, log-a-past-workout sheet. + +#### `TargetsViewUITests.swift` +This week slab, weekly target rows, week day rows, streak and records sections. ## Requirements @@ -44,22 +69,12 @@ Tests for the Adjust Sets functionality in PlanView: - **iOS 17.0+ Simulator** - Demo mode enabled for consistent test data -## Architecture - -### Test Structure -``` -BetterFitAppUITests/ -├── AdjustSetsUITests.swift # Adjust sets functionality tests -└── README.md # This file -``` +## Accessibility Identifiers -### Accessibility Identifiers -Tests rely on accessibility identifiers added to components: -- `exercise-timeline-row` - Timeline exercise rows in UnifiedExerciseTimeline +Current tests match on accessibility labels (section rules, row titles, button +labels) rather than custom identifiers. Known identifiers in the app: -### Test Helpers -- `navigateToPlan()` - Navigates to Plan tab -- `openAdjustSetsSheet()` - Opens adjust sets sheet for first exercise +- `exercise-timeline-row` — rows in `UnifiedExerciseTimeline` (currently only used by the unwired `PlanView`) ## Demo Mode @@ -67,21 +82,38 @@ Tests run with these launch arguments: - `UI_TESTING` - Enables UI testing mode - `DEMO_MODE` - Uses consistent seed data -This ensures predictable test results regardless of user data. +This ensures predictable test results regardless of user data. Note that the +demo plan is a Push/Pull/Legs split (`WorkoutPlanManager`), so today's session +name varies by weekday — assert structural markers ("The work", "Focus +exercise", "Exercise 01") rather than session names. + +## Coverage Gaps + +- **ProfileView sheet is unreachable** — `WorkoutHomeView.onProfile` is never + invoked, so profile-only surfaces are untested: PR detail sheet, achievements, + yearly wrapped, settings sheet, guest/sign-in prompt. +- **AppSearchView sheet is unreachable** — `WorkoutHomeView.onSearch` is never + invoked, so search/categories are untested. +- **`PlanView` (Trends) is unwired** — the old week-schedule plan screen is not + in the tab hierarchy; the week now lives on the Targets tab. ## Troubleshooting -### Tests Skip Due to No Exercises -If tests skip with "No exercises found in plan": +### Tests Can't Find a Tab +Verify the tab titles match `AppTab.title` in `RootTabView.swift` +(Workout, Body, Targets, Log). + +### No Exercises in the Plan +If the work list is empty: 1. Verify demo mode is generating exercise data -2. Check `WorkoutPlanManager.generateInitialPlan()` in demo mode -3. Ensure at least one day has exercises +2. Check `WorkoutPlanManager.generateInitialPlan()` and + `WorkoutHomeView.loadPlan()` (falls back to the demo Pull Day) ### Sheet Not Opening -If "Adjust Sets" sheet doesn't open: -1. Check accessibility identifier is set on timeline rows -2. Verify swipe gesture is working (try manual test) -3. Ensure `onAdjustSets` callback is wired in PlanView +If a sheet (workout preview, exercise detail) doesn't open: +1. Confirm the row is hittable (scroll it on screen first) +2. Verify the tap target — plan rows open the detail sheet via the + exercise-name button ("Focus exercise" meta on the first row) ### Simulator Issues ```bash @@ -95,9 +127,8 @@ mise run ios:sim:boot26 ## Future Tests Potential additions: -- Test replacing exercises -- Test creating supersets -- Test drag-to-reorder exercises -- Test workout execution flow -- Test active workout tracking +- Test replacing exercises and supersets (swipe actions on plan rows) +- Test workout execution flow (logging sets in the active session) +- Test active workout pause/resume/stop (minimized session dock) - Test watch app sync +- Re-add Profile coverage once `onProfile` is wired to a UI entry point diff --git a/Apps/iOS/BetterFitAppUITests/TabNavigationUITests.swift b/Apps/iOS/BetterFitAppUITests/TabNavigationUITests.swift index 34e367b..dcaf90e 100644 --- a/Apps/iOS/BetterFitAppUITests/TabNavigationUITests.swift +++ b/Apps/iOS/BetterFitAppUITests/TabNavigationUITests.swift @@ -1,6 +1,6 @@ import XCTest -/// E2E tests for core tab navigation and Start Workout button behavior +/// E2E tests for core tab navigation and Start workout button behavior final class TabNavigationUITests: XCTestCase { var app: XCUIApplication! @@ -36,26 +36,19 @@ final class TabNavigationUITests: XCTestCase { XCTAssertTrue(tabBar.waitForExistence(timeout: 5), "Tab bar should appear") let tabs = tabBar.buttons.allElementsBoundByIndex - XCTAssertEqual(tabs.count, 4, "Expected 4 tabs: Workout, Plan, Search, Me") + XCTAssertEqual(tabs.count, 4, "Expected 4 tabs: Workout, Body, Targets, Log") - // Tab through each one - for (index, tab) in tabs.enumerated() { + // Tab through each one and verify its distinct content + for (title, marker) in tabMarkers { + let tab = tabBar.buttons[title] + XCTAssertTrue(tab.waitForExistence(timeout: 2), "\(title) tab should exist") tab.tap() - sleep(1) - - // Verify each tab has distinct content - switch index { - case 0: - XCTAssertTrue(app.staticTexts["Up Next"].waitForExistence(timeout: 2)) - case 1: - XCTAssertTrue(app.staticTexts["Plan"].waitForExistence(timeout: 2)) - case 2: - XCTAssertTrue(app.staticTexts["Categories"].waitForExistence(timeout: 2)) - case 3: - XCTAssertTrue(app.staticTexts["Me"].waitForExistence(timeout: 2)) - default: - break - } + + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] %@", marker) + ).firstMatch.waitForExistence(timeout: 3), + "\(title) tab should show \(marker)") } } @@ -63,66 +56,85 @@ final class TabNavigationUITests: XCTestCase { let tabBar = app.tabBars.firstMatch XCTAssertTrue(tabBar.waitForExistence(timeout: 5)) - let tabs = tabBar.buttons.allElementsBoundByIndex + for (title, marker) in tabMarkers { + tabBar.buttons[title].tap() - for (index, tab) in tabs.enumerated() { - tab.tap() - sleep(1) + // Wait for the tab's content so the negative check is meaningful. + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] %@", marker) + ).firstMatch.waitForExistence(timeout: 3), + "\(title) tab content should load") - let startButton = app.buttons["Start Workout"] - if index == 0 { + let startButton = app.buttons["Start workout"] + if title == "Workout" { XCTAssertTrue( startButton.waitForExistence(timeout: 2), - "Start Workout should be visible on the Workout tab") + "Start workout should be visible on the Workout tab") } else { XCTAssertFalse( startButton.exists, - "Start Workout should not cover content on the \(tab.label) tab") + "Start workout should not cover content on the \(title) tab") } } } func testStartWorkoutButtonDoesNotOverlapContent() throws { - // Navigate to Me tab where scrollable content exists - let tabs = app.tabBars.buttons.allElementsBoundByIndex - tabs[3].tap() // Me tab + // Scrollable content lives on the Targets tab (the old Me tab is gone). + let tabBar = app.tabBars.firstMatch + XCTAssertTrue(tabBar.waitForExistence(timeout: 5)) + tabBar.buttons["Targets"].tap() - XCTAssertTrue(app.staticTexts["Me"].waitForExistence(timeout: 3)) - XCTAssertFalse(app.buttons["Start Workout"].exists) + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'Weekly targets'") + ).firstMatch.waitForExistence(timeout: 3), + "Targets tab should load") + XCTAssertFalse(app.buttons["Start workout"].exists) // Verify key sections exist and are tappable. - let weeklyTargets = app.staticTexts["Weekly Targets"] + let weeklyTargets = app.staticTexts["Workouts"] if weeklyTargets.waitForExistence(timeout: 2) { // If visible, it should be hittable (not covered by button) - XCTAssertTrue(weeklyTargets.isHittable, "Weekly Targets should be accessible") + XCTAssertTrue(weeklyTargets.isHittable, "Weekly target rows should be accessible") } // Scroll down and verify bottom content is accessible app.swipeUp() - sleep(1) - let achievements = app.staticTexts["Achievements"] - if achievements.exists { - XCTAssertTrue(achievements.isHittable, "Achievements should be accessible after scrolling") + let records = app.staticTexts["Keep training"] + if records.waitForExistence(timeout: 2) { + XCTAssertTrue(records.isHittable, "Records row should be accessible after scrolling") } } // MARK: - Active Workout State func testStartWorkoutTransitionsToActiveState() throws { - let startButton = app.buttons["Start Workout"] + let startButton = app.buttons["Start workout"] XCTAssertTrue(startButton.waitForExistence(timeout: 5)) startButton.tap() - sleep(2) - - // After starting, should see either active workout controls or the workout sheet - let pauseButton = app.buttons["Pause Workout"] - let resumeButton = app.buttons["Resume Workout"] - let stopButton = app.buttons["Stop Workout"] + // The active session embeds in the Workout tab (Plan → Workout mode). + let firstExercise = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'Exercise 01'") + ).firstMatch + let finishButton = app.buttons["Finish workout"] XCTAssertTrue( - pauseButton.exists || resumeButton.exists || stopButton.exists || app.sheets.firstMatch.exists, - "Should transition to active workout state after tapping Start Workout") + firstExercise.waitForExistence(timeout: 5) || finishButton.waitForExistence(timeout: 2), + "Should transition to the active session after tapping Start workout") + } + + // MARK: - Helper Methods + + /// Distinct content per tab, keyed by the tab title (`AppTab.title`). + private var tabMarkers: [(title: String, marker: String)] { + [ + ("Workout", "The work"), + ("Body", "Overall recovery"), + ("Targets", "Weekly targets"), + ("Log", "26 weeks"), + ] } } diff --git a/Apps/iOS/BetterFitAppUITests/TargetsViewUITests.swift b/Apps/iOS/BetterFitAppUITests/TargetsViewUITests.swift new file mode 100644 index 0000000..06184c9 --- /dev/null +++ b/Apps/iOS/BetterFitAppUITests/TargetsViewUITests.swift @@ -0,0 +1,106 @@ +import XCTest + +/// UI tests for the Targets tab: weekly goals, week plan, streak, records +final class TargetsViewUITests: XCTestCase { + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + + // Enable demo mode for consistent test data + app.launchArguments = ["UI_TESTING", "DEMO_MODE"] + app.launch() + } + + override func tearDownWithError() throws { + app = nil + } + + // MARK: - Weekly Targets + + func testTargetsShowsWeekAndWeeklyTargets() throws { + navigateToTargets() + + let weekHeader = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'This week'") + ).firstMatch + XCTAssertTrue(weekHeader.waitForExistence(timeout: 3), "This week slab should be visible") + + let targetsHeader = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'Weekly targets'") + ).firstMatch + XCTAssertTrue(targetsHeader.exists, "Weekly targets section should be visible") + + XCTAssertTrue(app.staticTexts["Workouts"].exists, "Workouts target row should be visible") + + // Volume and Time rows appear only when real history exists for the metric + for (label, unit) in [("Volume", "k"), ("Time", "h")] where app.staticTexts[label].exists { + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label MATCHES %@", "^[0-9]+(\\.[0-9])?\(unit)$") + ).count > 0, + "\(label) row should show a real value in \(unit)") + } + + // Rows with a derived goal show a progress caption; its percentage is data-dependent + let captions = app.staticTexts.matching( + NSPredicate(format: "label ENDSWITH '% of the week'") + ) + for index in 0..<captions.count { + XCTAssertTrue( + NSPredicate(format: "SELF MATCHES '[0-9]+% of the week'").evaluate( + with: captions.element(boundBy: index).label + ), + "Progress captions should show a real percentage") + } + + // Find day rows by looking for workout types + let workoutTypes = ["Push", "Pull", "Legs", "Upper", "Rest"] + let foundDays = workoutTypes.filter { app.staticTexts[$0].exists }.count + + XCTAssertGreaterThan(foundDays, 0, "Expected at least one planned day this week") + } + + // MARK: - Streak and Records + + func testTargetsScrollRevealsStreakAndRecords() throws { + navigateToTargets() + + let currentStreak = app.staticTexts["Current streak"] + let keepTraining = app.staticTexts["Keep training"] + + scrollUntilHittable(keepTraining) + XCTAssertTrue(currentStreak.isHittable, "Streak row should be reachable after scrolling") + XCTAssertTrue(keepTraining.isHittable, "Records row should be reachable after scrolling") + + let recordsHeader = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'Records this month'") + ).firstMatch + XCTAssertTrue(recordsHeader.exists, "Records section should be visible") + XCTAssertTrue( + app.staticTexts["Personal records appear here"].exists, + "Records row should show its empty-state meta") + } + + // MARK: - Helper Methods + + private func navigateToTargets() { + let tabBar = app.tabBars.firstMatch + XCTAssertTrue(tabBar.waitForExistence(timeout: 5)) + + let tabs = tabBar.buttons.allElementsBoundByIndex + XCTAssertGreaterThan(tabs.count, 2, "Expected at least 3 tabs") + tabs[2].tap() // Targets tab + + XCTAssertTrue(app.staticTexts["Targets"].waitForExistence(timeout: 3)) + } + + private func scrollUntilHittable(_ element: XCUIElement, maxSwipes: Int = 8) { + for _ in 0..<maxSwipes { + if element.isHittable { return } + app.swipeUp() + sleep(1) + } + } +} diff --git a/Apps/iOS/BetterFitAppUITests/WorkoutPreviewUITests.swift b/Apps/iOS/BetterFitAppUITests/WorkoutPreviewUITests.swift new file mode 100644 index 0000000..9251651 --- /dev/null +++ b/Apps/iOS/BetterFitAppUITests/WorkoutPreviewUITests.swift @@ -0,0 +1,72 @@ +import XCTest + +/// UI tests for the workout preview screen opened from Workout Home +final class WorkoutPreviewUITests: XCTestCase { + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + + // Enable demo mode for consistent test data + app.launchArguments = ["UI_TESTING", "DEMO_MODE"] + app.launch() + } + + override func tearDownWithError() throws { + app = nil + } + + // MARK: - Preview Journey + + func testLegsAPreviewRendersWorkAndCloses() throws { + let tabBar = app.tabBars.firstMatch + XCTAssertTrue(tabBar.waitForExistence(timeout: 5)) + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'The work'") + ).firstMatch.waitForExistence(timeout: 3), + "Workout home should be loaded") + + let legsRow = app.staticTexts["Legs A"] + scrollUntilHittable(legsRow) + XCTAssertTrue(legsRow.isHittable, "Legs A should be listed under Start something else") + legsRow.firstMatch.tap() + + let eyebrow = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'Suggested workout'") + ).firstMatch + XCTAssertTrue(eyebrow.waitForExistence(timeout: 3), "Preview should show its suggested workout header") + + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'The work'") + ).firstMatch.exists, + "Preview should list the work section") + XCTAssertTrue(app.staticTexts["5 exercises"].exists, "Preview should show the exercise count") + XCTAssertTrue(app.staticTexts["Back squat"].exists, "Preview should list Legs A exercises") + + let closeButton = app.buttons["Close"] + XCTAssertTrue(closeButton.waitForExistence(timeout: 3), "Preview should offer a Close action") + closeButton.tap() + + XCTAssertFalse( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'Suggested workout'") + ).firstMatch.exists, + "Preview should dismiss") + XCTAssertTrue( + app.staticTexts["Legs A"].waitForExistence(timeout: 3), + "Workout home should show the suggested row again") + } + + // MARK: - Helper Methods + + private func scrollUntilHittable(_ element: XCUIElement, maxSwipes: Int = 8) { + for _ in 0..<maxSwipes { + if element.isHittable { return } + app.swipeUp() + sleep(1) + } + } +} diff --git a/Apps/iOS/BetterFitAppUITests/WorkoutSummaryUITests.swift b/Apps/iOS/BetterFitAppUITests/WorkoutSummaryUITests.swift new file mode 100644 index 0000000..e7d5a69 --- /dev/null +++ b/Apps/iOS/BetterFitAppUITests/WorkoutSummaryUITests.swift @@ -0,0 +1,124 @@ +import XCTest + +/// UI tests for the workout summary shown after finishing a demo session +final class WorkoutSummaryUITests: XCTestCase { + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + + // Enable demo mode for consistent test data + app.launchArguments = ["UI_TESTING", "DEMO_MODE"] + app.launch() + } + + override func tearDownWithError() throws { + app = nil + } + + // MARK: - Finish Journey + + func testFinishSessionShowsSummary() throws { + startDemoSession() + finishSession() + + let complete = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'Session complete'") + ).firstMatch + XCTAssertTrue(complete.waitForExistence(timeout: 5), "Summary should show the session-complete header") + + let whatYouLifted = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'What you lifted'") + ).firstMatch + XCTAssertTrue(whatYouLifted.exists, "Summary should list what you lifted") + + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS 'Good work'") + ).firstMatch.exists, + "Summary should show the session note") + + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS 'Keep the streak going'") + ).firstMatch.exists, + "Summary should show the next-session row") + + let doneButton = app.buttons["Done"] + XCTAssertTrue(doneButton.waitForExistence(timeout: 3), "Summary should offer a Done action") + doneButton.tap() + + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'The work'") + ).firstMatch.waitForExistence(timeout: 3), + "Workout home should return after dismissing the summary") + } + + // MARK: - Recovery Guard + + func testSummaryHidesRecoverySectionWithoutRecoveryData() throws { + startDemoSession() + finishSession() + + let whatYouLifted = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'What you lifted'") + ).firstMatch + XCTAssertTrue(whatYouLifted.waitForExistence(timeout: 5), "Summary should be rendered") + + // Scroll to the closing section so the whole summary body has been covered + let nextRow = app.staticTexts["Keep the streak going"] + scrollUntilHittable(nextRow) + XCTAssertTrue(nextRow.isHittable, "Summary should scroll to the next-session row") + + let recovery = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'Effect on recovery'") + ).firstMatch + XCTAssertFalse( + recovery.exists, + "Recovery section must stay hidden when the session captured no recovery data") + } + + // MARK: - Helper Methods + + private func startDemoSession() { + let tabBar = app.tabBars.firstMatch + XCTAssertTrue(tabBar.waitForExistence(timeout: 5)) + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'The work'") + ).firstMatch.waitForExistence(timeout: 3), + "Workout home should be loaded before starting") + + let startButton = app.buttons["Start workout"] + XCTAssertTrue(startButton.waitForExistence(timeout: 5)) + startButton.tap() + + let firstExercise = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'Exercise 01'") + ).firstMatch + XCTAssertTrue( + firstExercise.waitForExistence(timeout: 5), + "Active session should open on the first planned exercise") + } + + private func finishSession() { + let finishButton = app.buttons["Finish workout"] + scrollUntilHittable(finishButton) + XCTAssertTrue(finishButton.isHittable, "Finish workout button should be reachable") + finishButton.tap() + + let saveButton = app.buttons["Finish & save"] + XCTAssertTrue(saveButton.waitForExistence(timeout: 3), "Finish confirmation should appear") + saveButton.tap() + } + + private func scrollUntilHittable(_ element: XCUIElement, maxSwipes: Int = 8) { + for _ in 0..<maxSwipes { + if element.isHittable { return } + app.swipeUp() + sleep(1) + } + } +} diff --git a/design-system/SKILL.md b/design-system/SKILL.md new file mode 100644 index 0000000..e5909db --- /dev/null +++ b/design-system/SKILL.md @@ -0,0 +1,9 @@ +--- +name: design +description: Use this skill to generate well-branded interfaces and assets for BetterFit, either for production or throwaway prototypes/mocks/etc. Contains essential design guidelines, colors, type, fonts, assets, and UI kit components for protoyping. +user-invocable: true +--- + +Read the README.md file within this skill, and explore the other available files. +If creating visual artifacts (slides, mocks, throwaway prototypes, etc), copy assets out and create static HTML files for the user to view. If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand. +If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need. diff --git a/design-system/_adherence.oxlintrc.json b/design-system/_adherence.oxlintrc.json new file mode 100644 index 0000000..942c366 --- /dev/null +++ b/design-system/_adherence.oxlintrc.json @@ -0,0 +1,570 @@ +{ + "plugins": [ + "react", + "import" + ], + "rules": { + "react/forbid-elements": [ + "warn", + { + "forbid": [] + } + ], + "no-restricted-imports": [ + "warn", + { + "patterns": [ + { + "group": [ + "components/core/**", + "components/data/**", + "components/feedback/**", + "components/lists/**", + "components/workout/**", + "ui_kits/ios_app/**", + "ui_kits/watch_app/**" + ], + "message": "Import design-system components from 'index.js', not component internals." + } + ] + } + ], + "no-restricted-syntax": [ + "warn", + { + "selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]", + "message": "Raw hex color — use a design-system color token via var()." + }, + { + "selector": "Literal[value=/\\b\\d+px\\b/]", + "message": "Raw px value — use a design-system spacing token via var()." + }, + { + "selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:BBH Hegarty))/i]", + "message": "Font not provided by the design system. Available: BBH Hegarty." + }, + { + "selector": "JSXOpeningElement[name.name='Banner'] > JSXAttribute > JSXIdentifier[name!=/^(?:tone|icon|title|message|action|onDismiss|style|key|ref|className|style|children)$/]", + "message": "<Banner> doesn't accept that prop. Declared props: tone, icon, title, message, action, onDismiss, style." + }, + { + "selector": "JSXOpeningElement[name.name='Banner'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:info|success|warning|danger)$/]", + "message": "<Banner> tone must be one of 'info' | 'success' | 'warning' | 'danger'." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|size|fullWidth|icon|trailingIcon|disabled|onClick|style|key|ref|className|style|children)$/]", + "message": "<Button> doesn't accept that prop. Declared props: children, variant, size, fullWidth, icon, trailingIcon, disabled, onClick, style." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:primary|secondary|ghost|destructive)$/]", + "message": "<Button> variant must be one of 'primary' | 'secondary' | 'ghost' | 'destructive'." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:lg|md|sm)$/]", + "message": "<Button> size must be one of 'lg' | 'md' | 'sm'." + }, + { + "selector": "JSXOpeningElement[name.name='Card'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|padding|radius|tone|style|key|ref|className|style|children)$/]", + "message": "<Card> doesn't accept that prop. Declared props: children, padding, radius, tone, style." + }, + { + "selector": "JSXOpeningElement[name.name='Card'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:surface|raised|identity|outline)$/]", + "message": "<Card> tone must be one of 'surface' | 'raised' | 'identity' | 'outline'." + }, + { + "selector": "JSXOpeningElement[name.name='ChevronRow'] > JSXAttribute > JSXIdentifier[name!=/^(?:icon|title|subtitle|iconTint|value|onClick|style|key|ref|className|style|children)$/]", + "message": "<ChevronRow> doesn't accept that prop. Declared props: icon, title, subtitle, iconTint, value, onClick, style." + }, + { + "selector": "JSXOpeningElement[name.name='Chip'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|icon|selected|onClick|style|key|ref|className|style|children)$/]", + "message": "<Chip> doesn't accept that prop. Declared props: label, icon, selected, onClick, style." + }, + { + "selector": "JSXOpeningElement[name.name='ContributionHeatmap'] > JSXAttribute > JSXIdentifier[name!=/^(?:values|weeks|cell|gap|style|key|ref|className|style|children)$/]", + "message": "<ContributionHeatmap> doesn't accept that prop. Declared props: values, weeks, cell, gap, style." + }, + { + "selector": "JSXOpeningElement[name.name='Divider'] > JSXAttribute > JSXIdentifier[name!=/^(?:inset|style|key|ref|className|style|children)$/]", + "message": "<Divider> doesn't accept that prop. Declared props: inset, style." + }, + { + "selector": "JSXOpeningElement[name.name='DropdownChip'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|size|onClick|style|key|ref|className|style|children)$/]", + "message": "<DropdownChip> doesn't accept that prop. Declared props: label, size, onClick, style." + }, + { + "selector": "JSXOpeningElement[name.name='DropdownChip'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:md|lg)$/]", + "message": "<DropdownChip> size must be one of 'md' | 'lg'." + }, + { + "selector": "JSXOpeningElement[name.name='EmptyState'] > JSXAttribute > JSXIdentifier[name!=/^(?:icon|title|message|action|style|key|ref|className|style|children)$/]", + "message": "<EmptyState> doesn't accept that prop. Declared props: icon, title, message, action, style." + }, + { + "selector": "JSXOpeningElement[name.name='ExerciseRow'] > JSXAttribute > JSXIdentifier[name!=/^(?:index|name|prescription|muscles|state|isLast|onClick|style|key|ref|className|style|children)$/]", + "message": "<ExerciseRow> doesn't accept that prop. Declared props: index, name, prescription, muscles, state, isLast, onClick, style." + }, + { + "selector": "JSXOpeningElement[name.name='ExerciseRow'] > JSXAttribute[name.name='state'] > Literal[value!=/^(?:planned|active|done)$/]", + "message": "<ExerciseRow> state must be one of 'planned' | 'active' | 'done'." + }, + { + "selector": "JSXOpeningElement[name.name='Gauge'] > JSXAttribute > JSXIdentifier[name!=/^(?:progress|width|thickness|tint|value|label|style|key|ref|className|style|children)$/]", + "message": "<Gauge> doesn't accept that prop. Declared props: progress, width, thickness, tint, value, label, style." + }, + { + "selector": "JSXOpeningElement[name.name='Icon'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|size|weight|color|style|key|ref|className|style|children)$/]", + "message": "<Icon> doesn't accept that prop. Declared props: name, size, weight, color, style." + }, + { + "selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute > JSXIdentifier[name!=/^(?:icon|label|size|variant|onClick|style|key|ref|className|style|children)$/]", + "message": "<IconButton> doesn't accept that prop. Declared props: icon, label, size, variant, onClick, style." + }, + { + "selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:surface|accent|quiet)$/]", + "message": "<IconButton> variant must be one of 'surface' | 'accent' | 'quiet'." + }, + { + "selector": "JSXOpeningElement[name.name='LegendDot'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|percent|color|style|key|ref|className|style|children)$/]", + "message": "<LegendDot> doesn't accept that prop. Declared props: label, percent, color, style." + }, + { + "selector": "JSXOpeningElement[name.name='ListRow'] > JSXAttribute > JSXIdentifier[name!=/^(?:icon|title|subtitle|iconTint|trailing|onClick|style|key|ref|className|style|children)$/]", + "message": "<ListRow> doesn't accept that prop. Declared props: icon, title, subtitle, iconTint, trailing, onClick, style." + }, + { + "selector": "JSXOpeningElement[name.name='Logo'] > JSXAttribute > JSXIdentifier[name!=/^(?:variant|height|assetBase|style|key|ref|className|style|children)$/]", + "message": "<Logo> doesn't accept that prop. Declared props: variant, height, assetBase, style." + }, + { + "selector": "JSXOpeningElement[name.name='MetricPill'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|value|icon|tone|style|key|ref|className|style|children)$/]", + "message": "<MetricPill> doesn't accept that prop. Declared props: label, value, icon, tone, style." + }, + { + "selector": "JSXOpeningElement[name.name='MetricPill'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|accent|success)$/]", + "message": "<MetricPill> tone must be one of 'neutral' | 'accent' | 'success'." + }, + { + "selector": "JSXOpeningElement[name.name='MuscleChip'] > JSXAttribute > JSXIdentifier[name!=/^(?:muscle|percent|tint|style|key|ref|className|style|children)$/]", + "message": "<MuscleChip> doesn't accept that prop. Declared props: muscle, percent, tint, style." + }, + { + "selector": "JSXOpeningElement[name.name='OverviewStat'] > JSXAttribute > JSXIdentifier[name!=/^(?:icon|value|label|tint|style|key|ref|className|style|children)$/]", + "message": "<OverviewStat> doesn't accept that prop. Declared props: icon, value, label, tint, style." + }, + { + "selector": "JSXOpeningElement[name.name='PhotoSlot'] > JSXAttribute > JSXIdentifier[name!=/^(?:src|alt|width|height|radius|label|style|key|ref|className|style|children)$/]", + "message": "<PhotoSlot> doesn't accept that prop. Declared props: src, alt, width, height, radius, label, style." + }, + { + "selector": "JSXOpeningElement[name.name='ProgressRing'] > JSXAttribute > JSXIdentifier[name!=/^(?:progress|size|lineWidth|tint|children|style|key|ref|className|style|children)$/]", + "message": "<ProgressRing> doesn't accept that prop. Declared props: progress, size, lineWidth, tint, children, style." + }, + { + "selector": "JSXOpeningElement[name.name='RecoveryBadge'] > JSXAttribute > JSXIdentifier[name!=/^(?:status|style|key|ref|className|style|children)$/]", + "message": "<RecoveryBadge> doesn't accept that prop. Declared props: status, style." + }, + { + "selector": "JSXOpeningElement[name.name='RecoveryDot'] > JSXAttribute > JSXIdentifier[name!=/^(?:status|size|style|key|ref|className|style|children)$/]", + "message": "<RecoveryDot> doesn't accept that prop. Declared props: status, size, style." + }, + { + "selector": "JSXOpeningElement[name.name='RestTimerBar'] > JSXAttribute > JSXIdentifier[name!=/^(?:remaining|total|onAdd|onSkip|style|key|ref|className|style|children)$/]", + "message": "<RestTimerBar> doesn't accept that prop. Declared props: remaining, total, onAdd, onSkip, style." + }, + { + "selector": "JSXOpeningElement[name.name='SectionHeader'] > JSXAttribute > JSXIdentifier[name!=/^(?:title|action|style|key|ref|className|style|children)$/]", + "message": "<SectionHeader> doesn't accept that prop. Declared props: title, action, style." + }, + { + "selector": "JSXOpeningElement[name.name='SetLogField'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|unit|value|onChange|style|key|ref|className|style|children)$/]", + "message": "<SetLogField> doesn't accept that prop. Declared props: label, unit, value, onChange, style." + }, + { + "selector": "JSXOpeningElement[name.name='SettingsRow'] > JSXAttribute > JSXIdentifier[name!=/^(?:kind|icon|title|subtitle|value|checked|onChange|onClick|style|key|ref|className|style|children)$/]", + "message": "<SettingsRow> doesn't accept that prop. Declared props: kind, icon, title, subtitle, value, checked, onChange, onClick, style." + }, + { + "selector": "JSXOpeningElement[name.name='SettingsRow'] > JSXAttribute[name.name='kind'] > Literal[value!=/^(?:toggle|radio|static|link)$/]", + "message": "<SettingsRow> kind must be one of 'toggle' | 'radio' | 'static' | 'link'." + }, + { + "selector": "JSXOpeningElement[name.name='StatTile'] > JSXAttribute > JSXIdentifier[name!=/^(?:icon|value|label|tint|style|key|ref|className|style|children)$/]", + "message": "<StatTile> doesn't accept that prop. Declared props: icon, value, label, tint, style." + }, + { + "selector": "JSXOpeningElement[name.name='SupersetIndicator'] > JSXAttribute > JSXIdentifier[name!=/^(?:rounds|label|style|key|ref|className|style|children)$/]", + "message": "<SupersetIndicator> doesn't accept that prop. Declared props: rounds, label, style." + }, + { + "selector": "JSXOpeningElement[name.name='WeightUnitToggle'] > JSXAttribute > JSXIdentifier[name!=/^(?:value|options|onChange|style|key|ref|className|style|children)$/]", + "message": "<WeightUnitToggle> doesn't accept that prop. Declared props: value, options, onChange, style." + }, + { + "selector": "JSXOpeningElement[name.name='WorkoutCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|focus|exercises|duration|volume|muscles|tone|badge|onClick|style|key|ref|className|style|children)$/]", + "message": "<WorkoutCard> doesn't accept that prop. Declared props: name, focus, exercises, duration, volume, muscles, tone, badge, onClick, style." + }, + { + "selector": "JSXOpeningElement[name.name='WorkoutCard'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:surface|identity)$/]", + "message": "<WorkoutCard> tone must be one of 'surface' | 'identity'." + } + ] + }, + "overrides": [ + { + "files": [ + "**/index.js" + ], + "rules": { + "no-restricted-imports": "off" + } + } + ], + "x-omelette": { + "components": { + "Banner": { + "replaces": [] + }, + "Button": { + "replaces": [] + }, + "Card": { + "replaces": [] + }, + "ChevronRow": { + "replaces": [] + }, + "Chip": { + "replaces": [] + }, + "ContributionHeatmap": { + "replaces": [] + }, + "Divider": { + "replaces": [] + }, + "DropdownChip": { + "replaces": [] + }, + "EmptyState": { + "replaces": [] + }, + "ExerciseRow": { + "replaces": [] + }, + "Gauge": { + "replaces": [] + }, + "Icon": { + "replaces": [] + }, + "IconButton": { + "replaces": [] + }, + "LegendDot": { + "replaces": [] + }, + "ListRow": { + "replaces": [] + }, + "Logo": { + "replaces": [] + }, + "MetricPill": { + "replaces": [] + }, + "MuscleChip": { + "replaces": [] + }, + "OverviewStat": { + "replaces": [] + }, + "PhotoSlot": { + "replaces": [] + }, + "ProgressRing": { + "replaces": [] + }, + "RecoveryBadge": { + "replaces": [] + }, + "RecoveryDot": { + "replaces": [] + }, + "RestTimerBar": { + "replaces": [] + }, + "SectionHeader": { + "replaces": [] + }, + "SetLogField": { + "replaces": [] + }, + "SettingsRow": { + "replaces": [] + }, + "StatTile": { + "replaces": [] + }, + "SupersetIndicator": { + "replaces": [] + }, + "WeightUnitToggle": { + "replaces": [] + }, + "WorkoutCard": { + "replaces": [] + } + }, + "tokens": [ + "--accent", + "--accent-pressed", + "--accent-surface", + "--accent-text", + "--bf-black", + "--bf-white", + "--bf-yellow", + "--bg-elevated", + "--bg-page", + "--border", + "--border-strong", + "--card-padding", + "--control-lg", + "--control-md", + "--control-sm", + "--danger", + "--danger-surface", + "--dur-fast", + "--dur-press", + "--dur-slow", + "--dur-standard", + "--ease-out", + "--ease-snappy", + "--ease-spring", + "--focus-ring", + "--font-display", + "--font-mono", + "--font-numeric", + "--font-ui", + "--glass-blur", + "--glass-border", + "--glass-fill", + "--glass-fill-strong", + "--glass-highlight", + "--glass-shadow", + "--grey-050", + "--grey-100", + "--grey-400", + "--grey-500", + "--grey-850", + "--grey-880", + "--grey-900", + "--grey-950", + "--grey-ink", + "--heat-0", + "--heat-1", + "--heat-2", + "--heat-3", + "--heat-4", + "--info", + "--info-surface", + "--leading-normal", + "--leading-relaxed", + "--leading-snug", + "--leading-tight", + "--page-inline", + "--pill-sm", + "--press-opacity", + "--press-scale", + "--radius-band", + "--radius-button", + "--radius-card", + "--radius-md", + "--radius-pill", + "--radius-sheet", + "--radius-sm", + "--recovery-fatigued", + "--recovery-fresh", + "--recovery-recovered", + "--recovery-sore", + "--separator", + "--shadow-card", + "--shadow-none", + "--shadow-pop", + "--shadow-sheet", + "--space-2xl", + "--space-3xl", + "--space-4xl", + "--space-lg", + "--space-md", + "--space-sm", + "--space-xl", + "--space-xs", + "--success", + "--success-surface", + "--surface", + "--surface-inverse", + "--surface-raised", + "--tap-min", + "--text-body", + "--text-callout", + "--text-caption", + "--text-footnote", + "--text-headline", + "--text-hero", + "--text-large-title", + "--text-on-accent", + "--text-on-inverse", + "--text-primary", + "--text-secondary", + "--text-stat-lg", + "--text-stat-md", + "--text-stat-sm", + "--text-subheadline", + "--text-tertiary", + "--text-timer", + "--text-title-1", + "--text-title-2", + "--text-title-3", + "--tracking-display", + "--tracking-label", + "--tracking-normal", + "--warning", + "--warning-surface", + "--weight-black", + "--weight-bold", + "--weight-medium", + "--weight-regular", + "--weight-semibold", + "--yellow-bright", + "--yellow-deep", + "--yellow-dim" + ], + "tokenKinds": { + "--font-display": "font", + "--font-ui": "font", + "--font-mono": "font", + "--font-numeric": "font", + "--bf-yellow": "color", + "--bf-black": "color", + "--bf-white": "color", + "--yellow-bright": "color", + "--yellow-deep": "color", + "--yellow-dim": "color", + "--grey-950": "color", + "--grey-900": "color", + "--grey-880": "color", + "--grey-850": "color", + "--grey-500": "color", + "--grey-400": "color", + "--grey-100": "color", + "--grey-050": "color", + "--grey-ink": "color", + "--bg-page": "color", + "--bg-elevated": "color", + "--surface": "color", + "--surface-raised": "color", + "--surface-inverse": "color", + "--border": "color", + "--border-strong": "color", + "--separator": "color", + "--text-primary": "font", + "--text-secondary": "font", + "--text-tertiary": "font", + "--text-on-accent": "font", + "--text-on-inverse": "font", + "--accent": "color", + "--accent-pressed": "color", + "--accent-surface": "color", + "--glass-fill": "color", + "--glass-fill-strong": "color", + "--glass-border": "color", + "--glass-highlight": "color", + "--glass-blur": "other", + "--glass-shadow": "shadow", + "--accent-text": "font", + "--focus-ring": "color", + "--success": "color", + "--warning": "color", + "--danger": "color", + "--info": "color", + "--success-surface": "color", + "--warning-surface": "color", + "--danger-surface": "color", + "--info-surface": "color", + "--recovery-recovered": "color", + "--recovery-fresh": "color", + "--recovery-fatigued": "color", + "--recovery-sore": "color", + "--heat-0": "color", + "--heat-1": "color", + "--heat-2": "color", + "--heat-3": "color", + "--heat-4": "color", + "--text-hero": "font", + "--text-large-title": "font", + "--text-title-1": "font", + "--text-title-2": "font", + "--text-title-3": "font", + "--text-headline": "font", + "--text-body": "font", + "--text-callout": "font", + "--text-subheadline": "font", + "--text-footnote": "font", + "--text-caption": "font", + "--text-timer": "font", + "--text-stat-lg": "font", + "--text-stat-md": "font", + "--text-stat-sm": "font", + "--weight-regular": "font", + "--weight-medium": "font", + "--weight-semibold": "font", + "--weight-bold": "font", + "--weight-black": "font", + "--leading-tight": "font", + "--leading-snug": "font", + "--leading-normal": "font", + "--leading-relaxed": "font", + "--tracking-display": "font", + "--tracking-normal": "font", + "--tracking-label": "font", + "--space-xs": "spacing", + "--space-sm": "spacing", + "--space-md": "spacing", + "--space-lg": "spacing", + "--space-xl": "spacing", + "--space-2xl": "spacing", + "--space-3xl": "spacing", + "--space-4xl": "spacing", + "--page-inline": "spacing", + "--card-padding": "spacing", + "--control-lg": "spacing", + "--control-md": "spacing", + "--control-sm": "spacing", + "--pill-sm": "spacing", + "--tap-min": "spacing", + "--radius-sm": "radius", + "--radius-md": "radius", + "--radius-button": "radius", + "--radius-card": "radius", + "--radius-sheet": "radius", + "--radius-pill": "radius", + "--radius-band": "radius", + "--shadow-none": "shadow", + "--shadow-card": "shadow", + "--shadow-sheet": "shadow", + "--shadow-pop": "shadow", + "--dur-press": "other", + "--dur-fast": "other", + "--dur-standard": "other", + "--dur-slow": "other", + "--ease-out": "other", + "--ease-snappy": "other", + "--ease-spring": "other", + "--press-opacity": "other", + "--press-scale": "other" + }, + "fontFamilies": [ + "BBH Hegarty" + ] + } +} \ No newline at end of file diff --git a/design-system/_ds_bundle.js b/design-system/_ds_bundle.js new file mode 100644 index 0000000..4980c91 --- /dev/null +++ b/design-system/_ds_bundle.js @@ -0,0 +1,8513 @@ +/* @ds-bundle: {"format":4,"namespace":"BetterFitDesignSystem_6a70ea","components":[{"name":"Button","sourcePath":"components/core/Button.jsx"},{"name":"Card","sourcePath":"components/core/Card.jsx"},{"name":"Chip","sourcePath":"components/core/Chip.jsx"},{"name":"Divider","sourcePath":"components/core/Divider.jsx"},{"name":"DropdownChip","sourcePath":"components/core/DropdownChip.jsx"},{"name":"Icon","sourcePath":"components/core/Icon.jsx"},{"name":"IconButton","sourcePath":"components/core/IconButton.jsx"},{"name":"Logo","sourcePath":"components/core/Logo.jsx"},{"name":"MetricPill","sourcePath":"components/core/MetricPill.jsx"},{"name":"PhotoSlot","sourcePath":"components/core/PhotoSlot.jsx"},{"name":"SectionHeader","sourcePath":"components/core/SectionHeader.jsx"},{"name":"ContributionHeatmap","sourcePath":"components/data/ContributionHeatmap.jsx"},{"name":"Gauge","sourcePath":"components/data/Gauge.jsx"},{"name":"LegendDot","sourcePath":"components/data/LegendDot.jsx"},{"name":"MuscleChip","sourcePath":"components/data/MuscleChip.jsx"},{"name":"OverviewStat","sourcePath":"components/data/OverviewStat.jsx"},{"name":"ProgressRing","sourcePath":"components/data/ProgressRing.jsx"},{"name":"StatTile","sourcePath":"components/data/StatTile.jsx"},{"name":"Banner","sourcePath":"components/feedback/Banner.jsx"},{"name":"EmptyState","sourcePath":"components/feedback/EmptyState.jsx"},{"name":"RecoveryBadge","sourcePath":"components/feedback/RecoveryBadge.jsx"},{"name":"RECOVERY_COLORS","sourcePath":"components/feedback/RecoveryDot.jsx"},{"name":"RecoveryDot","sourcePath":"components/feedback/RecoveryDot.jsx"},{"name":"RestTimerBar","sourcePath":"components/feedback/RestTimerBar.jsx"},{"name":"ChevronRow","sourcePath":"components/lists/ChevronRow.jsx"},{"name":"ExerciseRow","sourcePath":"components/lists/ExerciseRow.jsx"},{"name":"ListRow","sourcePath":"components/lists/ListRow.jsx"},{"name":"SettingsRow","sourcePath":"components/lists/SettingsRow.jsx"},{"name":"SetLogField","sourcePath":"components/workout/SetLogField.jsx"},{"name":"SupersetIndicator","sourcePath":"components/workout/SupersetIndicator.jsx"},{"name":"WeightUnitToggle","sourcePath":"components/workout/WeightUnitToggle.jsx"},{"name":"WorkoutCard","sourcePath":"components/workout/WorkoutCard.jsx"}],"sourceHashes":{"components/core/Button.jsx":"b3ff7acc06ae","components/core/Card.jsx":"99821a742ab2","components/core/Chip.jsx":"22233673b0e7","components/core/Divider.jsx":"c7d1116be1e4","components/core/DropdownChip.jsx":"f6c201733013","components/core/Icon.jsx":"d813a0ef591c","components/core/IconButton.jsx":"6636ca2d40b3","components/core/Logo.jsx":"64fb18c1ee7c","components/core/MetricPill.jsx":"f95d21160369","components/core/PhotoSlot.jsx":"90c56157fcc4","components/core/SectionHeader.jsx":"682d9badb625","components/data/ContributionHeatmap.jsx":"e0ba29fc1c97","components/data/Gauge.jsx":"db9b9b982037","components/data/LegendDot.jsx":"95236bd12e8a","components/data/MuscleChip.jsx":"ff24563545ff","components/data/OverviewStat.jsx":"884acd2df7a4","components/data/ProgressRing.jsx":"2cd572bbafbc","components/data/StatTile.jsx":"b4c056bce31b","components/feedback/Banner.jsx":"7368565bf0cd","components/feedback/EmptyState.jsx":"79410f181a52","components/feedback/RecoveryBadge.jsx":"b1074c0e51d8","components/feedback/RecoveryDot.jsx":"8a3df73177de","components/feedback/RestTimerBar.jsx":"3aeb4890f450","components/lists/ChevronRow.jsx":"d73714c0e67b","components/lists/ExerciseRow.jsx":"b570f05fdfbc","components/lists/ListRow.jsx":"49af92d7ade7","components/lists/SettingsRow.jsx":"6039aa5ae44c","components/workout/SetLogField.jsx":"e2dd672536ae","components/workout/SupersetIndicator.jsx":"001cc035146d","components/workout/WeightUnitToggle.jsx":"8c5229444ba6","components/workout/WorkoutCard.jsx":"1acf63f140b3","ui_kits/ios_app/AddExerciseSheet.jsx":"44d8b3bb32b7","ui_kits/ios_app/AppShell.jsx":"be15b642533f","ui_kits/ios_app/BodyScreen.jsx":"5bff67fe0708","ui_kits/ios_app/LogScreen.jsx":"54847cd64074","ui_kits/ios_app/MyPlanScreen.jsx":"70cee2652c4b","ui_kits/ios_app/ProfileScreen.jsx":"34f72961a90d","ui_kits/ios_app/SearchScreen.jsx":"09c892a2e5b3","ui_kits/ios_app/SessionScreen.jsx":"d8af610eda55","ui_kits/ios_app/SettingsScreen.jsx":"bf0de8bf39c3","ui_kits/ios_app/SignInScreen.jsx":"5ed950c1c533","ui_kits/ios_app/SummaryScreen.jsx":"5afb222c7820","ui_kits/ios_app/TargetsScreen.jsx":"ae2833a7cd86","ui_kits/ios_app/data.js":"6fa253140d9b","ui_kits/ios_app/image-slot.js":"fff26d081c8d","ui_kits/ios_app/tweaks-panel.jsx":"d259e3a86f73","ui_kits/watch_app/WatchScreens.jsx":"56a50166c9b2","ui_kits/watch_app/WatchShell.jsx":"e4722ab4aca4"},"inlinedExternals":[],"unexposedExports":[]} */ + +(() => { + +const __ds_ns = (window.BetterFitDesignSystem_6a70ea = window.BetterFitDesignSystem_6a70ea || {}); + +const __ds_scope = {}; + +(__ds_ns.__errors = __ds_ns.__errors || []); + +// components/core/Card.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Flat card: solid surface + hairline border + 16px continuous corners. No glass, no blur. */ +function Card({ + children, + padding = 16, + radius = 'var(--radius-card)', + tone = 'surface', + style, + ...rest +}) { + const tones = { + surface: { + background: 'var(--surface)', + border: '1px solid var(--border)', + color: 'var(--text-primary)' + }, + raised: { + background: 'var(--surface-raised)', + border: '1px solid var(--border)', + color: 'var(--text-primary)' + }, + identity: { + background: 'var(--bf-yellow)', + border: '1px solid transparent', + color: 'var(--bf-black)' + }, + outline: { + background: 'transparent', + border: '1px solid var(--border-strong)', + color: 'var(--text-primary)' + } + }; + return /*#__PURE__*/React.createElement("div", _extends({ + style: { + ...tones[tone], + padding, + borderRadius: radius, + boxShadow: 'var(--shadow-card)', + ...style + } + }, rest), children); +} +Object.assign(__ds_scope, { Card }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Card.jsx", error: String((e && e.message) || e) }); } + +// components/core/Divider.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** 1px separator. Use sparingly — spacing usually does the job. */ +function Divider({ + inset = 0, + style, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + role: "separator", + style: { + height: 1, + background: 'var(--separator)', + marginLeft: inset, + marginRight: inset, + ...style + } + }, rest)); +} +Object.assign(__ds_scope, { Divider }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Divider.jsx", error: String((e && e.message) || e) }); } + +// components/core/Icon.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** + * Phosphor glyphs that are pure strokes have no `fill` cut — asking for one + * renders tofu. These silently fall back to `bold`, which is the correct + * visual match anyway. + */ +const STROKE_ONLY = new Set(['plus', 'minus', 'x', 'check', 'equals', 'divide', 'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right', 'arrow-up-right', 'arrow-up-left', 'arrow-down-right', 'arrow-down-left', 'arrows-clockwise', 'arrows-counter-clockwise', 'arrows-left-right', 'arrows-out', 'arrows-in', 'caret-up', 'caret-down', 'caret-left', 'caret-right', 'caret-double-up', 'caret-double-down', 'caret-double-left', 'caret-double-right', 'dots-three', 'dots-three-vertical', 'dots-six', 'magnifying-glass', 'magnifying-glass-plus', 'magnifying-glass-minus', 'list', 'list-checks', 'list-bullets', 'sliders-horizontal', 'sliders', 'chart-line', 'chart-line-up', 'chart-line-down', 'wifi-high', 'wifi-medium']); + +/** Phosphor Icons stand-in for SF Symbols. See readme.md → ICONOGRAPHY. */ +function Icon({ + name, + size = 16, + weight = 'fill', + color = 'currentColor', + style, + ...rest +}) { + const w = weight === 'fill' && STROKE_ONLY.has(name) ? 'bold' : weight; + return /*#__PURE__*/React.createElement("i", _extends({ + className: `ph-${w} ph-${name}`, + "aria-hidden": "true", + style: { + fontSize: size, + lineHeight: 1, + color, + display: 'inline-flex', + ...style + } + }, rest)); +} +Object.assign(__ds_scope, { Icon }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Icon.jsx", error: String((e && e.message) || e) }); } + +// components/core/Button.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +const { + useState +} = React; +const HEIGHT = { + lg: 'var(--control-lg)', + md: 'var(--control-md)', + sm: 'var(--control-sm)' +}; +const FONT = { + lg: 'var(--text-headline)', + md: 'var(--text-headline)', + sm: 'var(--text-subheadline)' +}; +const VARIANTS = { + primary: { + background: 'var(--accent)', + color: 'var(--text-on-accent)', + border: '1px solid transparent' + }, + secondary: { + background: 'var(--surface-raised)', + color: 'var(--text-primary)', + border: '1px solid var(--border)' + }, + ghost: { + background: 'transparent', + color: 'var(--accent-text)', + border: '1px solid transparent' + }, + /* Destructive is inverted, not red — the system has no alarm hue. */ + destructive: { + background: 'var(--text-primary)', + color: 'var(--bg-page)', + border: '1px solid transparent' + } +}; + +/** Actions. One full-width primary per view; secondary/ghost for everything else. */ +function Button({ + children, + variant = 'primary', + size = 'lg', + fullWidth = variant === 'primary', + icon, + trailingIcon, + disabled = false, + onClick, + style, + ...rest +}) { + const [pressed, setPressed] = useState(false); + const v = VARIANTS[variant] || VARIANTS.primary; + const isGhost = variant === 'ghost'; + return /*#__PURE__*/React.createElement("button", _extends({ + type: "button", + disabled: disabled, + onClick: onClick, + onPointerDown: () => setPressed(true), + onPointerUp: () => setPressed(false), + onPointerLeave: () => setPressed(false), + style: { + ...v, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + height: isGhost ? 'auto' : HEIGHT[size], + /* Ghost has no container, but it still needs a thumb-sized hit area. */ + minHeight: 'var(--tap-min)', + padding: isGhost ? '0 2px' : `0 ${size === 'sm' ? 16 : 20}px`, + width: fullWidth ? '100%' : 'auto', + fontFamily: 'var(--font-ui)', + fontSize: FONT[size], + fontWeight: 'var(--weight-semibold)', + borderRadius: isGhost ? 0 : 'var(--radius-button)', + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.4 : pressed ? variant === 'secondary' ? 0.7 : isGhost ? 0.6 : 'var(--press-opacity)' : 1, + transform: pressed && !isGhost ? 'scale(var(--press-scale))' : 'none', + transition: 'opacity var(--dur-press) var(--ease-out), transform var(--dur-press) var(--ease-out)', + ...style + } + }, rest), icon && /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: icon, + size: size === 'sm' ? 14 : 17 + }), /*#__PURE__*/React.createElement("span", null, children), trailingIcon && /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: trailingIcon, + size: size === 'sm' ? 12 : 14, + style: { + opacity: 0.7 + } + })); +} +Object.assign(__ds_scope, { Button }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Button.jsx", error: String((e && e.message) || e) }); } + +// components/core/Chip.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Filter / selection capsule. Selected = accent fill, unselected = raised surface + hairline. */ +function Chip({ + label, + icon, + selected = false, + onClick, + style, + ...rest +}) { + return /*#__PURE__*/React.createElement("button", _extends({ + type: "button", + onClick: onClick, + "aria-pressed": selected, + style: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + minHeight: 'var(--tap-min)', + padding: '0 16px', + borderRadius: 'var(--radius-pill)', + cursor: onClick ? 'pointer' : 'default', + background: selected ? 'var(--accent)' : 'var(--surface-raised)', + color: selected ? 'var(--text-on-accent)' : 'var(--text-primary)', + border: selected ? '1px solid transparent' : '1px solid var(--border)', + fontSize: 'var(--text-footnote)', + fontWeight: 'var(--weight-semibold)', + fontFamily: 'var(--font-ui)', + transition: 'background var(--dur-fast) var(--ease-out)', + ...style + } + }, rest), icon && /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: icon, + size: 14 + }), label); +} +Object.assign(__ds_scope, { Chip }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Chip.jsx", error: String((e && e.message) || e) }); } + +// components/core/DropdownChip.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Capsule that opens a menu. Reads as a current value, not a filter. */ +function DropdownChip({ + label, + size = 'md', + onClick, + style, + ...rest +}) { + const big = size === 'lg'; + return /*#__PURE__*/React.createElement("button", _extends({ + type: "button", + onClick: onClick, + style: { + display: 'inline-flex', + alignItems: 'center', + gap: big ? 10 : 6, + padding: big ? '12px 20px' : '8px 14px', + minHeight: big ? 48 : 34, + borderRadius: 'var(--radius-pill)', + cursor: 'pointer', + background: 'var(--surface-raised)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + fontFamily: 'var(--font-ui)', + fontSize: big ? 'var(--text-callout)' : 'var(--text-footnote)', + fontWeight: 'var(--weight-semibold)', + ...style + } + }, rest), label, /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: "caret-down", + size: big ? 13 : 11, + weight: "bold", + style: { + opacity: 0.8 + } + })); +} +Object.assign(__ds_scope, { DropdownChip }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/DropdownChip.jsx", error: String((e && e.message) || e) }); } + +// components/core/IconButton.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +const { + useState +} = React; +/** Circular icon control on a raised surface. Chrome buttons, toolbars, row affordances. + `size` is the visible circle; the hit area never drops below `--tap-min`. */ +function IconButton({ + icon, + label, + size = 44, + variant = 'surface', + onClick, + style, + ...rest +}) { + const [pressed, setPressed] = useState(false); + const fills = { + surface: { + background: 'var(--surface-raised)', + color: 'var(--text-primary)', + border: '1px solid var(--border)' + }, + accent: { + background: 'var(--accent)', + color: 'var(--text-on-accent)', + border: '1px solid transparent' + }, + quiet: { + background: 'transparent', + color: 'var(--text-secondary)', + border: '1px solid transparent' + } + }; + return /*#__PURE__*/React.createElement("button", _extends({ + type: "button", + "aria-label": label, + onClick: onClick, + onPointerDown: () => setPressed(true), + onPointerUp: () => setPressed(false), + onPointerLeave: () => setPressed(false), + style: { + ...fills[variant], + width: size, + height: size, + minWidth: 'var(--tap-min)', + minHeight: 'var(--tap-min)', + borderRadius: 'var(--radius-pill)', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + padding: 0, + opacity: pressed ? 0.7 : 1, + transition: 'opacity var(--dur-press) var(--ease-out)', + ...style + } + }, rest), /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: icon, + size: Math.round(size * 0.4) + })); +} +Object.assign(__ds_scope, { IconButton }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/IconButton.jsx", error: String((e && e.message) || e) }); } + +// components/core/Logo.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +const FILES = { + wordmark: 'logo.png', + stacked: 'logo-stacked.png', + 'stacked-dark': 'logo-stacked-dark.png', + lettermark: 'icon-lettermark.png', + combo: 'icon-combo.png', + appicon: 'app-icon-1024.png' +}; + +/** Renders supplied BetterFit artwork. Never redraw or retype the mark. */ +function Logo({ + variant = 'wordmark', + height = 40, + assetBase = 'assets', + style, + ...rest +}) { + return /*#__PURE__*/React.createElement("img", _extends({ + src: `${assetBase}/${FILES[variant] || FILES.wordmark}`, + alt: "BetterFit", + style: { + height, + width: 'auto', + display: 'block', + ...style + } + }, rest)); +} +Object.assign(__ds_scope, { Logo }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/Logo.jsx", error: String((e && e.message) || e) }); } + +// components/core/MetricPill.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Small labelled metric capsule. Weekly counts, durations, ranges. */ +function MetricPill({ + label, + value, + icon, + tone = 'neutral', + style, + ...rest +}) { + const tones = { + neutral: { + color: 'var(--text-secondary)', + background: 'var(--surface-raised)', + border: '1px solid var(--border)' + }, + accent: { + color: 'var(--accent-text)', + background: 'var(--accent-surface)', + border: '1px solid transparent' + }, + success: { + color: 'var(--text-primary)', + background: 'var(--success-surface)', + border: '1px solid transparent' + } + }; + return /*#__PURE__*/React.createElement("span", _extends({ + style: { + ...tones[tone], + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '6px 10px', + borderRadius: 'var(--radius-pill)', + fontSize: 'var(--text-caption)', + fontWeight: 'var(--weight-semibold)', + ...style + } + }, rest), icon && /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: icon, + size: 12 + }), label && /*#__PURE__*/React.createElement("span", null, label), value != null && /*#__PURE__*/React.createElement("span", { + className: "bf-num" + }, value)); +} +Object.assign(__ds_scope, { MetricPill }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/MetricPill.jsx", error: String((e && e.message) || e) }); } + +// components/core/PhotoSlot.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** + * Placeholder for exercise / editorial photography. BetterFit ships no image + * assets, so every photo position renders as a labelled slot until real + * artwork is supplied. Pass `src` once you have it. + */ +function PhotoSlot({ + src, + alt = '', + width = 168, + height = 240, + radius = 'var(--radius-md)', + label, + style, + ...rest +}) { + if (src) { + return /*#__PURE__*/React.createElement("img", _extends({ + src: src, + alt: alt, + style: { + width, + height, + objectFit: 'cover', + borderRadius: radius, + display: 'block', + ...style + } + }, rest)); + } + return /*#__PURE__*/React.createElement("div", _extends({ + role: "img", + "aria-label": alt || 'Photography placeholder', + style: { + width, + height, + borderRadius: radius, + background: 'var(--surface-raised)', + border: '1px dashed var(--border-strong)', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + padding: 8, + textAlign: 'center', + ...style + } + }, rest), /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: "image", + size: 18, + color: "var(--text-tertiary)" + }), label && /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 'var(--text-caption)', + color: 'var(--text-tertiary)', + lineHeight: 1.2 + } + }, label)); +} +Object.assign(__ds_scope, { PhotoSlot }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/PhotoSlot.jsx", error: String((e && e.message) || e) }); } + +// components/core/SectionHeader.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Uppercased tracked section marker with an optional trailing action. */ +function SectionHeader({ + title, + action, + style, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + style: { + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + gap: 12, + ...style + } + }, rest), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 'var(--text-caption)', + fontWeight: 'var(--weight-semibold)', + textTransform: 'uppercase', + letterSpacing: 'var(--tracking-label)', + color: 'var(--text-secondary)' + } + }, title), action); +} +Object.assign(__ds_scope, { SectionHeader }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/core/SectionHeader.jsx", error: String((e && e.message) || e) }); } + +// components/data/ContributionHeatmap.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +const RAMP = ['var(--heat-0)', 'var(--heat-1)', 'var(--heat-2)', 'var(--heat-3)', 'var(--heat-4)']; + +/** Week-column activity heatmap. `values` is one intensity (0–4) per day, oldest first. */ +function ContributionHeatmap({ + values = [], + weeks = 26, + cell = 10, + gap = 3, + style, + ...rest +}) { + const total = weeks * 7; + const days = values.length >= total ? values.slice(-total) : [...Array(total - values.length).fill(0), ...values]; + const cols = []; + for (let w = 0; w < weeks; w++) cols.push(days.slice(w * 7, w * 7 + 7)); + return /*#__PURE__*/React.createElement("div", _extends({ + style: { + display: 'flex', + gap, + ...style + } + }, rest), cols.map((col, i) => /*#__PURE__*/React.createElement("div", { + key: i, + style: { + display: 'grid', + gap, + gridTemplateRows: `repeat(7, ${cell}px)` + } + }, col.map((v, j) => /*#__PURE__*/React.createElement("div", { + key: j, + title: `${v} workouts`, + style: { + width: cell, + height: cell, + borderRadius: 2, + background: RAMP[Math.max(0, Math.min(4, v))] + } + }))))); +} +Object.assign(__ds_scope, { ContributionHeatmap }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/data/ContributionHeatmap.jsx", error: String((e && e.message) || e) }); } + +// components/data/Gauge.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Semi-circular gauge. Reads as a conclusion, not a chart. */ +function Gauge({ + progress = 0, + width = 160, + thickness = 12, + tint = 'var(--accent)', + label, + value, + style, + ...rest +}) { + const p = Math.max(0, Math.min(1, progress)); + const r = (width - thickness) / 2; + const len = Math.PI * r; + const h = width / 2 + thickness / 2; + return /*#__PURE__*/React.createElement("div", _extends({ + style: { + width, + ...style + } + }, rest), /*#__PURE__*/React.createElement("svg", { + width: width, + height: h, + viewBox: `0 0 ${width} ${h}` + }, /*#__PURE__*/React.createElement("path", { + d: `M ${thickness / 2} ${h - thickness / 2} A ${r} ${r} 0 0 1 ${width - thickness / 2} ${h - thickness / 2}`, + fill: "none", + stroke: "var(--surface-raised)", + strokeWidth: thickness, + strokeLinecap: "round" + }), /*#__PURE__*/React.createElement("path", { + d: `M ${thickness / 2} ${h - thickness / 2} A ${r} ${r} 0 0 1 ${width - thickness / 2} ${h - thickness / 2}`, + fill: "none", + stroke: tint, + strokeWidth: thickness, + strokeLinecap: "round", + strokeDasharray: len, + strokeDashoffset: len * (1 - p), + style: { + transition: 'stroke-dashoffset var(--dur-slow) var(--ease-snappy)' + } + })), /*#__PURE__*/React.createElement("div", { + style: { + textAlign: 'center', + marginTop: -8 + } + }, value != null && /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + fontSize: 'var(--text-stat-lg)', + fontWeight: 'var(--weight-bold)' + } + }, value), label && /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-caption)', + color: 'var(--text-secondary)' + } + }, label))); +} +Object.assign(__ds_scope, { Gauge }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/data/Gauge.jsx", error: String((e && e.message) || e) }); } + +// components/data/LegendDot.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Colour key for a split or chart series. */ +function LegendDot({ + label, + percent, + color = 'var(--accent)', + style, + ...rest +}) { + return /*#__PURE__*/React.createElement("span", _extends({ + style: { + display: 'inline-flex', + alignItems: 'center', + gap: 6, + fontSize: 'var(--text-caption)', + color: 'var(--text-secondary)', + ...style + } + }, rest), /*#__PURE__*/React.createElement("span", { + style: { + width: 8, + height: 8, + borderRadius: 'var(--radius-pill)', + background: color + } + }), /*#__PURE__*/React.createElement("span", null, label), percent != null && /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + color: 'var(--text-primary)', + fontWeight: 'var(--weight-semibold)' + } + }, percent, "%")); +} +Object.assign(__ds_scope, { LegendDot }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/data/LegendDot.jsx", error: String((e && e.message) || e) }); } + +// components/data/MuscleChip.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Muscle group + share of the session, with a proportional bar. */ +function MuscleChip({ + muscle, + percent = 0, + tint = 'var(--accent)', + style, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + style: { + flex: 1, + minWidth: 72, + padding: 12, + borderRadius: 'var(--radius-md)', + background: 'var(--surface)', + border: '1px solid var(--border)', + ...style + } + }, rest), /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + fontSize: 'var(--text-stat-sm)', + fontWeight: 'var(--weight-bold)' + } + }, percent, "%"), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-caption)', + color: 'var(--text-secondary)', + marginTop: 2, + marginBottom: 8 + } + }, muscle), /*#__PURE__*/React.createElement("div", { + style: { + height: 4, + borderRadius: 2, + background: 'var(--surface-raised)', + overflow: 'hidden' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + width: `${Math.max(0, Math.min(100, percent))}%`, + height: '100%', + background: tint, + transition: 'width var(--dur-standard) var(--ease-out)' + } + }))); +} +Object.assign(__ds_scope, { MuscleChip }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/data/MuscleChip.jsx", error: String((e && e.message) || e) }); } + +// components/data/OverviewStat.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Inline icon + value + caption. Sits in a row under a headline, no container. */ +function OverviewStat({ + icon, + value, + label, + tint = 'var(--accent-text)', + style, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + style: { + display: 'flex', + flexDirection: 'column', + gap: 2, + ...style + } + }, rest), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + gap: 6 + } + }, icon && /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: icon, + size: 13, + color: tint + }), /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 'var(--text-stat-sm)', + fontWeight: 'var(--weight-semibold)' + } + }, value)), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 'var(--text-caption)', + color: 'var(--text-secondary)' + } + }, label)); +} +Object.assign(__ds_scope, { OverviewStat }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/data/OverviewStat.jsx", error: String((e && e.message) || e) }); } + +// components/data/ProgressRing.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Flat progress ring — solid track, accent fill, round caps. */ +function ProgressRing({ + progress = 0, + size = 86, + lineWidth = 10, + tint = 'var(--accent)', + children, + style, + ...rest +}) { + const p = Math.max(0, Math.min(1, progress)); + const r = (size - lineWidth) / 2; + const c = 2 * Math.PI * r; + return /*#__PURE__*/React.createElement("div", _extends({ + style: { + position: 'relative', + width: size, + height: size, + ...style + }, + role: "progressbar", + "aria-valuenow": Math.round(p * 100), + "aria-valuemin": 0, + "aria-valuemax": 100 + }, rest), /*#__PURE__*/React.createElement("svg", { + width: size, + height: size, + style: { + transform: 'rotate(-90deg)' + } + }, /*#__PURE__*/React.createElement("circle", { + cx: size / 2, + cy: size / 2, + r: r, + fill: "none", + stroke: "var(--surface-raised)", + strokeWidth: lineWidth + }), /*#__PURE__*/React.createElement("circle", { + cx: size / 2, + cy: size / 2, + r: r, + fill: "none", + stroke: tint, + strokeWidth: lineWidth, + strokeLinecap: "round", + strokeDasharray: c, + strokeDashoffset: c * (1 - p), + style: { + transition: 'stroke-dashoffset var(--dur-slow) var(--ease-snappy)' + } + })), /*#__PURE__*/React.createElement("div", { + style: { + position: 'absolute', + inset: 0, + display: 'grid', + placeItems: 'center' + } + }, children)); +} +Object.assign(__ds_scope, { ProgressRing }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/data/ProgressRing.jsx", error: String((e && e.message) || e) }); } + +// components/data/StatTile.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Icon + value + label tile. Used in grids for streaks, volume, recovery. */ +function StatTile({ + icon, + value, + label, + tint = 'var(--accent-text)', + style, + ...rest +}) { + return /*#__PURE__*/React.createElement(__ds_scope.Card, _extends({ + padding: 12, + style: { + display: 'flex', + flexDirection: 'column', + gap: 8, + ...style + } + }, rest), icon && /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: icon, + size: 16, + color: tint + }), /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + fontSize: 'var(--text-stat-md)', + fontWeight: 'var(--weight-bold)' + } + }, value), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-caption)', + color: 'var(--text-secondary)' + } + }, label)); +} +Object.assign(__ds_scope, { StatTile }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/data/StatTile.jsx", error: String((e && e.message) || e) }); } + +// components/feedback/Banner.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +const TONES = { + info: { + border: 'var(--info)', + surface: 'var(--info-surface)', + icon: 'info', + ink: 'var(--accent-text)' + }, + success: { + border: 'var(--success)', + surface: 'var(--success-surface)', + icon: 'check-circle', + ink: 'var(--accent-text)' + }, + warning: { + border: 'var(--warning)', + surface: 'var(--warning-surface)', + icon: 'warning', + ink: 'var(--accent-text)' + }, + danger: { + border: 'var(--danger)', + surface: 'var(--danger-surface)', + icon: 'warning-octagon', + ink: 'var(--text-primary)' + } +}; + +/** Inline notice with an optional action. Connection prompts, sync errors, plan changes. */ +function Banner({ + tone = 'info', + icon, + title, + message, + action, + onDismiss, + style, + ...rest +}) { + const t = TONES[tone]; + return /*#__PURE__*/React.createElement("div", _extends({ + style: { + display: 'flex', + gap: 12, + alignItems: 'flex-start', + padding: 14, + background: t.surface, + border: `1px solid color-mix(in srgb, ${t.border} 40%, transparent)`, + borderRadius: 'var(--radius-card)', + ...style + } + }, rest), /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: icon || t.icon, + size: 18, + color: t.ink + }), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + minWidth: 0 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-subheadline)', + fontWeight: 'var(--weight-semibold)' + } + }, title), message && /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)', + marginTop: 2 + } + }, message), action && /*#__PURE__*/React.createElement("div", { + style: { + marginTop: 10 + } + }, action)), onDismiss && /*#__PURE__*/React.createElement("button", { + type: "button", + "aria-label": "Dismiss", + onClick: onDismiss, + style: { + background: 'none', + border: 'none', + color: 'var(--text-tertiary)', + cursor: 'pointer', + padding: 0 + } + }, /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: "x", + size: 14, + weight: "bold" + }))); +} +Object.assign(__ds_scope, { Banner }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/feedback/Banner.jsx", error: String((e && e.message) || e) }); } + +// components/feedback/EmptyState.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Explains what will appear here and offers the next step. */ +function EmptyState({ + icon = 'list-checks', + title, + message, + action, + style, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + style: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 12, + padding: '32px 20px', + textAlign: 'center', + ...style + } + }, rest), /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: icon, + size: 36, + color: "var(--text-tertiary)" + }), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-headline)', + fontWeight: 'var(--weight-semibold)' + } + }, title), message && /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-subheadline)', + color: 'var(--text-secondary)', + maxWidth: 320 + } + }, message), action); +} +Object.assign(__ds_scope, { EmptyState }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/feedback/EmptyState.jsx", error: String((e && e.message) || e) }); } + +// components/feedback/RecoveryDot.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +const RECOVERY_COLORS = { + recovered: 'var(--recovery-recovered)', + fresh: 'var(--recovery-fresh)', + fatigued: 'var(--recovery-fatigued)', + sore: 'var(--recovery-sore)' +}; + +/** Small coloured dot for a region's recovery status. */ +function RecoveryDot({ + status = 'recovered', + size = 10, + style, + ...rest +}) { + return /*#__PURE__*/React.createElement("span", _extends({ + "aria-label": `Recovery: ${status}`, + style: { + width: size, + height: size, + borderRadius: 'var(--radius-pill)', + background: RECOVERY_COLORS[status], + display: 'inline-block', + ...style + } + }, rest)); +} +Object.assign(__ds_scope, { RECOVERY_COLORS, RecoveryDot }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/feedback/RecoveryDot.jsx", error: String((e && e.message) || e) }); } + +// components/feedback/RecoveryBadge.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +const LABEL = { + recovered: 'Recovered', + fresh: 'Fresh', + fatigued: 'Fatigued', + sore: 'Sore' +}; + +/** Tinted capsule stating a recovery status in words. */ +function RecoveryBadge({ + status = 'recovered', + style, + ...rest +}) { + const c = __ds_scope.RECOVERY_COLORS[status]; + return /*#__PURE__*/React.createElement("span", _extends({ + style: { + color: 'var(--text-primary)', + background: `color-mix(in srgb, ${c} 22%, transparent)`, + border: `1px solid color-mix(in srgb, ${c} 45%, transparent)`, + padding: '3px 10px', + borderRadius: 'var(--radius-pill)', + display: 'inline-flex', + alignItems: 'center', + gap: 6, + fontSize: 'var(--text-caption)', + fontWeight: 'var(--weight-semibold)', + ...style + } + }, rest), /*#__PURE__*/React.createElement("span", { + style: { + width: 7, + height: 7, + borderRadius: 'var(--radius-pill)', + background: c + } + }), LABEL[status]); +} +Object.assign(__ds_scope, { RecoveryBadge }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/feedback/RecoveryBadge.jsx", error: String((e && e.message) || e) }); } + +// components/feedback/RestTimerBar.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Sticky rest countdown with add-time and skip. Digits are tabular so they don't jitter. */ +function RestTimerBar({ + remaining = 90, + total = 90, + onAdd, + onSkip, + style, + ...rest +}) { + const mm = String(Math.floor(Math.max(0, remaining) / 60)).padStart(2, '0'); + const ss = String(Math.max(0, remaining) % 60).padStart(2, '0'); + const pct = total > 0 ? Math.max(0, Math.min(1, remaining / total)) : 0; + return /*#__PURE__*/React.createElement("div", _extends({ + style: { + background: 'var(--bg-elevated)', + borderBottom: '1px solid var(--border)', + ...style + } + }, rest), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '12px 20px' + } + }, /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: "timer", + size: 18, + color: "var(--accent)" + }), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-caption)', + color: 'var(--text-secondary)', + textTransform: 'uppercase', + letterSpacing: 'var(--tracking-label)', + fontWeight: 'var(--weight-semibold)' + } + }, "Rest"), /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + fontFamily: 'var(--font-mono)', + fontSize: 'var(--text-stat-md)', + fontWeight: 'var(--weight-bold)' + } + }, mm, ":", ss)), /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onAdd, + style: { + background: 'var(--surface-raised)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + borderRadius: 'var(--radius-pill)', + padding: '8px 14px', + fontSize: 'var(--text-footnote)', + fontWeight: 'var(--weight-semibold)', + cursor: 'pointer' + } + }, "+15s"), /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onSkip, + style: { + background: 'transparent', + border: 'none', + color: 'var(--accent-text)', + fontSize: 'var(--text-footnote)', + fontWeight: 'var(--weight-semibold)', + cursor: 'pointer', + padding: '8px 4px' + } + }, "Skip")), /*#__PURE__*/React.createElement("div", { + style: { + height: 3, + background: 'var(--surface-raised)' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + width: `${pct * 100}%`, + height: '100%', + background: 'var(--accent)', + transition: 'width 1s linear' + } + }))); +} +Object.assign(__ds_scope, { RestTimerBar }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/feedback/RestTimerBar.jsx", error: String((e && e.message) || e) }); } + +// components/lists/ExerciseRow.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Timeline row for a planned exercise: index marker, name, prescription, state. */ +function ExerciseRow({ + index, + name, + prescription, + muscles, + state = 'planned', + isLast = false, + onClick, + style, + ...rest +}) { + const done = state === 'done'; + const active = state === 'active'; + const markerBg = done ? 'var(--surface-raised)' : active ? 'var(--accent)' : 'var(--surface-raised)'; + const markerFg = active ? 'var(--text-on-accent)' : 'var(--text-secondary)'; + return /*#__PURE__*/React.createElement("div", _extends({ + onClick: onClick, + style: { + display: 'flex', + gap: 12, + cursor: onClick ? 'pointer' : 'default', + ...style + } + }, rest), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + flex: '0 0 28px' + } + }, /*#__PURE__*/React.createElement("span", { + style: { + width: 28, + height: 28, + borderRadius: 'var(--radius-pill)', + background: markerBg, + color: markerFg, + display: 'grid', + placeItems: 'center', + fontSize: 'var(--text-caption)', + fontWeight: 'var(--weight-bold)', + border: active ? '1px solid transparent' : '1px solid var(--border)' + }, + className: "bf-num" + }, done ? /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: "check", + size: 13, + weight: "bold", + color: "var(--accent-text)" + }) : index), !isLast && /*#__PURE__*/React.createElement("span", { + style: { + flex: 1, + width: 1, + background: 'var(--separator)', + marginTop: 4, + minHeight: 24 + } + })), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + minWidth: 0, + paddingBottom: isLast ? 0 : 16 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-subheadline)', + fontWeight: 'var(--weight-semibold)', + color: done ? 'var(--text-secondary)' : 'var(--text-primary)', + textDecoration: done ? 'line-through' : 'none' + } + }, name), prescription && /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)', + marginTop: 2 + } + }, prescription), muscles && /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-caption)', + color: 'var(--text-tertiary)', + marginTop: 4 + } + }, muscles)), active && /*#__PURE__*/React.createElement("span", { + style: { + alignSelf: 'flex-start', + fontSize: 'var(--text-caption)', + fontWeight: 'var(--weight-semibold)', + color: 'var(--accent-text)', + textTransform: 'uppercase', + letterSpacing: 'var(--tracking-label)' + } + }, "Now")); +} +Object.assign(__ds_scope, { ExerciseRow }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/lists/ExerciseRow.jsx", error: String((e && e.message) || e) }); } + +// components/lists/ListRow.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Circular tinted icon + title + subtitle + optional trailing content. */ +function ListRow({ + icon, + title, + subtitle, + iconTint = 'var(--accent-text)', + trailing, + onClick, + style, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + onClick: onClick, + role: onClick ? 'button' : undefined, + style: { + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '8px 0', + minHeight: 'var(--tap-min)', + cursor: onClick ? 'pointer' : 'default', + ...style + } + }, rest), icon && /*#__PURE__*/React.createElement("span", { + style: { + width: 44, + height: 44, + flex: '0 0 44px', + borderRadius: 'var(--radius-pill)', + background: 'color-mix(in srgb, ' + iconTint + ' 15%, transparent)', + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: icon, + size: 18, + color: iconTint + })), /*#__PURE__*/React.createElement("div", { + style: { + minWidth: 0, + flex: 1 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-subheadline)', + fontWeight: 'var(--weight-semibold)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' + } + }, title), subtitle && /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)', + marginTop: 2 + } + }, subtitle)), trailing); +} +Object.assign(__ds_scope, { ListRow }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/lists/ListRow.jsx", error: String((e && e.message) || e) }); } + +// components/lists/ChevronRow.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Navigation row — a ListRow that always ends in a chevron. */ +function ChevronRow({ + icon, + title, + subtitle, + iconTint, + value, + onClick, + style, + ...rest +}) { + return /*#__PURE__*/React.createElement(__ds_scope.ListRow, _extends({ + icon: icon, + title: title, + subtitle: subtitle, + iconTint: iconTint, + onClick: onClick, + style: style, + trailing: /*#__PURE__*/React.createElement("span", { + style: { + display: 'inline-flex', + alignItems: 'center', + gap: 8, + color: 'var(--text-tertiary)' + } + }, value && /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)' + } + }, value), /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: "caret-right", + size: 13, + weight: "bold" + })) + }, rest)); +} +Object.assign(__ds_scope, { ChevronRow }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/lists/ChevronRow.jsx", error: String((e && e.message) || e) }); } + +// components/lists/SettingsRow.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Settings row in four shapes: toggle, radio, static value, external link. */ +function SettingsRow({ + kind = 'static', + icon, + title, + subtitle, + value, + checked = false, + onChange, + onClick, + style, + ...rest +}) { + const interactive = kind === 'toggle' || kind === 'radio' || kind === 'link'; + const handle = () => { + if (kind === 'toggle' || kind === 'radio') onChange && onChange(!checked);else if (onClick) onClick(); + }; + return /*#__PURE__*/React.createElement("div", _extends({ + onClick: interactive ? handle : onClick, + style: { + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '12px 0', + minHeight: 'var(--tap-min)', + cursor: interactive || onClick ? 'pointer' : 'default', + ...style + } + }, rest), icon && /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: icon, + size: 17, + color: "var(--text-secondary)", + style: { + flex: '0 0 20px' + } + }), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + minWidth: 0 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-body)' + } + }, title), subtitle && /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)', + marginTop: 2 + } + }, subtitle)), kind === 'toggle' && /*#__PURE__*/React.createElement("span", { + role: "switch", + "aria-checked": checked, + style: { + width: 46, + height: 28, + flex: '0 0 46px', + borderRadius: 'var(--radius-pill)', + padding: 2, + background: checked ? 'var(--accent)' : 'var(--surface-raised)', + border: checked ? '1px solid transparent' : '1px solid var(--border)', + display: 'flex', + justifyContent: checked ? 'flex-end' : 'flex-start', + transition: 'background var(--dur-fast) var(--ease-out)' + } + }, /*#__PURE__*/React.createElement("span", { + style: { + width: 24, + height: 24, + borderRadius: 'var(--radius-pill)', + background: checked ? 'var(--bf-black)' : 'var(--bg-elevated)', + border: checked ? '1px solid transparent' : '1px solid var(--border)' + } + })), kind === 'radio' && checked && /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: "check", + size: 16, + weight: "bold", + color: "var(--accent-text)" + }), kind === 'static' && value && /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 'var(--text-subheadline)', + color: 'var(--text-secondary)' + } + }, value), kind === 'link' && /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: "arrow-up-right", + size: 14, + weight: "bold", + color: "var(--text-tertiary)" + })); +} +Object.assign(__ds_scope, { SettingsRow }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/lists/SettingsRow.jsx", error: String((e && e.message) || e) }); } + +// components/workout/SetLogField.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Labelled numeric field for logging a set. Big tabular value, unit suffix. */ +function SetLogField({ + label, + unit, + value, + onChange, + style, + ...rest +}) { + return /*#__PURE__*/React.createElement("label", _extends({ + style: { + flex: 1, + minWidth: 0, + display: 'block', + background: 'var(--surface-raised)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-md)', + padding: '10px 14px', + ...style + } + }, rest), /*#__PURE__*/React.createElement("span", { + style: { + display: 'block', + fontSize: 'var(--text-caption)', + fontWeight: 'var(--weight-semibold)', + textTransform: 'uppercase', + letterSpacing: 'var(--tracking-label)', + color: 'var(--text-secondary)' + } + }, label), /*#__PURE__*/React.createElement("span", { + style: { + display: 'flex', + alignItems: 'baseline', + gap: 6 + } + }, /*#__PURE__*/React.createElement("input", { + value: value, + onChange: e => onChange && onChange(e.target.value), + inputMode: "decimal", + size: 1, + style: { + flex: 1, + width: '100%', + minWidth: 0, + background: 'transparent', + border: 'none', + outline: 'none', + padding: 0, + color: 'var(--text-primary)', + fontFamily: 'var(--font-mono)', + fontSize: 'var(--text-stat-lg)', + fontWeight: 'var(--weight-bold)', + fontVariantNumeric: 'tabular-nums' + } + }), unit && /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)' + } + }, unit))); +} +Object.assign(__ds_scope, { SetLogField }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/workout/SetLogField.jsx", error: String((e && e.message) || e) }); } + +// components/workout/SupersetIndicator.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Bracket label grouping exercises performed back to back. */ +function SupersetIndicator({ + rounds = 3, + label = 'Superset', + style, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + style: { + display: 'flex', + alignItems: 'center', + gap: 8, + ...style + } + }, rest), /*#__PURE__*/React.createElement("span", { + style: { + width: 2, + height: 16, + background: 'var(--accent)', + borderRadius: 1 + } + }), /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: "arrows-clockwise", + size: 12, + color: "var(--accent-text)" + }), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 'var(--text-caption)', + fontWeight: 'var(--weight-semibold)', + textTransform: 'uppercase', + letterSpacing: 'var(--tracking-label)', + color: 'var(--text-secondary)' + } + }, label, " \xB7 ", /*#__PURE__*/React.createElement("span", { + className: "bf-num" + }, rounds), " rounds"), /*#__PURE__*/React.createElement("span", { + style: { + flex: 1, + height: 1, + background: 'var(--separator)' + } + })); +} +Object.assign(__ds_scope, { SupersetIndicator }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/workout/SupersetIndicator.jsx", error: String((e && e.message) || e) }); } + +// components/workout/WeightUnitToggle.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** Two-option segmented control on a raised well. */ +function WeightUnitToggle({ + value = 'lb', + options = ['lb', 'kg'], + onChange, + style, + ...rest +}) { + return /*#__PURE__*/React.createElement("div", _extends({ + role: "group", + style: { + display: 'inline-flex', + gap: 2, + padding: 2, + borderRadius: 'var(--radius-pill)', + background: 'var(--surface-raised)', + border: '1px solid var(--border)', + ...style + } + }, rest), options.map(o => { + const on = o === value; + return /*#__PURE__*/React.createElement("button", { + key: o, + type: "button", + onClick: () => onChange && onChange(o), + "aria-pressed": on, + style: { + minHeight: 'var(--tap-min)', + padding: '0 18px', + borderRadius: 'var(--radius-pill)', + border: 'none', + cursor: 'pointer', + background: on ? 'var(--accent)' : 'transparent', + color: on ? 'var(--text-on-accent)' : 'var(--text-secondary)', + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-footnote)', + fontWeight: 'var(--weight-semibold)', + transition: 'background var(--dur-fast) var(--ease-out)' + } + }, o); + })); +} +Object.assign(__ds_scope, { WeightUnitToggle }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/workout/WeightUnitToggle.jsx", error: String((e && e.message) || e) }); } + +// components/workout/WorkoutCard.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +/** A suggested or scheduled session: name, focus, prescription, muscle summary. */ +function WorkoutCard({ + name, + focus, + exercises, + duration, + volume, + muscles = [], + tone = 'surface', + badge, + onClick, + style, + ...rest +}) { + const identity = tone === 'identity'; + return /*#__PURE__*/React.createElement("div", _extends({ + onClick: onClick, + style: { + background: identity ? 'var(--bf-yellow)' : 'var(--surface)', + color: identity ? 'var(--bf-black)' : 'var(--text-primary)', + border: identity ? '1px solid transparent' : '1px solid var(--border)', + borderRadius: 'var(--radius-card)', + padding: 16, + cursor: onClick ? 'pointer' : 'default', + display: 'flex', + flexDirection: 'column', + gap: 12, + ...style + } + }, rest), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 'var(--text-caption)', + fontWeight: 'var(--weight-semibold)', + textTransform: 'uppercase', + letterSpacing: 'var(--tracking-label)', + opacity: identity ? 0.7 : 1, + color: identity ? 'var(--bf-black)' : 'var(--text-secondary)' + } + }, focus), badge && /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 'var(--text-caption)', + fontWeight: 'var(--weight-semibold)', + padding: '4px 10px', + borderRadius: 'var(--radius-pill)', + background: identity ? 'rgba(0,0,0,.12)' : 'var(--surface-raised)', + border: identity ? '1px solid transparent' : '1px solid var(--border)' + } + }, badge)), /*#__PURE__*/React.createElement("div", { + className: "bf-display", + style: { + fontSize: 'var(--text-title-1)' + } + }, name), /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + display: 'flex', + gap: 16, + fontSize: 'var(--text-footnote)', + opacity: identity ? 0.8 : 1, + color: identity ? 'var(--bf-black)' : 'var(--text-secondary)' + } + }, exercises != null && /*#__PURE__*/React.createElement("span", { + style: { + display: 'inline-flex', + gap: 5, + alignItems: 'center' + } + }, /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: "list-checks", + size: 13 + }), exercises, " exercises"), duration && /*#__PURE__*/React.createElement("span", { + style: { + display: 'inline-flex', + gap: 5, + alignItems: 'center' + } + }, /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: "clock", + size: 13 + }), duration), volume && /*#__PURE__*/React.createElement("span", { + style: { + display: 'inline-flex', + gap: 5, + alignItems: 'center' + } + }, /*#__PURE__*/React.createElement(__ds_scope.Icon, { + name: "barbell", + size: 13 + }), volume)), muscles.length > 0 && /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + flexWrap: 'wrap', + gap: 6 + } + }, muscles.map(m => /*#__PURE__*/React.createElement("span", { + key: m, + style: { + fontSize: 'var(--text-caption)', + padding: '4px 8px', + borderRadius: 'var(--radius-sm)', + background: identity ? 'rgba(0,0,0,.1)' : 'var(--surface-raised)', + color: identity ? 'var(--bf-black)' : 'var(--text-secondary)' + } + }, m)))); +} +Object.assign(__ds_scope, { WorkoutCard }); +})(); } catch (e) { __ds_ns.__errors.push({ path: "components/workout/WorkoutCard.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/AddExerciseSheet.jsx +try { (() => { +function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } +const { + Icon, + Card, + Divider, + Chip, + PhotoSlot +} = dsPick(['Icon', 'Card', 'Divider', 'Chip', 'PhotoSlot']); +const CATEGORIES = [{ + icon: 'barbell', + title: 'All exercises' +}, { + icon: 'clock-counter-clockwise', + title: 'Recently added' +}, { + icon: 'user', + title: 'Added by you' +}, { + icon: 'person-simple-run', + title: 'By muscle group' +}, { + icon: 'dumbbell', + title: 'By equipment' +}, { + icon: 'scales', + title: 'Weighted' +}, { + icon: 'hand-fist', + title: 'Bodyweight only' +}, { + icon: 'heartbeat', + title: 'Cardio' +}]; +const BY_MUSCLE = [{ + title: 'Lats', + subtitle: '32 exercises' +}, { + title: 'Biceps', + subtitle: '24 exercises' +}, { + title: 'Chest', + subtitle: '41 exercises' +}, { + title: 'Shoulders', + subtitle: '38 exercises' +}, { + title: 'Quads', + subtitle: '29 exercises' +}, { + title: 'Core', + subtitle: '35 exercises' +}]; +const ALL = [{ + title: 'Lat Pulldown', + subtitle: 'Lats · Cable' +}, { + title: 'Cable Row', + subtitle: 'Lats · Cable' +}, { + title: 'Hammer Curls', + subtitle: 'Biceps · Dumbbell' +}, { + title: 'Barbell Curl', + subtitle: 'Biceps · Barbell' +}, { + title: 'Bench Press', + subtitle: 'Chest · Barbell' +}, { + title: 'Overhead Press', + subtitle: 'Shoulders · Barbell' +}, { + title: 'Cable Wood Chop', + subtitle: 'Core · Cable' +}]; +function PickerRow({ + icon, + title, + subtitle, + selected, + onToggle, + showThumb, + inPlan +}) { + return /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: inPlan ? undefined : onToggle, + "aria-pressed": onToggle ? selected || inPlan : undefined, + disabled: inPlan, + style: { + width: '100%', + background: 'none', + border: 'none', + textAlign: 'left', + cursor: inPlan ? 'default' : 'pointer', + opacity: inPlan ? 0.55 : 1, + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '10px 0', + minHeight: 'var(--tap-min)', + color: 'var(--text-primary)', + fontFamily: 'var(--font-ui)' + } + }, showThumb ? /*#__PURE__*/React.createElement(PhotoSlot, { + width: 44, + height: 44, + radius: "10px", + label: "", + alt: `${title} demonstration` + }) : /*#__PURE__*/React.createElement(Icon, { + name: icon, + size: 20, + color: "var(--text-secondary)", + style: { + flex: '0 0 24px' + } + }), /*#__PURE__*/React.createElement("span", { + style: { + flex: 1, + minWidth: 0 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + display: 'block', + fontSize: 'var(--text-body)' + } + }, title), (inPlan || subtitle) && /*#__PURE__*/React.createElement("span", { + style: { + display: 'block', + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)', + marginTop: 2 + } + }, inPlan ? 'Already in this workout' : subtitle)), onToggle ? /*#__PURE__*/React.createElement("span", { + style: { + width: 26, + height: 26, + flex: '0 0 26px', + borderRadius: 'var(--radius-pill)', + background: selected || inPlan ? 'var(--accent)' : 'transparent', + border: selected || inPlan ? '1px solid transparent' : '1px solid var(--border-strong)', + display: 'grid', + placeItems: 'center' + } + }, (selected || inPlan) && /*#__PURE__*/React.createElement(Icon, { + name: "check", + size: 14, + weight: "bold", + color: "var(--bf-black)" + })) : /*#__PURE__*/React.createElement(Icon, { + name: "caret-right", + size: 13, + weight: "bold", + color: "var(--text-tertiary)" + })); +} + +/** Full-height sheet for adding exercises to the current plan. */ +function AddExerciseSheet({ + onClose, + onAdd, + inPlan = [] +}) { + const [scope, setScope] = React.useState('All'); + const [query, setQuery] = React.useState(''); + const [picked, setPicked] = React.useState([]); + const [group, setGroup] = React.useState(false); + const toggle = title => { + if (inPlan.includes(title)) return; + setPicked(p => p.includes(title) ? p.filter(t => t !== title) : [...p, title]); + }; + const list = scope === 'Categories' ? CATEGORIES : scope === 'By muscle' ? BY_MUSCLE : ALL; + const rows = list.filter(r => r.title.toLowerCase().includes(query.toLowerCase())); + const selectable = scope === 'All'; + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StatusBar, null), /*#__PURE__*/React.createElement(NavBar, { + title: "Add exercise", + leading: /*#__PURE__*/React.createElement("button", { + type: "button", + "aria-label": "Filters", + style: { + width: 'var(--tap-min)', + height: 'var(--tap-min)', + borderRadius: 'var(--radius-pill)', + cursor: 'pointer', + background: 'transparent', + border: '1px solid var(--border)', + color: 'var(--accent-text)', + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "sliders-horizontal", + size: 15 + })), + trailing: /*#__PURE__*/React.createElement("button", { + type: "button", + "aria-label": "Close", + onClick: onClose, + style: { + width: 'var(--tap-min)', + height: 'var(--tap-min)', + borderRadius: 'var(--radius-pill)', + cursor: 'pointer', + background: 'transparent', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "x", + size: 15, + weight: "bold" + })) + }), /*#__PURE__*/React.createElement("div", { + style: { + flex: '0 0 auto', + padding: '0 20px 12px' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + gap: 10, + background: 'var(--surface-raised)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-md)', + padding: '0 12px', + height: 44 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "magnifying-glass", + size: 16, + color: "var(--text-tertiary)" + }), /*#__PURE__*/React.createElement("input", { + value: query, + onChange: e => setQuery(e.target.value), + placeholder: "Search exercises", + style: { + flex: 1, + minWidth: 0, + alignSelf: 'stretch', + background: 'transparent', + border: 'none', + outline: 'none', + color: 'var(--text-primary)', + fontSize: 'var(--text-body)', + fontFamily: 'var(--font-ui)' + } + }), query && /*#__PURE__*/React.createElement("button", { + type: "button", + "aria-label": "Clear", + onClick: () => setQuery(''), + style: { + background: 'none', + border: 'none', + cursor: 'pointer', + color: 'var(--text-tertiary)', + padding: 0 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "x-circle", + size: 16 + })))), /*#__PURE__*/React.createElement("div", { + style: { + flex: '0 0 auto', + display: 'flex', + gap: 8, + padding: '0 20px 14px' + } + }, ['All', 'By muscle', 'Categories'].map(s => /*#__PURE__*/React.createElement(Chip, { + key: s, + label: s, + selected: scope === s, + onClick: () => setScope(s) + }))), /*#__PURE__*/React.createElement(Scroll, null, /*#__PURE__*/React.createElement(Card, { + padding: 12 + }, rows.map((r, i) => /*#__PURE__*/React.createElement("div", { + key: r.title + }, i > 0 && /*#__PURE__*/React.createElement(Divider, { + inset: selectable ? 56 : 36 + }), /*#__PURE__*/React.createElement(PickerRow, _extends({}, r, { + showThumb: selectable, + inPlan: selectable && inPlan.includes(r.title), + selected: picked.includes(r.title), + onToggle: selectable ? () => toggle(r.title) : undefined + }))))), /*#__PURE__*/React.createElement("div", { + style: { + height: 140 + } + })), /*#__PURE__*/React.createElement("div", { + style: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + padding: '28px 20px 20px', + background: 'linear-gradient(to top, var(--bg-page) 60%, transparent)' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + gap: 10, + alignItems: 'center' + } + }, /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: () => setGroup(g => !g), + "aria-pressed": group, + style: { + flex: '0 0 auto', + height: 'var(--control-lg)', + padding: '0 16px', + cursor: 'pointer', + borderRadius: 'var(--radius-button)', + background: group ? 'var(--surface-raised)' : 'transparent', + border: '1px solid var(--border-strong)', + color: 'var(--text-primary)', + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-footnote)', + fontWeight: 'var(--weight-semibold)', + display: 'inline-flex', + alignItems: 'center', + gap: 6 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "arrows-clockwise", + size: 14 + }), "Group as circuit"), /*#__PURE__*/React.createElement("button", { + type: "button", + disabled: picked.length === 0, + onClick: () => onAdd(picked.filter(p => !inPlan.includes(p))), + style: { + flex: 1, + height: 'var(--control-lg)', + border: 'none', + cursor: picked.length ? 'pointer' : 'not-allowed', + opacity: picked.length ? 1 : 0.4, + background: 'var(--accent)', + color: 'var(--bf-black)', + borderRadius: 'var(--radius-button)', + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-headline)', + fontWeight: 'var(--weight-semibold)' + } + }, picked.length ? `Add ${picked.length} exercise${picked.length > 1 ? 's' : ''}` : 'Add exercise')))); +} +Object.assign(window, { + AddExerciseSheet +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/AddExerciseSheet.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/AppShell.jsx +try { (() => { +const { + Icon, + Logo +} = window.BetterFitDesignSystem_6a70ea; + +/* ---- Tweak context ---- + Three cross-cutting choices the whole kit reads: how chrome sits over the page, + how tightly the ledger is packed, and how much yellow the app spends. */ +const BFT_DEFAULT = { + chrome: 'glass', + density: 'standard', + accent: 'balanced', + artwork: 'icon' +}; +const BFTweakCtx = React.createContext(BFT_DEFAULT); +const useBFT = () => React.useContext(BFTweakCtx); +const DENSITY = { + roomy: { + rowPy: 18, + gutter: 38, + sectPt: 34, + metaGap: 5, + title: 17 + }, + standard: { + rowPy: 14, + gutter: 32, + sectPt: 26, + metaGap: 3, + title: 17 + }, + compact: { + rowPy: 9, + gutter: 26, + sectPt: 18, + metaGap: 1, + title: 15 + } +}; +const TABS = [{ + id: 'workout', + label: 'Workout', + icon: 'barbell' +}, { + id: 'body', + label: 'Body', + icon: 'person-simple-run' +}, { + id: 'targets', + label: 'Targets', + icon: 'crosshair' +}, { + id: 'log', + label: 'Log', + icon: 'calendar-blank' +}]; +function StatusBar() { + return /*#__PURE__*/React.createElement("div", { + style: { + height: 48, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0 24px', + color: 'var(--text-primary)', + fontSize: 14, + fontWeight: 600, + flex: '0 0 48px' + } + }, /*#__PURE__*/React.createElement("span", { + className: "bf-num" + }, "9:41"), /*#__PURE__*/React.createElement("span", { + style: { + display: 'inline-flex', + gap: 5, + alignItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "cell-signal-full", + size: 14 + }), /*#__PURE__*/React.createElement(Icon, { + name: "wifi-high", + size: 14 + }), /*#__PURE__*/React.createElement(Icon, { + name: "battery-full", + size: 16 + }))); +} + +/** Floating glass nav, or an opaque bar welded to the layout — see the `chrome` tweak. */ +function TabBar({ + active, + onChange +}) { + const { + chrome + } = useBFT(); + const solid = chrome === 'solid'; + const compact = chrome === 'compact'; + const shell = solid ? { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + zIndex: 40 + } : { + position: 'absolute', + left: compact ? 60 : 12, + right: compact ? 60 : 12, + bottom: compact ? 16 : 12, + zIndex: 40 + }; + const inner = solid ? { + background: 'var(--bg-elevated)', + borderTop: '1px solid var(--border)', + borderRadius: 0, + padding: '6px 8px 22px', + boxShadow: 'none' + } : { + background: 'var(--glass-fill)', + WebkitBackdropFilter: 'var(--glass-blur)', + backdropFilter: 'var(--glass-blur)', + border: '1px solid var(--glass-border)', + borderRadius: 32, + padding: 6, + boxShadow: 'var(--glass-shadow), inset 0 1px 0 rgba(255,255,255,.16)' + }; + return /*#__PURE__*/React.createElement("div", { + style: shell + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'grid', + gridTemplateColumns: 'repeat(4,1fr)', + gap: 4, + ...inner + } + }, TABS.map(t => { + const on = t.id === active; + return /*#__PURE__*/React.createElement("button", { + key: t.id, + type: "button", + onClick: () => onChange(t.id), + "aria-label": t.label, + style: { + background: on && !solid ? 'var(--glass-highlight)' : 'transparent', + border: 'none', + cursor: 'pointer', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 3, + padding: compact ? '0' : '9px 0 7px', + borderRadius: 'var(--radius-pill)', + minHeight: 52, + color: on ? 'var(--accent-text)' : 'var(--text-tertiary)' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: t.icon, + size: 21 + }), !compact && /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 10, + fontWeight: 700, + letterSpacing: '0.01em' + } + }, t.label)); + }))); +} +function NavBar({ + title, + leading, + trailing +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + flex: '0 0 auto', + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '4px 20px 12px' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + flex: '0 0 auto', + minWidth: 44 + } + }, leading), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + textAlign: 'center', + fontSize: 'var(--text-headline)', + fontWeight: 'var(--weight-semibold)' + } + }, title), /*#__PURE__*/React.createElement("div", { + style: { + flex: '0 0 auto', + minWidth: 44, + display: 'flex', + justifyContent: 'flex-end' + } + }, trailing)); +} +function Phone({ + children, + scheme = 'dark', + tweaks = BFT_DEFAULT +}) { + const d = DENSITY[tweaks.density] || DENSITY.standard; + return /*#__PURE__*/React.createElement(BFTweakCtx.Provider, { + value: tweaks + }, /*#__PURE__*/React.createElement("div", { + "data-scheme": scheme, + style: { + width: 390, + height: 844, + background: 'var(--bg-page)', + color: 'var(--text-primary)', + borderRadius: 44, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + boxShadow: '0 30px 80px rgba(0,0,0,.55), 0 0 0 10px #1b1b1f', + position: 'relative', + fontFamily: 'var(--font-ui)', + '--l-row-py': d.rowPy + 'px', + '--l-gutter': d.gutter + 'px', + '--l-sect-pt': d.sectPt + 'px', + '--l-meta-gap': d.metaGap + 'px', + '--l-title': d.title + 'px' + } + }, children)); +} + +/** Non-Ledger screens scroll through here. Bottom padding clears the floating nav. */ +function Scroll({ + children, + pad = true +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + overflowY: 'auto', + overflowX: 'hidden', + padding: pad ? '0 20px 100px' : '0 0 100px', + scrollbarWidth: 'none', + msOverflowStyle: 'none' + } + }, children); +} + +/** Keeps one broken screen from blanking the whole kit. */ +class ScreenBoundary extends React.Component { + constructor(p) { + super(p); + this.state = { + error: null + }; + } + static getDerivedStateFromError(error) { + return { + error + }; + } + componentDidUpdate(prev) { + if (prev.screenKey !== this.props.screenKey && this.state.error) this.setState({ + error: null + }); + } + render() { + if (!this.state.error) return this.props.children; + return /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + display: 'grid', + placeItems: 'center', + padding: 24, + textAlign: 'center' + } + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-headline)', + fontWeight: 'var(--weight-semibold)' + } + }, "This screen didn't load"), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)', + marginTop: 6, + maxWidth: 260, + textWrap: 'pretty' + } + }, "Usually a stale ", /*#__PURE__*/React.createElement("code", null, "_ds_bundle.js"), ". Pick another screen below, or reload once the bundle recompiles."), /*#__PURE__*/React.createElement("div", { + style: { + fontFamily: 'var(--font-mono)', + fontSize: 11, + color: 'var(--text-tertiary)', + marginTop: 10 + } + }, String(this.state.error && this.state.error.message).slice(0, 120)))); + } +} + +/** Destructure DS components through this so a missing export degrades instead of throwing. */ +function dsPick(names) { + const NS = window.BetterFitDesignSystem_6a70ea || {}; + const out = {}; + for (const n of names) { + out[n] = NS[n] || function Missing() { + return /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 11, + fontFamily: 'var(--font-mono)', + color: 'var(--text-tertiary)' + } + }, "[", n, "]"); + }; + } + return out; +} + +/* ---- The Ledger: reimagined screen layout primitives ---- + Rules, not cards. A 46px mono gutter runs down every list. One squared + full-bleed yellow slab per screen carries the number that matters and the + single primary action, so nothing floats over the content. */ + +/** Uppercase micro label. */ +function Eyebrow({ + children, + style +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 11, + fontWeight: 'var(--weight-bold)', + textTransform: 'uppercase', + letterSpacing: '0.14em', + ...style + } + }, children); +} + +/** The screen's yellow moment: full bleed, squared, black ink. Goes neutral when the + `accent` tweak is restrained — the yellow then lives on the docked action instead. */ +function Slab({ + children, + style +}) { + const { + accent + } = useBFT(); + const quiet = accent === 'restrained'; + return /*#__PURE__*/React.createElement("div", { + style: { + background: quiet ? 'transparent' : 'var(--bf-yellow)', + color: quiet ? 'var(--text-primary)' : 'var(--bf-black)', + borderBottom: quiet ? '1px solid var(--separator)' : 'none', + padding: '18px 20px 20px', + ...style + } + }, children); +} + +/** Primary action, inverted so it reads on the yellow field. */ +function SlabAction({ + icon, + children, + onClick, + tone = 'ink' +}) { + const { + accent + } = useBFT(); + const onYellow = accent !== 'restrained'; + const ink = tone === 'ink'; + const fill = onYellow ? ink ? 'var(--bf-black)' : 'transparent' : 'var(--accent)'; + const label = onYellow ? ink ? 'var(--bf-yellow)' : 'var(--bf-black)' : 'var(--text-on-accent)'; + return /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onClick, + style: { + width: '100%', + height: 'var(--control-lg)', + marginTop: 16, + cursor: 'pointer', + border: onYellow && !ink ? '1.5px solid rgba(0,0,0,.28)' : 'none', + background: fill, + color: label, + borderRadius: 'var(--radius-button)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 9, + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-headline)', + fontWeight: 'var(--weight-semibold)' + } + }, icon && /*#__PURE__*/React.createElement(Icon, { + name: icon, + size: 16 + }), children); +} + +/** Mono spec strip — the two or three facts that qualify the slab's headline. */ +function SpecStrip({ + items, + style +}) { + return /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + display: 'flex', + gap: 18, + marginTop: 12, + ...style + } + }, items.map(it => /*#__PURE__*/React.createElement("div", { + key: it.label + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 22, + fontWeight: 'var(--weight-bold)', + lineHeight: 1 + } + }, it.value), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 10, + fontWeight: 'var(--weight-semibold)', + textTransform: 'uppercase', + letterSpacing: '0.1em', + opacity: 0.58, + marginTop: 5, + fontFamily: 'var(--font-ui)' + } + }, it.label)))); +} + +/** Section marker: label, then a hairline that runs to the trailing value. */ +function SectionRule({ + label, + trailing, + style +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + gap: 12, + padding: 'var(--l-sect-pt, 26px) 0 10px', + ...style + } + }, /*#__PURE__*/React.createElement(Eyebrow, { + style: { + color: 'var(--text-secondary)', + flex: '0 0 auto' + } + }, label), /*#__PURE__*/React.createElement("span", { + style: { + flex: 1, + height: 1, + background: 'var(--separator)' + } + }), trailing && /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 12, + color: 'var(--text-tertiary)', + flex: '0 0 auto' + } + }, trailing)); +} + +/** Tracks an <image-slot>'s `data-filled` attribute so a row can lay itself out + differently before and after the user drops a photo in. */ +function useSlotFilled() { + const [filled, setFilled] = React.useState(false); + const [el, setEl] = React.useState(null); + React.useEffect(() => { + if (!el) return; + const read = () => setFilled(el.hasAttribute('data-filled')); + read(); + const mo = new MutationObserver(read); + mo.observe(el, { + attributes: true, + attributeFilter: ['data-filled'] + }); + return () => mo.disconnect(); + }, [el]); + return [filled, setEl]; +} + +/** Rounded artwork tile that replaces the mono gutter when rows carry art. */ +function ArtTile({ + icon, + size = 38 +}) { + return /*#__PURE__*/React.createElement("span", { + style: { + flex: `0 0 ${size}px`, + width: size, + height: size, + borderRadius: 11, + background: 'var(--surface-raised)', + border: '1px solid var(--border)', + display: 'grid', + placeItems: 'center', + color: 'var(--accent-text)', + position: 'relative', + zIndex: 2 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: icon || 'barbell', + size: 19 + })); +} + +/** One ruled ledger row. `gutter` is the mono index/date column; `art` opts the row + into the `artwork` tweak — {icon, slot, label} for an icon tile or a photo bed. */ +function LedgerRow({ + gutter, + gutterAccent, + title, + titleIcon, + meta, + trailing, + trailingMeta, + accent, + dim, + chevron, + onClick, + art, + children +}) { + const { + artwork + } = useBFT(); + const [filled, slotRef] = useSlotFilled(); + const mode = art ? artwork : 'plain'; + const photo = mode === 'photo'; + const bed = photo && filled; + const Tag = onClick ? 'button' : 'div'; + const slot = photo ? /*#__PURE__*/React.createElement("image-slot", { + ref: slotRef, + id: art.slot, + shape: "rect", + fit: "cover", + placeholder: " " + }) : null; + const row = /*#__PURE__*/React.createElement(Tag, { + type: onClick ? 'button' : undefined, + onClick: onClick, + style: { + display: 'flex', + alignItems: photo || mode === 'icon' ? 'center' : 'flex-start', + gap: 14, + width: '100%', + textAlign: 'left', + padding: 'var(--l-row-py, 14px) 0', + minHeight: photo ? 78 : undefined, + borderTop: '1px solid var(--separator)', + background: 'none', + border: 'none', + borderTopWidth: 1, + borderTopStyle: 'solid', + borderTopColor: 'var(--separator)', + color: 'inherit', + font: 'inherit', + cursor: onClick ? 'pointer' : 'default', + position: 'relative', + opacity: dim ? 0.45 : 1, + fontFamily: 'var(--font-ui)' + } + }, accent && /*#__PURE__*/React.createElement("span", { + style: { + position: 'absolute', + left: -20, + top: 0, + bottom: 0, + width: 3, + background: 'var(--accent)', + zIndex: 3 + } + }), photo && (filled ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("span", { + "aria-hidden": "true", + style: { + position: 'absolute', + top: 1, + bottom: 0, + left: 0, + right: -20, + overflow: 'hidden', + zIndex: 0 + } + }, slot), /*#__PURE__*/React.createElement("span", { + "aria-hidden": "true", + style: { + position: 'absolute', + top: 1, + bottom: 0, + left: -20, + right: -20, + zIndex: 1, + pointerEvents: 'none', + background: 'linear-gradient(90deg, var(--bg-page) 0%, color-mix(in oklab, var(--bg-page) 88%, transparent) 46%, color-mix(in oklab, var(--bg-page) 58%, transparent) 100%)' + } + })) : /*#__PURE__*/React.createElement("span", { + style: { + position: 'absolute', + top: 1, + bottom: 0, + right: -20, + width: 152, + overflow: 'hidden', + zIndex: 0 + } + }, slot)), mode === 'icon' || photo ? /*#__PURE__*/React.createElement(ArtTile, { + icon: art.icon + }) : /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + flex: '0 0 var(--l-gutter, 32px)', + fontSize: 15, + fontWeight: 'var(--weight-bold)', + color: gutterAccent ? 'var(--accent-text)' : 'var(--text-tertiary)', + paddingTop: 2 + } + }, gutter), /*#__PURE__*/React.createElement("span", { + style: { + flex: 1, + minWidth: 0, + position: 'relative', + zIndex: 2, + marginRight: photo && !filled ? 148 : 0 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + display: 'flex', + alignItems: 'center', + gap: 8, + fontSize: 'var(--l-title, var(--text-body))', + fontWeight: 'var(--weight-semibold)', + textWrap: 'pretty' + } + }, titleIcon && /*#__PURE__*/React.createElement(Icon, { + name: titleIcon, + size: 16, + weight: "bold", + color: "var(--accent-text)" + }), title), (meta || photo && !filled && (trailing || trailingMeta)) && /*#__PURE__*/React.createElement("span", { + style: { + display: 'block', + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)', + marginTop: 'var(--l-meta-gap, 3px)' + } + }, photo && !filled && (trailing || trailingMeta) ? [trailing, trailingMeta, meta].filter(Boolean).join(' · ') : meta), children), (trailing || trailingMeta) && !(photo && !filled) && /*#__PURE__*/React.createElement("span", { + style: { + flex: '0 0 auto', + textAlign: 'right', + paddingTop: 1, + position: 'relative', + zIndex: 2, + ...(bed ? { + padding: '4px 8px', + marginRight: -4, + borderRadius: 8, + background: 'color-mix(in oklab, var(--bg-page) 78%, transparent)', + WebkitBackdropFilter: 'blur(10px)', + backdropFilter: 'blur(10px)' + } : null) + } + }, /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + display: 'block', + fontSize: 'var(--text-subheadline)', + fontWeight: 'var(--weight-semibold)' + } + }, trailing), trailingMeta && /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + display: 'block', + fontSize: 'var(--text-caption)', + color: 'var(--text-tertiary)', + marginTop: 3 + } + }, trailingMeta)), chevron && /*#__PURE__*/React.createElement(Icon, { + name: "caret-right", + size: 14, + weight: "bold", + color: "var(--text-tertiary)", + style: { + marginTop: 4, + position: 'relative', + zIndex: 2 + } + })); + return photo ? /*#__PURE__*/React.createElement("div", { + style: { + position: 'relative', + overflow: 'hidden' + } + }, row) : row; +} + +/** Conclusion first: the value, its label, then the sentence that interprets it. */ +function Readout({ + value, + unit, + label, + note, + style +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + padding: '10px 0 4px', + ...style + } + }, /*#__PURE__*/React.createElement(Eyebrow, { + style: { + color: 'var(--text-secondary)' + } + }, label), /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + display: 'flex', + alignItems: 'baseline', + gap: 6, + marginTop: 10 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 64, + fontWeight: 'var(--weight-bold)', + lineHeight: 0.9, + letterSpacing: '-0.03em' + } + }, value), unit && /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 22, + fontWeight: 'var(--weight-bold)', + color: 'var(--text-secondary)' + } + }, unit)), note && /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-subheadline)', + color: 'var(--text-secondary)', + marginTop: 12, + maxWidth: 300, + textWrap: 'pretty' + } + }, note)); +} + +/** Flat 4px bar on the yellow ladder. Never a ring. */ +function Bar({ + pct, + color = 'var(--accent)', + style +}) { + return /*#__PURE__*/React.createElement("span", { + style: { + display: 'block', + height: 4, + background: 'var(--surface-raised)', + borderRadius: 2, + overflow: 'hidden', + ...style + } + }, /*#__PURE__*/React.createElement("span", { + style: { + display: 'block', + height: '100%', + width: `${Math.max(2, Math.min(100, pct))}%`, + background: color + } + })); +} + +/** Trailing row that opens the exercise picker. Plus sits in the gutter. */ +function AddRow({ + onClick, + label = 'Add exercise' +}) { + return /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onClick, + style: { + display: 'flex', + alignItems: 'center', + gap: 14, + width: '100%', + padding: '16px 0', + borderTop: '1px solid var(--separator)', + background: 'none', + border: 'none', + borderTopWidth: 1, + borderTopStyle: 'solid', + borderTopColor: 'var(--separator)', + cursor: 'pointer', + color: 'var(--accent-text)', + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-body)', + fontWeight: 'var(--weight-semibold)' + } + }, /*#__PURE__*/React.createElement("span", { + style: { + flex: '0 0 var(--l-gutter, 32px)', + display: 'flex' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "plus", + size: 16, + weight: "bold" + })), label); +} + +/** Screen title line that sits above a slab or a readout. */ +function LedgerHead({ + title, + right +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + flex: '0 0 auto', + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '0 20px 14px' + } + }, /*#__PURE__*/React.createElement("span", { + className: "bf-display", + style: { + flex: 1, + fontSize: 26 + } + }, title), right); +} + +/** Neutral page header for ACTION screens — unless the `accent` tweak is bold, in which + case it takes the full yellow field and the docked action goes quiet. */ +function HeaderBlock({ + eyebrow, + title, + spec, + children, + style +}) { + const { + accent + } = useBFT(); + const yellow = accent === 'bold'; + return /*#__PURE__*/React.createElement("div", { + style: { + padding: '4px 20px 20px', + background: yellow ? 'var(--bf-yellow)' : 'transparent', + color: yellow ? 'var(--bf-black)' : 'var(--text-primary)', + borderBottom: yellow ? 'none' : '1px solid var(--separator)', + ...style + } + }, eyebrow && /*#__PURE__*/React.createElement(Eyebrow, { + style: { + color: yellow ? 'inherit' : 'var(--accent-text)', + opacity: yellow ? 0.62 : 1 + } + }, eyebrow), title && /*#__PURE__*/React.createElement("h1", { + className: "bf-display", + style: { + margin: '10px 0 0', + fontSize: 44, + lineHeight: 0.94 + } + }, title), spec && /*#__PURE__*/React.createElement(SpecStrip, { + items: spec, + style: { + marginTop: 14 + } + }), children); +} + +/** Floating glass dock — or an opaque tray when the `chrome` tweak is solid. */ +function GlassDock({ + children, + lift = 88, + style +}) { + const { + chrome + } = useBFT(); + const solid = chrome === 'solid'; + return /*#__PURE__*/React.createElement("div", { + style: { + position: 'absolute', + left: solid ? 0 : 12, + right: solid ? 0 : 12, + bottom: solid ? lift > 40 ? 74 : 0 : lift, + zIndex: 35, + background: solid ? 'var(--bg-elevated)' : 'var(--glass-fill-strong)', + WebkitBackdropFilter: solid ? 'none' : 'var(--glass-blur)', + backdropFilter: solid ? 'none' : 'var(--glass-blur)', + border: solid ? 'none' : '1px solid var(--glass-border)', + borderTop: solid ? '1px solid var(--border)' : undefined, + borderRadius: solid ? 0 : 34, + boxShadow: solid ? 'none' : 'var(--glass-shadow), inset 0 1px 0 rgba(255,255,255,.16)', + padding: solid ? '12px 16px' : 10, + display: 'flex', + gap: 8, + alignItems: 'center', + ...style + } + }, children); +} + +/** The docked primary action. Wears the yellow unless the `accent` tweak has spent it + on a full-bleed header instead — exactly one yellow fill per screen, always. */ +function PrimaryAction({ + icon, + children, + onClick, + style +}) { + const { + accent + } = useBFT(); + const quiet = accent === 'bold'; + return /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onClick, + style: { + flex: 1, + height: 'var(--control-lg)', + cursor: 'pointer', + border: quiet ? '1px solid var(--glass-border)' : 'none', + background: quiet ? 'var(--glass-highlight)' : 'var(--accent)', + color: quiet ? 'var(--text-primary)' : 'var(--text-on-accent)', + borderRadius: 'var(--radius-pill)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 9, + boxShadow: quiet ? 'inset 0 1px 0 rgba(255,255,255,.14)' : 'none', + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-headline)', + fontWeight: 'var(--weight-semibold)', + ...style + } + }, icon && /*#__PURE__*/React.createElement(Icon, { + name: icon, + size: 17 + }), children); +} + +/** Square glass companion button beside the primary action. */ +function DockButton({ + icon, + label, + onClick +}) { + return /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onClick, + "aria-label": label, + title: label, + style: { + width: 'var(--control-lg)', + height: 'var(--control-lg)', + flex: '0 0 var(--control-lg)', + cursor: 'pointer', + borderRadius: 'var(--radius-pill)', + boxShadow: 'inset 0 1px 0 rgba(255,255,255,.14)', + background: 'var(--glass-highlight)', + border: '1px solid var(--glass-border)', + color: 'var(--text-primary)', + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: icon, + size: 19, + weight: "bold" + })); +} + +/** Horizontal quick-action strip. Small, glassy, always one tap from the list. */ +function QuickActions({ + items, + style +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + gap: 8, + overflowX: 'auto', + scrollbarWidth: 'none', + msOverflowStyle: 'none', + padding: '0 20px', + ...style + } + }, items.map(it => /*#__PURE__*/React.createElement("button", { + key: it.label, + type: "button", + onClick: it.onClick, + style: { + flex: '0 0 auto', + display: 'inline-flex', + alignItems: 'center', + gap: 7, + height: 'var(--tap-min)', + padding: '0 16px', + borderRadius: 'var(--radius-pill)', + cursor: 'pointer', + background: 'var(--glass-fill)', + WebkitBackdropFilter: 'var(--glass-blur)', + backdropFilter: 'var(--glass-blur)', + border: '1px solid var(--glass-border)', + color: 'var(--text-primary)', + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-footnote)', + fontWeight: 'var(--weight-semibold)' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: it.icon, + size: 15, + weight: "bold", + color: "var(--accent-text)" + }), it.label))); +} + +/** Swipe a ledger row either way to reveal actions. Drag, or tap the row's + trailing handle. `left`/`right` are the edges the actions sit on. */ +function SwipeRow({ + left = [], + right = [], + accent, + children +}) { + const W = 84; + const [x, setX] = React.useState(0); + const drag = React.useRef(null); + const openL = left.length * W, + openR = right.length * W; + const down = e => { + drag.current = { + start: e.clientX, + from: x + }; + e.currentTarget.setPointerCapture(e.pointerId); + }; + const move = e => { + if (!drag.current) return; + const next = drag.current.from + (e.clientX - drag.current.start); + setX(Math.max(-openR, Math.min(openL, next))); + }; + const up = () => { + if (!drag.current) return; + drag.current = null; + setX(v => v <= -openR / 2 ? -openR : v >= openL / 2 ? openL : 0); + }; + const Face = ({ + actions, + side + }) => /*#__PURE__*/React.createElement("div", { + style: { + position: 'absolute', + top: 0, + bottom: 0, + [side]: 0, + display: 'flex' + } + }, actions.map(a => /*#__PURE__*/React.createElement("button", { + key: a.label, + type: "button", + onClick: () => { + setX(0); + a.onClick && a.onClick(); + }, + style: { + width: W, + cursor: 'pointer', + border: 'none', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 5, + background: a.destructive ? 'var(--text-primary)' : 'var(--glass-fill-strong)', + WebkitBackdropFilter: 'var(--glass-blur)', + backdropFilter: 'var(--glass-blur)', + color: a.destructive ? 'var(--bg-page)' : 'var(--text-primary)', + fontFamily: 'var(--font-ui)', + fontSize: 11, + fontWeight: 'var(--weight-bold)' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: a.icon, + size: 18, + weight: "bold" + }), a.label))); + return /*#__PURE__*/React.createElement("div", { + style: { + position: 'relative', + overflow: 'hidden', + touchAction: 'pan-y', + margin: '0 -20px' + } + }, !!openL && /*#__PURE__*/React.createElement(Face, { + actions: left, + side: "left" + }), !!openR && /*#__PURE__*/React.createElement(Face, { + actions: right, + side: "right" + }), /*#__PURE__*/React.createElement("div", { + onPointerDown: down, + onPointerMove: move, + onPointerUp: up, + onPointerCancel: up, + style: { + transform: `translateX(${x}px)`, + transition: drag.current ? 'none' : 'transform var(--dur-fast) var(--ease-snappy)', + background: 'var(--bg-page)', + cursor: 'grab', + position: 'relative', + padding: '0 20px' + } + }, accent && /*#__PURE__*/React.createElement("span", { + style: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: 3, + background: 'var(--accent)' + } + }), children)); +} +Object.assign(window, { + StatusBar, + TabBar, + NavBar, + Phone, + Scroll, + ScreenBoundary, + dsPick, + BFTABS: TABS, + Eyebrow, + Slab, + SlabAction, + SpecStrip, + SectionRule, + LedgerRow, + ArtTile, + Readout, + Bar, + AddRow, + LedgerHead, + HeaderBlock, + GlassDock, + PrimaryAction, + DockButton, + QuickActions, + SwipeRow, + useBFT, + BFT_DEFAULT +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/AppShell.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/BodyScreen.jsx +try { (() => { +const { + Icon +} = dsPick(['Icon']); +const D = window.BFData; +const WORD = { + recovered: 'Recovered', + fresh: 'Ready', + fatigued: 'Fatigued', + sore: 'Sore' +}; +const TONE = { + recovered: 'var(--recovery-recovered)', + fresh: 'var(--recovery-fresh)', + fatigued: 'var(--recovery-fatigued)', + sore: 'var(--recovery-sore)' +}; +const LAST = { + Chest: '1 day ago', + Back: '4 days ago', + Shoulders: '2 days ago', + Arms: '3 days ago', + Core: '5 days ago', + Legs: '3 days ago' +}; +const REGION_ICON = { + Chest: 'arrows-in-line-horizontal', + Back: 'arrow-fat-line-down', + Shoulders: 'arrows-out-line-horizontal', + Arms: 'barbell', + Core: 'circle-half-tilt', + Legs: 'person-simple-run' +}; + +/** Recovery reads as a ruled bar chart: one row per group, bars on a shared left axis. */ +function RecoveryRow({ + r +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + padding: '13px 0', + borderTop: '1px solid var(--separator)' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + gap: 12 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + flex: '0 0 var(--l-gutter, 32px)', + display: 'flex', + color: TONE[r.status] + } + }, /*#__PURE__*/React.createElement(Icon, { + name: REGION_ICON[r.region] || 'barbell', + size: 19 + })), /*#__PURE__*/React.createElement("span", { + style: { + flex: 1, + fontSize: 'var(--l-title, var(--text-body))', + fontWeight: 'var(--weight-semibold)' + } + }, r.region), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)' + } + }, WORD[r.status]), /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + flex: '0 0 42px', + textAlign: 'right', + fontSize: 'var(--text-subheadline)', + fontWeight: 'var(--weight-semibold)' + } + }, r.percent, "%")), /*#__PURE__*/React.createElement(Bar, { + pct: r.percent, + color: TONE[r.status], + style: { + marginTop: 10, + marginLeft: 'calc(var(--l-gutter, 32px) + 12px)' + } + })); +} +function BodyScreen() { + const regions = [...D.recovery.regions].sort((a, b) => b.percent - a.percent); + const ready = regions.filter(r => r.percent >= 75); + const sore = regions.filter(r => r.percent < 50); + const list = xs => xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`; + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StatusBar, null), /*#__PURE__*/React.createElement(LedgerHead, { + title: "Body", + right: /*#__PURE__*/React.createElement("button", { + type: "button", + "aria-label": "Recovery help", + style: { + background: 'none', + border: 'none', + cursor: 'pointer', + color: 'var(--text-secondary)', + width: 44, + height: 44, + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "question", + size: 20, + weight: "bold" + })) + }), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + overflowY: 'auto', + overflowX: 'hidden', + scrollbarWidth: 'none', + msOverflowStyle: 'none', + padding: '0 20px 110px' + } + }, /*#__PURE__*/React.createElement(Readout, { + label: "Overall recovery", + value: D.recovery.overall, + unit: "%", + note: `${list(ready.map(r => r.region))} are ready to train.${sore.length ? ` ${sore[0].region} needs another day.` : ''}` + }), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + gap: 8, + marginTop: 20 + } + }, regions.map(r => /*#__PURE__*/React.createElement("span", { + key: r.region, + style: { + flex: 1, + height: 6, + background: TONE[r.status], + borderRadius: 1 + }, + title: r.region + }))), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + justifyContent: 'space-between', + marginTop: 8, + fontSize: 10, + color: 'var(--text-tertiary)', + textTransform: 'uppercase', + letterSpacing: '0.1em', + fontWeight: 'var(--weight-semibold)' + } + }, /*#__PURE__*/React.createElement("span", null, "Most recovered"), /*#__PURE__*/React.createElement("span", null, "Least")), /*#__PURE__*/React.createElement(SectionRule, { + label: "By muscle group", + trailing: `${regions.length} tracked` + }), regions.map(r => /*#__PURE__*/React.createElement(RecoveryRow, { + key: r.region, + r: r + })), /*#__PURE__*/React.createElement(SectionRule, { + label: "Last trained" + }), regions.map(r => /*#__PURE__*/React.createElement(LedgerRow, { + key: r.region, + gutter: /*#__PURE__*/React.createElement(Icon, { + name: REGION_ICON[r.region] || 'barbell', + size: 17 + }), + title: r.region, + trailing: LAST[r.region] || '—' + })), /*#__PURE__*/React.createElement(SectionRule, { + label: "How this is measured" + }), /*#__PURE__*/React.createElement("p", { + style: { + margin: 0, + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)', + lineHeight: 1.5, + textWrap: 'pretty' + } + }, "Recovery combines the volume you lifted per muscle group, how long ago you trained it, and the sets you reported as hard. It is an estimate, not a diagnosis \u2014 train by how you feel."))); +} +Object.assign(window, { + BodyScreen +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/BodyScreen.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/LogScreen.jsx +try { (() => { +const { + Icon, + ContributionHeatmap +} = dsPick(['Icon', 'ContributionHeatmap']); +const D = window.BFData; +const GROUP_ICON = { + Chest: 'arrows-in-line-horizontal', + Back: 'arrow-fat-line-down', + Shoulders: 'arrows-out-line-horizontal', + Arms: 'barbell', + Core: 'circle-half-tilt', + Legs: 'person-simple-run', + Cardio: 'heartbeat' +}; +const JULY = [{ + date: 26, + name: 'Pull B', + meta: 'Planned · today', + today: true, + groups: [['Back', 2], ['Arms', 3], ['Shoulders', 2]] +}, { + date: 25, + name: 'Push A', + meta: '48 min · 8 exercises', + volume: '16.2k', + groups: [['Chest', 3], ['Shoulders', 3], ['Arms', 2]] +}, { + date: 24, + name: 'Legs A', + meta: '52 min · 9 exercises', + volume: '18.4k', + groups: [['Legs', 6], ['Core', 3]] +}, { + date: 23, + name: 'Rest', + rest: true +}, { + date: 22, + name: 'Push A', + meta: '46 min · 7 exercises', + volume: '15.1k', + groups: [['Chest', 4], ['Shoulders', 3]] +}, { + date: 20, + name: 'Pull A', + meta: '41 min · 7 exercises', + volume: '14.2k', + groups: [['Back', 4], ['Arms', 3]] +}, { + date: 19, + name: 'Conditioning', + meta: '28 min · intervals', + groups: [['Cardio', 4], ['Core', 2]] +}, { + date: 17, + name: 'Legs B', + meta: '55 min · 8 exercises', + volume: '19.8k', + groups: [['Legs', 5], ['Core', 3]] +}, { + date: 16, + name: 'Rest', + rest: true +}, { + date: 15, + name: 'Push B', + meta: '44 min · 7 exercises', + volume: '13.9k', + groups: [['Chest', 4], ['Arms', 3]] +}]; + +/** Stacked horizontal badges: one per muscle group trained, overlapping, with its count. */ +function GroupBadges({ + groups +}) { + return /*#__PURE__*/React.createElement("span", { + style: { + display: 'flex', + alignItems: 'center', + marginTop: 8 + } + }, groups.map(([g, n], i) => /*#__PURE__*/React.createElement("span", { + key: g, + title: `${g} · ${n} exercises`, + style: { + display: 'inline-flex', + alignItems: 'center', + gap: 4, + height: 26, + padding: '0 9px 0 7px', + borderRadius: 'var(--radius-pill)', + background: 'var(--surface-raised)', + border: '1px solid var(--bg-page)', + color: 'var(--text-secondary)', + marginLeft: i ? -8 : 0, + zIndex: groups.length - i, + position: 'relative' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: GROUP_ICON[g] || 'barbell', + size: 13, + weight: "bold", + color: "var(--accent-text)" + }), /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 11, + fontWeight: 'var(--weight-bold)' + } + }, n)))); +} + +/** One day in the ledger. `streakUp`/`streakDown` draw the hot-streak line through + the gutter, joining consecutive training days into a single unbroken run. */ +function DayRow({ + s, + streakUp, + streakDown, + onClick +}) { + const hot = streakUp || streakDown; + return /*#__PURE__*/React.createElement("div", { + style: { + position: 'relative' + } + }, hot && /*#__PURE__*/React.createElement("span", { + "aria-hidden": "true", + style: { + position: 'absolute', + left: 15, + top: streakUp ? 0 : '50%', + bottom: streakDown ? 0 : '50%', + width: 2, + background: 'var(--accent)', + zIndex: 0 + } + }), /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onClick, + disabled: !onClick, + style: { + display: 'flex', + alignItems: 'flex-start', + gap: 14, + width: '100%', + textAlign: 'left', + padding: 'var(--l-row-py, 14px) 0', + borderTop: '1px solid var(--separator)', + background: 'none', + border: 'none', + borderTopWidth: 1, + borderTopStyle: 'solid', + borderTopColor: 'var(--separator)', + color: 'inherit', + font: 'inherit', + fontFamily: 'var(--font-ui)', + cursor: onClick ? 'pointer' : 'default', + opacity: s.rest ? 0.45 : 1, + position: 'relative' + } + }, /*#__PURE__*/React.createElement("span", { + style: { + flex: '0 0 var(--l-gutter, 32px)', + display: 'flex', + justifyContent: 'flex-start', + position: 'relative', + zIndex: 1 + } + }, /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + width: 32, + height: 32, + marginLeft: -1, + borderRadius: 'var(--radius-pill)', + display: 'grid', + placeItems: 'center', + fontSize: 14, + fontWeight: 'var(--weight-bold)', + background: hot ? 'var(--accent)' : 'transparent', + color: hot ? 'var(--text-on-accent)' : s.today ? 'var(--accent-text)' : 'var(--text-tertiary)' + } + }, s.date)), /*#__PURE__*/React.createElement("span", { + style: { + flex: 1, + minWidth: 0 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + display: 'flex', + alignItems: 'baseline', + gap: 8 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + flex: 1, + fontSize: 'var(--l-title, var(--text-body))', + fontWeight: 'var(--weight-semibold)' + } + }, s.name), s.volume && /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 'var(--text-subheadline)', + fontWeight: 'var(--weight-semibold)' + } + }, s.volume, /*#__PURE__*/React.createElement("span", { + style: { + color: 'var(--text-tertiary)', + fontSize: 'var(--text-caption)' + } + }, "\xA0kg"))), s.meta && /*#__PURE__*/React.createElement("span", { + style: { + display: 'block', + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)', + marginTop: 'var(--l-meta-gap, 3px)' + } + }, s.meta), s.groups && /*#__PURE__*/React.createElement(GroupBadges, { + groups: s.groups + })))); +} +function LogScreen({ + onOpenSummary, + onAddExercise +}) { + const trained = i => JULY[i] && !JULY[i].rest; + const joined = (a, b) => trained(a) && trained(b) && JULY[a].date - JULY[b].date === 1; + const runs = JULY.filter(s => !s.rest).length; + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StatusBar, null), /*#__PURE__*/React.createElement(LedgerHead, { + title: "Log", + right: /*#__PURE__*/React.createElement("button", { + type: "button", + "aria-label": "Jump to date", + style: { + background: 'none', + border: 'none', + cursor: 'pointer', + color: 'var(--text-secondary)', + width: 44, + height: 44, + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "calendar-blank", + size: 20, + weight: "bold" + })) + }), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + overflowY: 'auto', + overflowX: 'hidden', + scrollbarWidth: 'none', + msOverflowStyle: 'none', + padding: '0 20px 110px' + } + }, /*#__PURE__*/React.createElement(Readout, { + label: "Current streak", + value: D.streak.current, + unit: "days", + note: `Three days back to back this week. Your best run is ${D.streak.best} days — keep Thursday and you match it.` + }), /*#__PURE__*/React.createElement(SectionRule, { + label: "Consistency", + trailing: "26 weeks" + }), /*#__PURE__*/React.createElement("div", { + style: { + padding: '4px 0 2px' + } + }, /*#__PURE__*/React.createElement(ContributionHeatmap, { + values: D.heatmap, + cell: 9, + gap: 3 + })), /*#__PURE__*/React.createElement(SectionRule, { + label: "July", + trailing: `${runs} sessions · 97.6k kg` + }), JULY.map((s, i) => /*#__PURE__*/React.createElement(DayRow, { + key: s.date, + s: s, + streakUp: joined(i - 1, i), + streakDown: joined(i, i + 1), + onClick: !s.rest && !s.today ? onOpenSummary : undefined + })), /*#__PURE__*/React.createElement(AddRow, { + onClick: onAddExercise, + label: "Log a past workout" + }), /*#__PURE__*/React.createElement(SectionRule, { + label: "All time" + }), /*#__PURE__*/React.createElement(LedgerRow, { + gutter: /*#__PURE__*/React.createElement(Icon, { + name: "barbell", + size: 15, + weight: "bold" + }), + title: "Sessions", + trailing: "184" + }), /*#__PURE__*/React.createElement(LedgerRow, { + gutter: /*#__PURE__*/React.createElement(Icon, { + name: "scales", + size: 15, + weight: "bold" + }), + title: "Volume lifted", + trailing: "2.41M", + trailingMeta: "kg" + }), /*#__PURE__*/React.createElement(LedgerRow, { + gutter: /*#__PURE__*/React.createElement(Icon, { + name: "clock", + size: 15, + weight: "bold" + }), + title: "Time training", + trailing: "142", + trailingMeta: "hours" + }))); +} +Object.assign(window, { + LogScreen +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/LogScreen.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/MyPlanScreen.jsx +try { (() => { +const { + Icon +} = dsPick(['Icon']); +const PLAN = { + name: 'Pull Day', + duration: '45 min', + gym: 'Anytime Fitness', + muscles: 3, + rows: [{ + name: 'Lat pulldown', + sets: 3, + reps: 8, + load: '50 kg', + focus: true, + region: 'Lats' + }, { + name: 'Cable row', + sets: 4, + reps: 8, + load: '64 kg', + region: 'Lats' + }, { + name: 'Barbell curl', + sets: 4, + reps: 8, + load: '30 kg', + region: 'Biceps' + }, { + name: 'Hammer curls', + sets: 3, + reps: 12, + load: '15 kg', + region: 'Biceps' + }, { + name: 'Face pull', + sets: 3, + reps: 15, + load: '18 kg', + region: 'Rear delts' + }, { + name: 'Reverse fly', + sets: 3, + reps: 12, + load: '10 kg', + region: 'Rear delts' + }, { + name: 'Cable wood chop', + sets: 3, + reps: 15, + load: '20 kg', + region: 'Core' + }] +}; +const SUGGESTED = [{ + name: 'Pull Day', + meta: 'Recommended · 45 min · back and biceps', + why: 'Back is 96% recovered' +}, { + name: 'Legs A', + meta: '52 min · quads and glutes', + why: 'Due since Thursday' +}, { + name: 'Push B', + meta: '44 min · chest and shoulders', + why: 'Chest still sore', + dim: true +}]; +const FREQUENT = [{ + name: 'Pull B', + meta: 'Done 14 times · last Tuesday' +}, { + name: 'Push A', + meta: 'Done 12 times · last Monday' +}, { + name: 'Legs A', + meta: 'Done 9 times · last Thursday' +}, { + name: 'Conditioning', + meta: 'Done 6 times · last Saturday' +}]; +const REGION_ICON = { + Lats: 'arrow-fat-line-down', + Biceps: 'barbell', + 'Rear delts': 'arrows-out-line-horizontal', + Core: 'circle-half-tilt', + Quads: 'person-simple-run', + Chest: 'arrows-in-line-horizontal', + Added: 'plus-circle' +}; +const slugify = s => s.toLowerCase().replace(/[^a-z0-9]+/g, '-'); +const pad = n => String(n).padStart(2, '0'); + +/** Segmented source switch for where the next session comes from. */ +function SourceSwitch({ + value, + onChange +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + gap: 4, + padding: 4, + background: 'var(--surface-raised)', + borderRadius: 'var(--radius-pill)', + marginTop: 4 + } + }, [['suggested', 'Suggested'], ['frequent', 'Frequent']].map(([id, label]) => /*#__PURE__*/React.createElement("button", { + key: id, + type: "button", + onClick: () => onChange(id), + style: { + flex: 1, + height: 'var(--tap-min)', + cursor: 'pointer', + border: 'none', + borderRadius: 'var(--radius-pill)', + background: value === id ? 'var(--bg-page)' : 'transparent', + color: value === id ? 'var(--text-primary)' : 'var(--text-secondary)', + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-footnote)', + fontWeight: 'var(--weight-semibold)' + } + }, label))); +} + +/** One-tap start for a session you didn't have to go looking for. */ +function QuickStart({ + onClick +}) { + return /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onClick, + "aria-label": "Start this session", + style: { + width: 44, + height: 44, + flex: '0 0 44px', + borderRadius: 'var(--radius-pill)', + cursor: 'pointer', + background: 'var(--surface-raised)', + border: '1px solid var(--border)', + color: 'var(--accent-text)', + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "play", + size: 15 + })); +} +function MyPlanScreen({ + onStart, + onAddExercise, + added = [] +}) { + const base = PLAN.rows.map(r => r.name); + const initial = [...PLAN.rows, ...added.filter(n => !base.includes(n)).map(name => ({ + name, + sets: 3, + reps: 10, + load: '—', + region: 'Added' + }))]; + const [rows, setRows] = React.useState(initial); + const [source, setSource] = React.useState('suggested'); + React.useEffect(() => { + setRows(cur => { + const have = cur.map(r => r.name); + const extra = added.filter(n => !have.includes(n)).map(name => ({ + name, + sets: 3, + reps: 10, + load: '—', + region: 'Added' + })); + return extra.length ? [...cur, ...extra] : cur; + }); + }, [added.length]); + const remove = name => setRows(cur => cur.filter(r => r.name !== name)); + const toTop = name => setRows(cur => { + const i = cur.findIndex(r => r.name === name); + if (i < 1) return cur; + const c = [...cur]; + const [it] = c.splice(i, 1); + return [it, ...c]; + }); + const mark = (name, key) => setRows(cur => cur.map(r => r.name === name ? { + ...r, + [key]: !r[key] + } : r)); + const list = source === 'suggested' ? SUGGESTED : FREQUENT; + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StatusBar, null), /*#__PURE__*/React.createElement(LedgerHead, { + title: "Plan", + right: /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)' + } + }, "Tue 26 Jul"), /*#__PURE__*/React.createElement("span", { + style: { + width: 34, + height: 34, + borderRadius: 'var(--radius-pill)', + background: 'var(--surface-raised)', + border: '1px solid var(--border)', + display: 'grid', + placeItems: 'center', + fontSize: 12, + fontWeight: 'var(--weight-bold)' + } + }, "JH")) + }), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + overflowY: 'auto', + overflowX: 'hidden', + scrollbarWidth: 'none', + msOverflowStyle: 'none' + } + }, /*#__PURE__*/React.createElement(HeaderBlock, { + eyebrow: "Today \xB7 Pull", + title: PLAN.name, + spec: [{ + value: rows.length, + label: 'Exercises' + }, { + value: PLAN.duration.replace(' min', "'"), + label: 'Planned' + }, { + value: PLAN.muscles, + label: 'Muscles' + }] + }), /*#__PURE__*/React.createElement(QuickActions, { + style: { + padding: '14px 20px 4px' + }, + items: [{ + icon: 'arrows-left-right', + label: 'Swap session', + onClick: () => {} + }, { + icon: 'pencil-simple', + label: 'Edit', + onClick: () => {} + }, { + icon: 'arrow-counter-clockwise', + label: 'Repeat last', + onClick: () => {} + }, { + icon: 'clock', + label: 'Trim to 30 min', + onClick: () => {} + }, { + icon: 'barbell', + label: PLAN.gym, + onClick: () => {} + }] + }), /*#__PURE__*/React.createElement("div", { + style: { + padding: '0 20px 200px' + } + }, /*#__PURE__*/React.createElement(SectionRule, { + label: "The work", + trailing: "Swipe a row" + }), rows.map((r, i) => /*#__PURE__*/React.createElement(SwipeRow, { + key: `${r.name}-${i}`, + accent: r.focus, + left: [{ + icon: 'arrow-line-up', + label: 'To top', + onClick: () => toTop(r.name) + }, { + icon: 'link-simple', + label: 'Superset', + onClick: () => mark(r.name, 'superset') + }], + right: [{ + icon: 'arrows-clockwise', + label: 'Replace', + onClick: () => mark(r.name, 'replacing') + }, { + icon: 'trash', + label: 'Remove', + destructive: true, + onClick: () => remove(r.name) + }] + }, /*#__PURE__*/React.createElement(LedgerRow, { + gutter: pad(i + 1), + gutterAccent: r.focus, + art: { + icon: REGION_ICON[r.region] || 'barbell', + slot: `plan-${slugify(r.name)}`, + label: r.name + }, + title: r.name, + meta: [`${pad(i + 1)}`, r.focus ? 'Focus exercise' : null, r.superset ? 'Superset' : null, r.replacing ? 'Choosing a replacement' : null, r.region].filter(Boolean).join(' · '), + trailing: `${r.sets}×${r.reps}`, + trailingMeta: r.load + }))), /*#__PURE__*/React.createElement(AddRow, { + onClick: onAddExercise + }), /*#__PURE__*/React.createElement(SectionRule, { + label: "Start something else", + trailing: source === 'suggested' ? 'Based on recovery' : 'Most used' + }), /*#__PURE__*/React.createElement(SourceSwitch, { + value: source, + onChange: setSource + }), /*#__PURE__*/React.createElement("div", { + style: { + marginTop: 8 + } + }, list.map(s => /*#__PURE__*/React.createElement(LedgerRow, { + key: s.name, + gutter: /*#__PURE__*/React.createElement(Icon, { + name: "barbell", + size: 15, + weight: "bold" + }), + art: { + icon: 'barbell', + slot: `session-${slugify(s.name)}`, + label: s.name + }, + title: s.name, + meta: s.meta, + dim: s.dim, + trailing: /*#__PURE__*/React.createElement(QuickStart, { + onClick: onStart + }) + }))))), /*#__PURE__*/React.createElement(GlassDock, { + lift: 88 + }, /*#__PURE__*/React.createElement(PrimaryAction, { + icon: "play", + onClick: onStart + }, "Start workout"), /*#__PURE__*/React.createElement(DockButton, { + icon: "pencil-simple", + label: "Edit this session" + }), /*#__PURE__*/React.createElement(DockButton, { + icon: "plus", + label: "Add exercise", + onClick: onAddExercise + }))); +} +Object.assign(window, { + MyPlanScreen, + BF_PLAN_EXERCISES: PLAN.rows.map(r => r.name) +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/MyPlanScreen.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/ProfileScreen.jsx +try { (() => { +const { + Card, + SectionHeader, + Divider, + Icon, + StatTile, + ContributionHeatmap, + MetricPill, + ListRow, + Button, + IconButton, + Logo +} = window.BetterFitDesignSystem_6a70ea; +function ProfileScreen({ + onSettings +}) { + const d = window.BFData; + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StatusBar, null), /*#__PURE__*/React.createElement(NavBar, { + title: "Me", + trailing: /*#__PURE__*/React.createElement(IconButton, { + icon: "gear-six", + label: "Settings", + size: 44, + variant: "quiet", + onClick: onSettings + }) + }), /*#__PURE__*/React.createElement(Scroll, null, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + flexDirection: 'column', + gap: 20 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + gap: 14 + } + }, /*#__PURE__*/React.createElement(Logo, { + variant: "combo", + height: 58, + assetBase: "../../assets", + style: { + borderRadius: 16 + } + }), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + minWidth: 0 + } + }, /*#__PURE__*/React.createElement("div", { + className: "bf-display", + style: { + fontSize: 'var(--text-title-2)' + } + }, d.user.name), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)' + } + }, d.user.since)), /*#__PURE__*/React.createElement(MetricPill, { + label: d.user.plan, + tone: "accent", + icon: "star" + })), /*#__PURE__*/React.createElement("div", { + style: { + display: 'grid', + gridTemplateColumns: 'repeat(3,1fr)', + gap: 10 + } + }, /*#__PURE__*/React.createElement(StatTile, { + icon: "fire-simple", + value: d.streak.current, + label: "Day streak" + }), /*#__PURE__*/React.createElement(StatTile, { + icon: "barbell", + value: "41k", + label: "Volume (lb)" + }), /*#__PURE__*/React.createElement(StatTile, { + icon: "heartbeat", + value: d.recovery.overall + '%', + label: "Recovery" + })), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + flexDirection: 'column', + gap: 12 + } + }, /*#__PURE__*/React.createElement(SectionHeader, { + title: "Weekly targets", + action: /*#__PURE__*/React.createElement(Button, { + variant: "ghost", + size: "sm", + fullWidth: false + }, "Edit") + }), /*#__PURE__*/React.createElement(Card, null, d.targets.map((t, i) => /*#__PURE__*/React.createElement("div", { + key: t.label, + style: { + marginTop: i ? 14 : 0 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between' + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 'var(--text-subheadline)' + } + }, t.label), /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 'var(--text-subheadline)', + fontWeight: 600, + whiteSpace: 'nowrap' + } + }, t.value, /*#__PURE__*/React.createElement("span", { + style: { + color: 'var(--text-tertiary)', + fontWeight: 400 + } + }, "\xA0/\xA0", t.target, t.unit))), /*#__PURE__*/React.createElement("div", { + style: { + height: 6, + borderRadius: 3, + background: 'var(--surface-raised)', + marginTop: 8, + overflow: 'hidden' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + width: Math.min(100, t.value / t.target * 100) + '%', + height: '100%', + background: 'var(--accent)' + } + })))))), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + flexDirection: 'column', + gap: 12 + } + }, /*#__PURE__*/React.createElement(SectionHeader, { + title: "Personal records", + action: /*#__PURE__*/React.createElement(Button, { + variant: "ghost", + size: "sm", + fullWidth: false + }, "View all") + }), /*#__PURE__*/React.createElement(Card, { + padding: 12 + }, d.prs.map((p, i) => /*#__PURE__*/React.createElement("div", { + key: p.name + }, i > 0 && /*#__PURE__*/React.createElement(Divider, { + inset: 56 + }), /*#__PURE__*/React.createElement(ListRow, { + icon: "trophy", + title: p.name, + subtitle: p.when, + iconTint: "var(--accent-text)", + trailing: /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 'var(--text-subheadline)', + fontWeight: 600 + } + }, p.value) + }))))), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + flexDirection: 'column', + gap: 12 + } + }, /*#__PURE__*/React.createElement(SectionHeader, { + title: "Achievements", + action: /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 'var(--text-caption)', + color: 'var(--text-tertiary)' + } + }, "2/4") + }), /*#__PURE__*/React.createElement("div", { + style: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: 10 + } + }, d.achievements.map(a => /*#__PURE__*/React.createElement(Card, { + key: a.title, + padding: 14, + style: { + opacity: a.done ? 1 : 0.45 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: a.icon, + size: 20, + color: a.done ? 'var(--accent-text)' : 'var(--text-tertiary)' + }), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-footnote)', + fontWeight: 600, + marginTop: 8 + } + }, a.title), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-caption)', + color: 'var(--text-secondary)', + marginTop: 2 + } + }, a.done ? 'Earned' : 'Locked'))))), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + flexDirection: 'column', + gap: 12 + } + }, /*#__PURE__*/React.createElement(SectionHeader, { + title: "Your year" + }), /*#__PURE__*/React.createElement(Card, null, /*#__PURE__*/React.createElement(ContributionHeatmap, { + values: d.heatmap, + weeks: 22, + cell: 9, + gap: 3 + }), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)', + marginTop: 12 + } + }, /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + color: 'var(--text-primary)', + fontWeight: 600 + } + }, "112"), " workouts logged. Twelve weeks unbroken.")))))); +} +Object.assign(window, { + ProfileScreen +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/ProfileScreen.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/SearchScreen.jsx +try { (() => { +const { + Card, + Divider, + Chip, + Icon, + ListRow, + EmptyState, + Button +} = window.BetterFitDesignSystem_6a70ea; +function SearchScreen() { + const d = window.BFData; + const [q, setQ] = React.useState(''); + const results = d.searchResults.filter(r => r.title.toLowerCase().includes(q.toLowerCase())); + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StatusBar, null), /*#__PURE__*/React.createElement(NavBar, { + title: "Search" + }), /*#__PURE__*/React.createElement("div", { + style: { + flex: '0 0 auto', + padding: '0 20px 12px' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + gap: 10, + background: 'var(--surface-raised)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-md)', + padding: '0 12px', + height: 44 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "magnifying-glass", + size: 16, + color: "var(--text-tertiary)" + }), /*#__PURE__*/React.createElement("input", { + value: q, + onChange: e => setQ(e.target.value), + placeholder: "Exercises, plans, settings", + style: { + flex: 1, + alignSelf: 'stretch', + background: 'transparent', + border: 'none', + outline: 'none', + color: 'var(--text-primary)', + fontSize: 'var(--text-body)', + fontFamily: 'var(--font-ui)' + } + }), q && /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: () => setQ(''), + "aria-label": "Clear", + style: { + background: 'none', + border: 'none', + cursor: 'pointer', + color: 'var(--text-tertiary)', + width: 'var(--tap-min)', + height: 'var(--tap-min)', + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "x-circle", + size: 16 + })))), /*#__PURE__*/React.createElement("div", { + style: { + flex: '0 0 auto', + display: 'flex', + gap: 8, + padding: '0 20px 16px', + overflowX: 'auto', + scrollbarWidth: 'none', + msOverflowStyle: 'none' + } + }, /*#__PURE__*/React.createElement(Chip, { + label: "All", + selected: true + }), /*#__PURE__*/React.createElement(Chip, { + label: "Exercises", + icon: "barbell" + }), /*#__PURE__*/React.createElement(Chip, { + label: "Muscles" + }), /*#__PURE__*/React.createElement(Chip, { + label: "Settings", + icon: "gear-six" + })), /*#__PURE__*/React.createElement(Scroll, null, results.length === 0 ? /*#__PURE__*/React.createElement(EmptyState, { + icon: "magnifying-glass", + title: `Nothing matches "${q}"`, + message: "Try a muscle group, a piece of equipment, or a setting name.", + action: /*#__PURE__*/React.createElement(Button, { + size: "sm", + fullWidth: false, + variant: "secondary", + onClick: () => setQ('') + }, "Clear search") + }) : /*#__PURE__*/React.createElement(Card, { + padding: 12 + }, results.map((r, i) => /*#__PURE__*/React.createElement("div", { + key: r.title + }, i > 0 && /*#__PURE__*/React.createElement(Divider, { + inset: 56 + }), /*#__PURE__*/React.createElement(ListRow, { + icon: r.icon, + title: r.title, + subtitle: r.subtitle, + onClick: () => {}, + trailing: /*#__PURE__*/React.createElement(Icon, { + name: "caret-right", + size: 13, + weight: "bold", + color: "var(--text-tertiary)" + }) + })))))); +} +Object.assign(window, { + SearchScreen +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/SearchScreen.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/SessionScreen.jsx +try { (() => { +const { + Icon +} = dsPick(['Icon']); +const NS_S = window.BetterFitDesignSystem_6a70ea || {}; +const RestBar = NS_S.RestTimerBar || (() => null); +const EX = { + index: 2, + name: 'Cable row', + region: 'Lats', + load: 64, + reps: 8, + total: 4, + last: { + load: 60, + reps: 8 + }, + pr: { + load: 64, + reps: 8 + } +}; +const UP_NEXT = [{ + i: 3, + name: 'Barbell curl', + spec: '4×8 · 30 kg', + icon: 'barbell' +}, { + i: 4, + name: 'Hammer curls', + spec: '3×12 · 15 kg', + icon: 'barbell' +}, { + i: 5, + name: 'Face pull', + spec: '3×15 · 18 kg', + icon: 'arrows-out-line-horizontal' +}, { + i: 6, + name: 'Reverse fly', + spec: '3×12 · 10 kg', + icon: 'arrows-out-line-horizontal' +}, { + i: 7, + name: 'Cable wood chop', + spec: '3×15 · 20 kg', + icon: 'circle-half-tilt' +}]; +const slugify = s => s.toLowerCase().replace(/[^a-z0-9]+/g, '-'); +const pad2 = n => String(n).padStart(2, '0'); +const kg = n => Number.isInteger(n) ? n : n.toFixed(1); + +/** Big two-button stepper. The value is the largest thing in it, and both + buttons clear 44px so they work with a thumb between sets. */ +function Stepper({ + label, + value, + unit, + step, + min = 0, + onChange +}) { + const bump = d => onChange(Math.max(min, Math.round((value + d * step) * 10) / 10)); + return /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + minWidth: 0 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 10, + fontWeight: 'var(--weight-bold)', + textTransform: 'uppercase', + letterSpacing: '0.12em', + color: 'var(--text-secondary)' + } + }, label), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + gap: 8, + marginTop: 8 + } + }, /*#__PURE__*/React.createElement("button", { + type: "button", + "aria-label": `Decrease ${label}`, + onClick: () => bump(-1), + style: { + width: 46, + height: 46, + flex: '0 0 46px', + borderRadius: 'var(--radius-md)', + cursor: 'pointer', + border: '1px solid var(--border-strong)', + background: 'var(--surface-raised)', + color: 'var(--text-primary)', + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "minus", + size: 16, + weight: "bold" + })), /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + flex: 1, + textAlign: 'center', + whiteSpace: 'nowrap' + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 34, + fontWeight: 'var(--weight-bold)', + letterSpacing: '-0.02em' + } + }, kg(value)), unit && /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 15, + fontWeight: 'var(--weight-bold)', + color: 'var(--text-secondary)', + marginLeft: 3 + } + }, unit)), /*#__PURE__*/React.createElement("button", { + type: "button", + "aria-label": `Increase ${label}`, + onClick: () => bump(1), + style: { + width: 46, + height: 46, + flex: '0 0 46px', + borderRadius: 'var(--radius-md)', + cursor: 'pointer', + border: '1px solid var(--border-strong)', + background: 'var(--surface-raised)', + color: 'var(--text-primary)', + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "plus", + size: 16, + weight: "bold" + })))); +} + +/** Inline set editor: steppers, one-tap suggestions, and bulk apply. */ +function SetEditor({ + set, + index, + count, + onChange, + onApplyRest, + onApplyAll, + onClose +}) { + const sugg = [{ + label: `Last ${EX.last.load} × ${EX.last.reps}`, + icon: 'arrow-counter-clockwise', + load: EX.last.load, + reps: EX.last.reps + }, { + label: '+2.5 kg', + icon: 'trend-up', + load: set.load + 2.5, + reps: set.reps + }, { + label: '+1 rep', + icon: 'plus', + load: set.load, + reps: set.reps + 1 + }, { + label: `PR ${EX.pr.load} × ${EX.pr.reps}`, + icon: 'medal', + load: EX.pr.load, + reps: EX.pr.reps + }]; + const rest = count - index - 1; + return /*#__PURE__*/React.createElement("div", { + style: { + padding: '4px 0 18px', + borderTop: '1px solid var(--separator)' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + gap: 16, + paddingTop: 12 + } + }, /*#__PURE__*/React.createElement(Stepper, { + label: "Weight", + value: set.load, + unit: "kg", + step: 2.5, + onChange: v => onChange({ + load: v + }) + }), /*#__PURE__*/React.createElement(Stepper, { + label: "Reps", + value: set.reps, + step: 1, + min: 1, + onChange: v => onChange({ + reps: v + }) + })), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + gap: 8, + overflowX: 'auto', + scrollbarWidth: 'none', + msOverflowStyle: 'none', + marginTop: 16 + } + }, sugg.map(s => /*#__PURE__*/React.createElement("button", { + key: s.label, + type: "button", + onClick: () => onChange({ + load: s.load, + reps: s.reps + }), + style: { + flex: '0 0 auto', + display: 'inline-flex', + alignItems: 'center', + gap: 7, + minHeight: 'var(--tap-min)', + padding: '0 15px', + borderRadius: 'var(--radius-pill)', + cursor: 'pointer', + background: 'var(--surface-raised)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-footnote)', + fontWeight: 'var(--weight-semibold)' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: s.icon, + size: 14, + weight: "bold", + color: "var(--accent-text)" + }), s.label))), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + gap: 8, + marginTop: 12 + } + }, rest > 0 && /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onApplyRest, + style: { + flex: 1, + minHeight: 'var(--tap-min)', + cursor: 'pointer', + borderRadius: 'var(--radius-button)', + background: 'transparent', + border: '1px solid var(--border-strong)', + color: 'var(--text-primary)', + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-footnote)', + fontWeight: 'var(--weight-semibold)' + } + }, "Apply to next ", rest), /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onApplyAll, + style: { + flex: 1, + minHeight: 'var(--tap-min)', + cursor: 'pointer', + borderRadius: 'var(--radius-button)', + background: 'transparent', + border: '1px solid var(--border-strong)', + color: 'var(--text-primary)', + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-footnote)', + fontWeight: 'var(--weight-semibold)' + } + }, "Apply to all ", count), /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onClose, + "aria-label": "Close editor", + style: { + width: 'var(--tap-min)', + flex: '0 0 var(--tap-min)', + minHeight: 'var(--tap-min)', + cursor: 'pointer', + borderRadius: 'var(--radius-button)', + background: 'transparent', + border: '1px solid var(--border-strong)', + color: 'var(--text-secondary)', + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "check", + size: 16, + weight: "bold" + })))); +} + +/** One set as a ledger row: big mono value, tap to edit, swipe left to clear or delete. */ +function SetRow({ + set, + i, + count, + open, + onOpen, + onChange, + onApplyRest, + onApplyAll, + onClear, + onDelete +}) { + const planned = !set.logged; + return /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement(SwipeRow, { + accent: set.current, + left: [{ + icon: 'copy', + label: 'Copy all', + onClick: onApplyAll + }], + right: [...(set.logged ? [{ + icon: 'arrow-counter-clockwise', + label: 'Clear', + onClick: onClear + }] : []), { + icon: 'trash', + label: 'Delete', + destructive: true, + onClick: onDelete + }] + }, /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onOpen, + style: { + display: 'flex', + alignItems: 'center', + gap: 14, + width: '100%', + textAlign: 'left', + cursor: 'pointer', + padding: 'var(--l-row-py, 14px) 0', + borderTop: '1px solid var(--separator)', + background: 'none', + border: 'none', + borderTopWidth: 1, + borderTopStyle: 'solid', + borderTopColor: 'var(--separator)', + color: 'inherit', + font: 'inherit', + fontFamily: 'var(--font-ui)', + opacity: planned && !set.current ? 0.5 : 1 + } + }, /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + flex: '0 0 var(--l-gutter, 32px)', + fontSize: 15, + fontWeight: 'var(--weight-bold)', + color: set.current ? 'var(--accent-text)' : 'var(--text-tertiary)' + } + }, pad2(i + 1)), /*#__PURE__*/React.createElement("span", { + style: { + flex: 1, + minWidth: 0 + } + }, /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + display: 'flex', + alignItems: 'baseline', + gap: 5, + whiteSpace: 'nowrap' + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 28, + fontWeight: 'var(--weight-bold)', + letterSpacing: '-0.02em' + } + }, kg(set.load)), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 13, + fontWeight: 'var(--weight-bold)', + color: 'var(--text-secondary)' + } + }, "kg"), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 15, + fontWeight: 'var(--weight-bold)', + color: 'var(--text-tertiary)', + margin: '0 2px' + } + }, "\xD7"), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 28, + fontWeight: 'var(--weight-bold)' + } + }, set.reps)), /*#__PURE__*/React.createElement("span", { + style: { + display: 'block', + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)', + marginTop: 'var(--l-meta-gap, 3px)' + } + }, set.logged ? 'Logged' : set.current ? 'Up now · tap to edit' : 'Planned')), set.logged ? /*#__PURE__*/React.createElement(Icon, { + name: "check", + size: 18, + weight: "bold", + color: "var(--accent-text)" + }) : /*#__PURE__*/React.createElement(Icon, { + name: open ? 'caret-up' : 'pencil-simple', + size: 16, + weight: "bold", + color: "var(--text-tertiary)" + }))), open && /*#__PURE__*/React.createElement(SetEditor, { + set: set, + index: i, + count: count, + onChange: onChange, + onApplyRest: onApplyRest, + onApplyAll: onApplyAll, + onClose: onOpen + })); +} +function SessionScreen({ + onClose, + onFinish, + onAddExercise +}) { + const [sets, setSets] = React.useState(Array.from({ + length: EX.total + }, (_, i) => ({ + load: EX.load, + reps: EX.reps, + logged: i < 2 + }))); + const [openSet, setOpenSet] = React.useState(null); + const [resting, setResting] = React.useState(false); + const [remaining, setRemaining] = React.useState(90); + const [queue, setQueue] = React.useState(UP_NEXT); + React.useEffect(() => { + if (!resting) return; + const id = setInterval(() => setRemaining(r => r <= 1 ? (setResting(false), 90) : r - 1), 1000); + return () => clearInterval(id); + }, [resting]); + const done = sets.filter(s => s.logged).length; + const currentIdx = sets.findIndex(s => !s.logged); + const complete = currentIdx === -1; + const cur = complete ? sets[sets.length - 1] : sets[currentIdx]; + const patch = (i, next) => setSets(cur => cur.map((s, j) => j === i ? { + ...s, + ...next + } : s)); + const applyFrom = (i, all) => setSets(cur => cur.map((s, j) => (all ? true : j > i) && !s.logged ? { + ...s, + load: cur[i].load, + reps: cur[i].reps + } : s)); + const logSet = () => { + if (complete) return; + patch(currentIdx, { + logged: true + }); + setRemaining(90); + setResting(true); + setOpenSet(null); + }; + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StatusBar, null), /*#__PURE__*/React.createElement("div", { + style: { + flex: '0 0 auto', + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '0 16px 10px' + } + }, /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onClose, + "aria-label": "Close session", + style: { + background: 'none', + border: 'none', + cursor: 'pointer', + color: 'var(--text-secondary)', + width: 44, + height: 44, + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "x", + size: 20, + weight: "bold" + })), /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + flex: 1, + textAlign: 'center', + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)' + } + }, "Pull Day \xB7 ", pad2(EX.index), " of 07"), /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 'var(--text-subheadline)', + fontWeight: 'var(--weight-bold)', + padding: '0 6px' + } + }, "18:24")), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + overflowY: 'auto', + overflowX: 'hidden', + scrollbarWidth: 'none', + msOverflowStyle: 'none' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + padding: '4px 20px 20px', + borderBottom: '1px solid var(--separator)' + } + }, /*#__PURE__*/React.createElement(Eyebrow, { + style: { + color: 'var(--accent-text)' + } + }, "Exercise ", pad2(EX.index), " \xB7 ", EX.region), /*#__PURE__*/React.createElement("h1", { + className: "bf-display", + style: { + margin: '8px 0 0', + fontSize: 34, + lineHeight: 1 + } + }, EX.name), /*#__PURE__*/React.createElement("div", { + style: { + marginTop: 18 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 10, + fontWeight: 'var(--weight-bold)', + textTransform: 'uppercase', + letterSpacing: '0.12em', + color: 'var(--text-secondary)' + } + }, "Set ", complete ? EX.total : currentIdx + 1, " of ", sets.length), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + gap: 16, + marginTop: 10 + } + }, /*#__PURE__*/React.createElement(Stepper, { + label: "Weight", + value: cur.load, + unit: "kg", + step: 2.5, + onChange: v => patch(complete ? sets.length - 1 : currentIdx, { + load: v + }) + }), /*#__PURE__*/React.createElement(Stepper, { + label: "Reps", + value: cur.reps, + step: 1, + min: 1, + onChange: v => patch(complete ? sets.length - 1 : currentIdx, { + reps: v + }) + })))), resting && /*#__PURE__*/React.createElement(RestBar, { + remaining: remaining, + total: 90, + onAdd: () => setRemaining(r => r + 15), + onSkip: () => setResting(false) + }), /*#__PURE__*/React.createElement(QuickActions, { + style: { + padding: '14px 20px 4px' + }, + items: [{ + icon: 'arrows-clockwise', + label: 'Swap exercise', + onClick: () => {} + }, { + icon: 'copy', + label: 'Same for all sets', + onClick: () => applyFrom(complete ? sets.length - 1 : currentIdx, true) + }, { + icon: 'plus-circle', + label: 'Add a set', + onClick: () => setSets(c => [...c, { + load: cur.load, + reps: cur.reps, + logged: false + }]) + }, { + icon: 'note-pencil', + label: 'Add note', + onClick: () => {} + }, { + icon: 'timer', + label: 'Rest 90s', + onClick: () => { + setRemaining(90); + setResting(true); + } + }] + }), /*#__PURE__*/React.createElement("div", { + style: { + padding: '0 20px 200px' + } + }, /*#__PURE__*/React.createElement(SectionRule, { + label: "This exercise", + trailing: `${done} of ${sets.length} logged` + }), sets.map((s, i) => /*#__PURE__*/React.createElement(SetRow, { + key: i, + set: { + ...s, + current: i === currentIdx + }, + i: i, + count: sets.length, + open: openSet === i, + onOpen: () => setOpenSet(o => o === i ? null : i), + onChange: next => patch(i, next), + onApplyRest: () => applyFrom(i, false), + onApplyAll: () => applyFrom(i, true), + onClear: () => patch(i, { + logged: false + }), + onDelete: () => { + setOpenSet(null); + setSets(c => c.filter((_, j) => j !== i)); + } + })), /*#__PURE__*/React.createElement(AddRow, { + onClick: () => setSets(c => [...c, { + load: cur.load, + reps: cur.reps, + logged: false + }]), + label: "Add a set" + }), /*#__PURE__*/React.createElement(SectionRule, { + label: "Up next", + trailing: "Swipe a row" + }), queue.map(e => /*#__PURE__*/React.createElement(SwipeRow, { + key: e.i, + left: [{ + icon: 'arrow-line-up', + label: 'Do now', + onClick: () => setQueue(q => [e, ...q.filter(x => x.i !== e.i)]) + }], + right: [{ + icon: 'arrows-clockwise', + label: 'Replace', + onClick: () => {} + }, { + icon: 'trash', + label: 'Remove', + destructive: true, + onClick: () => setQueue(q => q.filter(x => x.i !== e.i)) + }] + }, /*#__PURE__*/React.createElement(LedgerRow, { + gutter: pad2(e.i), + title: e.name, + meta: `${pad2(e.i)} · ${e.spec}`, + chevron: true, + onClick: () => {}, + art: { + icon: e.icon, + slot: `up-${slugify(e.name)}`, + label: e.name + } + }))), /*#__PURE__*/React.createElement(AddRow, { + onClick: onAddExercise + }), /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onFinish, + style: { + width: '100%', + height: 'var(--control-md)', + marginTop: 26, + cursor: 'pointer', + background: 'transparent', + border: '1px solid var(--border-strong)', + color: 'var(--text-secondary)', + borderRadius: 'var(--radius-button)', + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-subheadline)', + fontWeight: 'var(--weight-semibold)' + } + }, "Finish workout"))), /*#__PURE__*/React.createElement(GlassDock, { + lift: 16 + }, /*#__PURE__*/React.createElement(PrimaryAction, { + icon: complete ? 'arrow-right' : 'check', + onClick: complete ? undefined : logSet + }, complete ? 'Next exercise' : `Log ${kg(cur.load)} × ${cur.reps}`), /*#__PURE__*/React.createElement(DockButton, { + icon: "timer", + label: "Start rest timer", + onClick: () => { + setRemaining(90); + setResting(true); + } + }), /*#__PURE__*/React.createElement(DockButton, { + icon: "plus", + label: "Add exercise", + onClick: onAddExercise + }))); +} +Object.assign(window, { + SessionScreen +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/SessionScreen.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/SettingsScreen.jsx +try { (() => { +const { + Card, + SectionHeader, + Divider, + SettingsRow, + Button, + WeightUnitToggle, + Icon +} = window.BetterFitDesignSystem_6a70ea; +function SettingsScreen({ + onBack, + scheme, + onScheme +}) { + const [reminders, setReminders] = React.useState(true); + const [health, setHealth] = React.useState(true); + const [autoTrack, setAutoTrack] = React.useState(false); + const [unit, setUnit] = React.useState('lb'); + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StatusBar, null), /*#__PURE__*/React.createElement(NavBar, { + title: "Settings", + leading: /*#__PURE__*/React.createElement(Button, { + variant: "ghost", + size: "sm", + fullWidth: false, + icon: "caret-left", + onClick: onBack + }, "Back") + }), /*#__PURE__*/React.createElement(Scroll, null, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + flexDirection: 'column', + gap: 20 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + flexDirection: 'column', + gap: 10 + } + }, /*#__PURE__*/React.createElement(SectionHeader, { + title: "Appearance" + }), /*#__PURE__*/React.createElement(Card, null, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between' + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 'var(--text-body)' + } + }, "Theme"), /*#__PURE__*/React.createElement(WeightUnitToggle, { + value: scheme === 'dark' ? 'Dark' : 'Light', + options: ['Light', 'Dark'], + onChange: v => onScheme(v === 'Dark' ? 'dark' : 'light') + })), /*#__PURE__*/React.createElement(Divider, { + style: { + margin: '12px 0' + } + }), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-secondary)', + textWrap: 'pretty' + } + }, "BetterFit has one accent. Light and dark mirror the same two colours \u2014 there is nothing else to pick."))), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + flexDirection: 'column', + gap: 10 + } + }, /*#__PURE__*/React.createElement(SectionHeader, { + title: "Training" + }), /*#__PURE__*/React.createElement(Card, null, /*#__PURE__*/React.createElement(SettingsRow, { + kind: "static", + icon: "scales", + title: "Weight units", + value: unit === 'lb' ? 'Pounds' : 'Kilograms' + }), /*#__PURE__*/React.createElement(Divider, null), /*#__PURE__*/React.createElement(SettingsRow, { + kind: "toggle", + icon: "pulse", + title: "Auto-track sets", + subtitle: "Detect sets from Apple Watch motion", + checked: autoTrack, + onChange: setAutoTrack + }), /*#__PURE__*/React.createElement(Divider, null), /*#__PURE__*/React.createElement(SettingsRow, { + kind: "toggle", + icon: "heartbeat", + title: "Apple Health", + subtitle: "Read workouts and resting heart rate", + checked: health, + onChange: setHealth + }))), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + flexDirection: 'column', + gap: 10 + } + }, /*#__PURE__*/React.createElement(SectionHeader, { + title: "Notifications" + }), /*#__PURE__*/React.createElement(Card, null, /*#__PURE__*/React.createElement(SettingsRow, { + kind: "toggle", + icon: "bell", + title: "Workout reminders", + subtitle: "Weekdays, 6:30 pm", + checked: reminders, + onChange: setReminders + }), /*#__PURE__*/React.createElement(Divider, null), /*#__PURE__*/React.createElement(SettingsRow, { + kind: "static", + icon: "timer", + title: "Default rest", + value: "90s" + }))), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + flexDirection: 'column', + gap: 10 + } + }, /*#__PURE__*/React.createElement(SectionHeader, { + title: "About" + }), /*#__PURE__*/React.createElement(Card, null, /*#__PURE__*/React.createElement(SettingsRow, { + kind: "static", + title: "Version", + value: "1.8.2" + }), /*#__PURE__*/React.createElement(Divider, null), /*#__PURE__*/React.createElement(SettingsRow, { + kind: "link", + title: "Privacy policy" + }), /*#__PURE__*/React.createElement(Divider, null), /*#__PURE__*/React.createElement(SettingsRow, { + kind: "link", + title: "Licences" + }))), /*#__PURE__*/React.createElement(Button, { + variant: "destructive" + }, "Sign out")))); +} +Object.assign(window, { + SettingsScreen +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/SettingsScreen.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/SignInScreen.jsx +try { (() => { +const { + Button, + Logo +} = window.BetterFitDesignSystem_6a70ea; +function SignInScreen({ + onSignIn, + onGuest +}) { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StatusBar, null), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + display: 'flex', + flexDirection: 'column' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + display: 'grid', + placeItems: 'center', + padding: 24 + } + }, /*#__PURE__*/React.createElement("div", { + style: { + textAlign: 'center' + } + }, /*#__PURE__*/React.createElement(Logo, { + variant: "wordmark", + height: 200, + assetBase: "../../assets", + style: { + margin: '0 auto', + borderRadius: 28 + } + }), /*#__PURE__*/React.createElement("div", { + style: { + marginTop: 24, + fontSize: 'var(--text-title-3)', + color: 'var(--text-secondary)' + } + }, "Your strength training coach"))), /*#__PURE__*/React.createElement("div", { + style: { + padding: '0 24px 12px', + display: 'flex', + flexDirection: 'column', + gap: 12 + } + }, /*#__PURE__*/React.createElement(Button, { + icon: "apple-logo", + onClick: onSignIn + }, "Continue with Apple"), /*#__PURE__*/React.createElement(Button, { + variant: "secondary", + icon: "google-logo", + onClick: onSignIn + }, "Sign in with Google"), /*#__PURE__*/React.createElement(Button, { + variant: "secondary", + icon: "envelope", + onClick: onSignIn + }, "Sign in with email"), /*#__PURE__*/React.createElement(Button, { + variant: "ghost", + onClick: onGuest + }, "Continue as guest")), /*#__PURE__*/React.createElement("div", { + style: { + padding: '4px 40px 28px', + textAlign: 'center', + fontSize: 'var(--text-caption)', + color: 'var(--text-secondary)', + textWrap: 'pretty' + } + }, "We value your privacy. Guest mode stores data on this device only."))); +} +Object.assign(window, { + SignInScreen +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/SignInScreen.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/SummaryScreen.jsx +try { (() => { +const { + Icon +} = dsPick(['Icon']); +const RESULTS = [{ + i: 1, + name: 'Lat pulldown', + best: '50 kg × 8', + sets: '3 of 3', + vol: '1.2k' +}, { + i: 2, + name: 'Cable row', + best: '64 kg × 8', + sets: '4 of 4', + vol: '2.0k', + pr: true +}, { + i: 3, + name: 'Barbell curl', + best: '30 kg × 8', + sets: '4 of 4', + vol: '960' +}, { + i: 4, + name: 'Hammer curls', + best: '15 kg × 12', + sets: '3 of 3', + vol: '540' +}, { + i: 5, + name: 'Face pull', + best: '18 kg × 15', + sets: '3 of 3', + vol: '810' +}, { + i: 6, + name: 'Reverse fly', + best: '10 kg × 12', + sets: '2 of 3', + vol: '240', + short: true +}, { + i: 7, + name: 'Cable wood chop', + best: '20 kg × 15', + sets: '3 of 3', + vol: '900' +}]; +const pad2 = n => String(n).padStart(2, '0'); +function SummaryScreen({ + onBack, + onAddExercise +}) { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StatusBar, null), /*#__PURE__*/React.createElement("div", { + style: { + flex: '0 0 auto', + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '0 16px 12px' + } + }, /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onBack, + "aria-label": "Back", + style: { + background: 'none', + border: 'none', + cursor: 'pointer', + color: 'var(--text-secondary)', + width: 44, + height: 44, + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "caret-left", + size: 20, + weight: "bold" + })), /*#__PURE__*/React.createElement("span", { + style: { + flex: 1 + } + }), /*#__PURE__*/React.createElement("button", { + type: "button", + "aria-label": "Share summary", + style: { + background: 'none', + border: 'none', + cursor: 'pointer', + color: 'var(--text-secondary)', + width: 44, + height: 44, + display: 'grid', + placeItems: 'center' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "export", + size: 20, + weight: "bold" + }))), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + overflowY: 'auto', + overflowX: 'hidden', + scrollbarWidth: 'none', + msOverflowStyle: 'none' + } + }, /*#__PURE__*/React.createElement(Slab, null, /*#__PURE__*/React.createElement(Eyebrow, { + style: { + opacity: 0.6 + } + }, "Tue 26 Jul \xB7 Session complete"), /*#__PURE__*/React.createElement("h1", { + className: "bf-display", + style: { + margin: '10px 0 0', + fontSize: 44, + lineHeight: 0.94 + } + }, "Pull Day"), /*#__PURE__*/React.createElement(SpecStrip, { + items: [{ + value: "44'", + label: 'Duration' + }, { + value: '6.7k', + label: 'Volume kg' + }, { + value: '22', + label: 'Sets' + }] + }), /*#__PURE__*/React.createElement("div", { + style: { + marginTop: 18, + paddingTop: 16, + borderTop: '1.5px solid rgba(0,0,0,.2)', + fontSize: 'var(--text-subheadline)', + fontWeight: 'var(--weight-semibold)', + maxWidth: 300, + textWrap: 'pretty' + } + }, "Strongest pull session this month. Cable row went up 4 kg.")), /*#__PURE__*/React.createElement("div", { + style: { + padding: '0 20px 190px' + } + }, /*#__PURE__*/React.createElement(SectionRule, { + label: "New records", + trailing: "1" + }), /*#__PURE__*/React.createElement(LedgerRow, { + gutter: /*#__PURE__*/React.createElement(Icon, { + name: "medal", + size: 15, + weight: "bold" + }), + gutterAccent: true, + title: "Cable row", + meta: "Previous best 60 kg \xD7 8", + trailing: "64 kg", + trailingMeta: "\xD7 8" + }), /*#__PURE__*/React.createElement(SectionRule, { + label: "What you lifted", + trailing: `${RESULTS.length} exercises` + }), RESULTS.map(r => /*#__PURE__*/React.createElement(LedgerRow, { + key: r.i, + gutter: pad2(r.i), + gutterAccent: r.pr, + title: r.name, + meta: `Best ${r.best} · ${r.sets} sets${r.short ? ' · cut short' : ''}`, + trailing: r.vol, + trailingMeta: "kg" + })), /*#__PURE__*/React.createElement(AddRow, { + onClick: onAddExercise, + label: "Add something you did off plan" + }), /*#__PURE__*/React.createElement(SectionRule, { + label: "Effect on recovery" + }), [{ + m: 'Lats', + from: 96, + to: 41 + }, { + m: 'Biceps', + from: 81, + to: 46 + }, { + m: 'Rear delts', + from: 78, + to: 52 + }].map(x => /*#__PURE__*/React.createElement("div", { + key: x.m, + style: { + padding: '13px 0', + borderTop: '1px solid var(--separator)' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'baseline', + gap: 12 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + flex: 1, + fontSize: 'var(--text-body)', + fontWeight: 'var(--weight-semibold)' + } + }, x.m), /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-tertiary)' + } + }, x.from, "%"), /*#__PURE__*/React.createElement(Icon, { + name: "arrow-right", + size: 12, + weight: "bold", + color: "var(--text-tertiary)" + }), /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 'var(--text-subheadline)', + fontWeight: 'var(--weight-semibold)' + } + }, x.to, "%")), /*#__PURE__*/React.createElement(Bar, { + pct: x.to, + color: "var(--recovery-fatigued)", + style: { + marginTop: 10 + } + }))), /*#__PURE__*/React.createElement(SectionRule, { + label: "Next" + }), /*#__PURE__*/React.createElement(LedgerRow, { + gutter: /*#__PURE__*/React.createElement(Icon, { + name: "calendar-blank", + size: 15, + weight: "bold" + }), + title: "Legs A", + meta: "Thursday \xB7 8 exercises", + chevron: true, + onClick: () => {} + }))), /*#__PURE__*/React.createElement(GlassDock, { + lift: 16 + }, /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onBack, + style: { + flex: 1, + height: 'var(--control-lg)', + cursor: 'pointer', + borderRadius: 16, + background: 'var(--glass-highlight)', + border: '1px solid var(--glass-border)', + color: 'var(--text-primary)', + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-headline)', + fontWeight: 'var(--weight-semibold)' + } + }, "Done"), /*#__PURE__*/React.createElement(DockButton, { + icon: "export", + label: "Share summary" + }))); +} +Object.assign(window, { + SummaryScreen +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/SummaryScreen.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/TargetsScreen.jsx +try { (() => { +const { + Icon +} = dsPick(['Icon']); +const D = window.BFData; +const SESSION_ICON = name => /pull/i.test(name) ? 'arrow-fat-line-down' : /push/i.test(name) ? 'arrows-in-line-horizontal' : /leg/i.test(name) ? 'person-simple-run' : /cond|cardio|interval/i.test(name) ? 'heartbeat' : /rest/i.test(name) ? 'moon' : 'barbell'; +function TargetRow({ + t +}) { + const pct = Math.round(t.value / t.target * 100); + return /*#__PURE__*/React.createElement("div", { + style: { + padding: '14px 0', + borderTop: '1px solid var(--separator)' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'baseline', + gap: 12 + } + }, /*#__PURE__*/React.createElement("span", { + style: { + flex: 1, + fontSize: 'var(--text-body)', + fontWeight: 'var(--weight-semibold)' + } + }, t.label), /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 'var(--text-subheadline)', + fontWeight: 'var(--weight-semibold)' + } + }, t.value, t.unit), /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 'var(--text-footnote)', + color: 'var(--text-tertiary)' + } + }, "/ ", t.target, t.unit)), /*#__PURE__*/React.createElement(Bar, { + pct: pct, + style: { + marginTop: 10 + } + }), /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + fontSize: 'var(--text-caption)', + color: 'var(--text-secondary)', + marginTop: 7 + } + }, pct, "% of the week")); +} +function TargetsScreen() { + const w = D.targets[0]; + const left = w.target - w.value; + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(StatusBar, null), /*#__PURE__*/React.createElement(LedgerHead, { + title: "Targets", + right: /*#__PURE__*/React.createElement("button", { + type: "button", + "aria-label": "Edit targets", + style: { + background: 'none', + border: 'none', + cursor: 'pointer', + color: 'var(--accent-text)', + minWidth: 44, + height: 44, + fontFamily: 'var(--font-ui)', + fontSize: 'var(--text-subheadline)', + fontWeight: 'var(--weight-semibold)' + } + }, "Edit") + }), /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + overflowY: 'auto', + overflowX: 'hidden', + scrollbarWidth: 'none', + msOverflowStyle: 'none' + } + }, /*#__PURE__*/React.createElement(Slab, null, /*#__PURE__*/React.createElement(Eyebrow, { + style: { + opacity: 0.6 + } + }, "This week"), /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + display: 'flex', + alignItems: 'baseline', + gap: 8, + marginTop: 10, + whiteSpace: 'nowrap' + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 68, + fontWeight: 'var(--weight-bold)', + lineHeight: 0.86, + letterSpacing: '-0.03em' + } + }, w.value), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 26, + fontWeight: 'var(--weight-bold)', + opacity: 0.5 + } + }, "/\xA0", w.target)), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 'var(--text-body)', + fontWeight: 'var(--weight-semibold)', + marginTop: 14, + maxWidth: 280, + textWrap: 'pretty' + } + }, "Workouts done. ", left === 1 ? 'One more reaches your goal.' : `${left} more reach your goal.`), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + gap: 5, + marginTop: 18 + } + }, Array.from({ + length: w.target + }).map((_, i) => /*#__PURE__*/React.createElement("span", { + key: i, + style: { + flex: 1, + height: 8, + background: i < w.value ? 'var(--bf-black)' : 'rgba(0,0,0,.22)' + } + })))), /*#__PURE__*/React.createElement("div", { + style: { + padding: '0 20px 110px' + } + }, /*#__PURE__*/React.createElement(SectionRule, { + label: "Weekly targets", + trailing: `${D.targets.length} set` + }), D.targets.map(t => /*#__PURE__*/React.createElement(TargetRow, { + key: t.label, + t: t + })), /*#__PURE__*/React.createElement(SectionRule, { + label: "The week", + trailing: "Mon\u2013Sun" + }), D.week.map(d => /*#__PURE__*/React.createElement(LedgerRow, { + key: d.day, + gutter: d.day.slice(0, 2).toUpperCase(), + gutterAccent: d.today, + title: d.name, + titleIcon: SESSION_ICON(d.name), + meta: d.today ? 'Today' : d.done ? 'Completed' : d.name === 'Rest' ? 'Scheduled rest' : 'Scheduled', + dim: d.name === 'Rest', + trailing: d.done ? /*#__PURE__*/React.createElement(Icon, { + name: "check", + size: 16, + weight: "bold", + color: "var(--accent-text)" + }) : null + })), /*#__PURE__*/React.createElement(SectionRule, { + label: "Streak" + }), /*#__PURE__*/React.createElement(LedgerRow, { + gutter: /*#__PURE__*/React.createElement(Icon, { + name: "flame", + size: 15, + weight: "bold" + }), + title: "Current streak", + meta: `Best is ${D.streak.best} days`, + trailing: `${D.streak.current} days` + }), /*#__PURE__*/React.createElement(SectionRule, { + label: "Records this month", + trailing: `${D.prs.length}` + }), D.prs.map(p => /*#__PURE__*/React.createElement(LedgerRow, { + key: p.name, + gutter: /*#__PURE__*/React.createElement(Icon, { + name: "medal", + size: 15, + weight: "bold" + }), + title: p.name, + meta: p.when, + trailing: p.value + }))))); +} +Object.assign(window, { + TargetsScreen +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/TargetsScreen.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/data.js +try { (() => { +window.BFData = { + user: { + name: 'Johnny', + since: 'Member since Jan 2024', + plan: 'Pro' + }, + today: { + name: 'Pull B', + focus: 'Today · Tue 26 Jul', + exercises: 5, + duration: '42 min', + volume: '14.2k lb', + muscles: ['Lats', 'Biceps', 'Rear delts'] + }, + muscleSplit: [{ + muscle: 'Lats', + percent: 38 + }, { + muscle: 'Biceps', + percent: 27 + }, { + muscle: 'Rear delts', + percent: 21 + }, { + muscle: 'Core', + percent: 14 + }], + plan: [{ + index: 1, + name: 'Trap bar deadlift', + prescription: '4 × 5 · 245 lb', + muscles: 'Back, Glutes', + state: 'planned' + }, { + index: 2, + name: 'Barbell row', + prescription: '4 × 8 · 135 lb', + muscles: 'Lats, Rear delts', + state: 'planned' + }, { + index: 3, + name: 'Lat pulldown', + prescription: '3 × 10 · 120 lb', + muscles: 'Lats', + state: 'planned' + }, { + index: 4, + name: 'EZ curl', + prescription: '3 × 12 · 65 lb', + muscles: 'Biceps', + state: 'planned' + }, { + index: 5, + name: 'Face pull', + prescription: '3 × 15 · 40 lb', + muscles: 'Rear delts', + state: 'planned' + }], + recovery: { + overall: 72, + regions: [{ + region: 'Chest', + status: 'sore', + percent: 34 + }, { + region: 'Back', + status: 'recovered', + percent: 96 + }, { + region: 'Shoulders', + status: 'fatigued', + percent: 58 + }, { + region: 'Arms', + status: 'fresh', + percent: 81 + }, { + region: 'Core', + status: 'recovered', + percent: 92 + }, { + region: 'Legs', + status: 'fresh', + percent: 77 + }] + }, + streak: { + current: 12, + best: 21 + }, + heatmap: Array.from({ + length: 182 + }, (_, i) => i % 7 === 2 || i % 7 === 6 ? 0 : Math.round(Math.abs(Math.sin(i * 0.63)) * 4)), + prs: [{ + name: 'Trap bar deadlift', + value: '345 lb', + when: '2 weeks ago' + }, { + name: 'Bench press', + value: '205 lb', + when: 'Last month' + }, { + name: 'Back squat', + value: '285 lb', + when: 'Last month' + }], + targets: [{ + label: 'Workouts', + value: 3, + target: 5, + unit: '' + }, { + label: 'Volume', + value: 41, + target: 60, + unit: 'k lb' + }, { + label: 'Active minutes', + value: 186, + target: 250, + unit: '' + }], + achievements: [{ + icon: 'medal', + title: 'Ten in a row', + done: true + }, { + icon: 'barbell', + title: 'Bodyweight bench', + done: true + }, { + icon: 'sun-horizon', + title: 'Five 6am sessions', + done: false + }, { + icon: 'mountains', + title: '100k lb month', + done: false + }], + searchResults: [{ + icon: 'barbell', + title: 'Bench press', + subtitle: 'Chest · Barbell' + }, { + icon: 'barbell', + title: 'Incline bench press', + subtitle: 'Chest · Barbell' + }, { + icon: 'person-simple-run', + title: 'Treadmill intervals', + subtitle: 'Cardio · Machine' + }, { + icon: 'barbell', + title: 'Bent-over row', + subtitle: 'Back · Barbell' + }, { + icon: 'gear-six', + title: 'Weight units', + subtitle: 'Setting · Preferences' + }], + week: [{ + day: 'Mon', + name: 'Push A', + done: true + }, { + day: 'Tue', + name: 'Pull B', + done: false, + today: true + }, { + day: 'Wed', + name: 'Rest', + done: false + }, { + day: 'Thu', + name: 'Legs A', + done: false + }, { + day: 'Fri', + name: 'Push B', + done: false + }, { + day: 'Sat', + name: 'Conditioning', + done: false + }, { + day: 'Sun', + name: 'Rest', + done: false + }] +}; +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/data.js", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/image-slot.js +try { (() => { +// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) +// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected). +/* BEGIN USAGE */ +/** + * <image-slot> — user-fillable image placeholder. + * + * Drop this into a deck, mockup, or page wherever a design needs an image. + * You control the slot's shape; it sizes to its container by default. When the search_stock_photos tool + * is available, prefill the slot by default — write the photo's URL into + * src (with credit/credit-href); the user can still fill or replace it + * by dragging an image file onto it (or clicking to browse). The dropped + * image persists across reloads via a .image-slots.state.json sidecar — + * same read-via-fetch / write-via-window.omelette pattern as + * design_canvas.jsx, so the filled slot shows on share links, downloaded + * zips, and PPTX export. Outside the omelette runtime the slot is read-only. + * + * The sidecar is a SIBLING of the HTML file that uses this component: the + * read is a document-relative fetch, and the host resolves the bridge's + * sidecar writes into the previewed file's directory to match (same + * contract as design_canvas.jsx). Pages in the same directory share one + * sidecar; keep slot ids distinct across them. + * + * Attributes: + * id Persistence key. REQUIRED for the drop to survive reload — + * every slot on the page needs a distinct id. + * shape 'rect' | 'rounded' | 'circle' | 'pill' (default 'rounded') + * 'circle' applies 50% border-radius; on a non-square slot + * that's an ellipse — set equal width and height for a true + * circle. + * radius Corner radius in px for 'rounded'. (default 12) + * mask Any CSS clip-path value. Overrides `shape` — use this for + * hexagons, blobs, arbitrary polygons. + * fit Initial framing baseline: cover | contain. (default 'cover') + * cover starts the image filling the frame (overflow cropped); + * contain starts it fully visible (letterboxed). Either way the + * user can always pan/scale from there — double-click, or the + * Edit control, enters reframe mode (drag to move, scroll or + * corner-handles to scale; Escape / click-out commits). The + * crop persists alongside the image in the sidecar. + * placeholder Empty-state caption. (default 'Drop an image') + * src Optional initial/fallback image URL. Prefill it with a real + * photo via search_stock_photos when that tool is available + * (set credit/credit-href from the result). A user drop + * overrides it; clearing the drop reveals src again. + * credit Attribution text shown as a small overlay at the + * bottom-left of the filled slot. REQUIRED whenever src + * points at any Unsplash host (images.unsplash.com, + * plus.unsplash.com, …): an Unsplash src with no credit + * renders an error tile INSTEAD of the photo (Unsplash + * terms forbid showing their photos unattributed). Use the + * exact form 'Photo by {photographer name} on Unsplash' — + * the overlay then links the name to credit-href and + * 'Unsplash' to the Unsplash homepage, and links back to + * unsplash.com automatically get the required utm referral + * params appended at render time. The credit belongs to + * the src image, so it only shows while src is what's + * displayed — a user-dropped image hides it. + * credit-href Link for the photographer's name in the credit overlay + * (their Unsplash profile URL from the stock-photo search + * results). http(s) URLs only — anything else renders the + * name as plain text. + * + * Sizing: the slot fills its container by default (width/height 100%). + * Put it in a sized wrapper — absolutely positioned, a grid cell, a fixed + * frame — and it takes exactly that box. When the parent's height is + * indefinite (ordinary flow), it falls back to full width at a 3:2 aspect + * ratio instead of collapsing. In a shrink-to-fit parent (a float, + * width:max-content, an unsized absolute wrapper), percentages have + * nothing to resolve against — size the slot or its wrapper explicitly + * there. For a fixed-size slot, set + * width/height on the element itself (inline style), which overrides the + * default. When + * layering content above a slot (full-bleed layouts), make the overlay + * click-through — pointer-events: none on scrims/text plates, re-enabled + * on interactive children — so the slot's hover controls stay reachable. + * Keep the slot's bottom-left corner visually clear as well: the credit + * overlay renders there, and a dark fade or text plate covering it hides + * the attribution Unsplash's terms require — end the fade above that + * corner, or keep it nearly transparent where the credit sits. + * + * Usage: + * <div style="position:relative;width:100%;height:100%"> <!-- full-bleed: --> + * <image-slot id="bg" shape="rect"></image-slot> <!-- fills the wrapper --> + * </div> + * <image-slot id="hero" style="width:800px;height:450px" shape="rounded" radius="20" + * placeholder="Drop a hero image"></image-slot> + * <image-slot id="avatar" style="width:120px;height:120px" shape="circle"></image-slot> + * <image-slot id="kite" style="width:300px;height:300px" + * mask="polygon(50% 0, 100% 50%, 50% 100%, 0 50%)"></image-slot> + */ +/* END USAGE */ + +(() => { + const STATE_FILE = '.image-slots.state.json'; + + // Unsplash terms require visible attribution wherever their photos + // display, and every link back to unsplash.com must carry utm referral + // params. Two render-time rules enforce that here: + // - an Unsplash-src slot with NO credit attribute renders an error + // tile INSTEAD of the photo (an uncredited Unsplash photo on screen + // is itself the terms violation, so it never renders bare); + // - rendered credit links pointing at unsplash.com get the referral + // params appended when absent (credit-href values live in page + // content that can't be edited after the fact). + // Keep the utm_source value in sync with UTM_SOURCE in + // platform/web-agent/unsplash.ts — this file is a project-local + // artifact and cannot import it (equality is pinned by tests). + const UNSPLASH_HOMEPAGE_HREF = 'https://unsplash.com/?utm_source=claude_design&utm_medium=referral'; + // Host rule mirrors the hotlink validator that admits Unsplash srcs into + // pages in the first place (cdn$ in unsplash.ts: apex or any subdomain) + // — Unsplash+ results serve from plus.unsplash.com, not just images.*, + // and an admitted-but-uncredited photo must error whatever unsplash + // host it rides on. + // Trailing-dot FQDNs (images.unsplash.com.) are the same host to the + // browser but would miss the regex — strip one dot so the check fails + // CLOSED (unrecognized-but-real Unsplash srcs must error, not render). + const isUnsplashHost = u => { + try { + return /(^|\.)unsplash\.com$/.test(new URL(u, document.baseURI).hostname.replace(/\.$/, '')); + } catch { + return false; + } + }; + // Render-time referral normalization for links back to Unsplash: + // appends utm_source/utm_medium when absent, preserves every existing + // query param, never overwrites an existing utm_source, and passes + // non-Unsplash URLs through untouched. Input is an ABSOLUTE validated + // http(s) URL (the credit render funnel resolves + validates first). + const withReferral = href => { + try { + const u = new URL(href); + if (!/(^|\.)unsplash\.com$/.test(u.hostname.replace(/\.$/, ''))) { + return href; + } + if (!u.searchParams.has('utm_source')) { + u.searchParams.set('utm_source', 'claude_design'); + } + if (!u.searchParams.has('utm_medium')) { + u.searchParams.set('utm_medium', 'referral'); + } + return u.toString(); + } catch (e) { + return href; + } + }; + // 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the + // sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB. + const MAX_DIM = 1200; + // Raster formats only. SVG is excluded (can carry script; createImageBitmap + // on SVG blobs is inconsistent). GIF is excluded because the canvas + // re-encode keeps only the first frame, so an animated GIF would silently + // go still — better to reject than surprise. + const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif']; + + // ── Shared sidecar store ──────────────────────────────────────────────── + // One fetch + immediate write-on-change for every <image-slot> on the + // page. Reads via fetch() so viewing works anywhere the HTML and sidecar + // are served together; writes go through window.omelette.writeFile, which + // the host allowlists to *.state.json basenames only. + const subs = new Set(); + let slots = {}; + // ids explicitly cleared before the sidecar fetch resolved — otherwise + // the merge below can't tell "never set" from "just deleted" and would + // resurrect the sidecar's stale value. + const tombstones = new Set(); + let loaded = false; + let loadP = null; + function load() { + if (loadP) return loadP; + loadP = fetch(STATE_FILE).then(r => r.ok ? r.json() : null).then(j => { + // Merge: sidecar loses to any in-memory change that raced ahead of + // the fetch (drop or clear) so neither is clobbered by hydration. + if (j && typeof j === 'object') { + const merged = Object.assign({}, j, slots); + // A framing-only write that raced ahead of hydration must not + // drop a user image that's only on disk — inherit u from the + // sidecar for any in-memory entry that lacks one. + for (const k in slots) { + if (merged[k] && !merged[k].u && j[k]) { + merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u; + } + } + for (const id of tombstones) delete merged[id]; + slots = merged; + } + tombstones.clear(); + }).catch(() => {}).then(() => { + loaded = true; + subs.forEach(fn => fn()); + }); + return loadP; + } + + // Serialize writes so two near-simultaneous drops on different slots + // can't reorder at the backend and leave the sidecar with only the + // first. A save requested mid-flight just marks dirty and re-fires on + // completion with the then-current slots. + let saving = false; + let saveDirty = false; + // Unload-time flush: save()'s serialization defers a mid-RTT re-fire to a + // .then that never runs in an unloading document, silently dropping a + // pagehide commit. Post the current slots immediately instead — content + // is a superset snapshot of any in-flight save's, the write is a + // whole-file last-writer-wins replace, and postMessage FIFO delivers it + // to the host after the in-flight one, so a backend-side reorder at + // worst reproduces the dropped-commit outcome this flush improves on. + // Guarded on the initial sidecar read: pre-hydration slots can miss + // other slots' persisted entries, and flushing it would clobber them — + // that narrow case stays best-effort (the in-memory merge in load() + // cannot happen in an unloading document anyway). + function flushNow() { + if (!loaded) return; + const w = window.omelette && window.omelette.writeFile; + if (!w) return; + try { + Promise.resolve(w(STATE_FILE, JSON.stringify(slots))).catch(() => {}); + } catch (e) {} + } + function save() { + if (saving) { + saveDirty = true; + return; + } + const w = window.omelette && window.omelette.writeFile; + if (!w) return; + saving = true; + Promise.resolve(w(STATE_FILE, JSON.stringify(slots))).catch(() => {}).then(() => { + saving = false; + if (saveDirty) { + saveDirty = false; + save(); + } + }); + } + const S_MAX = 5; + const clampS = s => Math.max(1, Math.min(S_MAX, s)); + + // Normalize a stored slot value. Pre-reframe sidecars stored a bare + // data-URL string; newer ones store {u, s, x, y}. Either shape is valid. + function getSlot(id) { + const v = slots[id]; + if (!v) return null; + return typeof v === 'string' ? { + u: v, + s: 1, + x: 0, + y: 0 + } : v; + } + function setSlot(id, val) { + if (!id) return; + if (val) { + slots[id] = val; + tombstones.delete(id); + } else { + delete slots[id]; + if (!loaded) tombstones.add(id); + } + subs.forEach(fn => fn()); + // A drop is rare + high-value — write immediately so nav-away can't lose + // it. Gate on the initial read so we don't overwrite a sidecar we haven't + // merged yet; the merge in load() keeps this change once the read lands. + if (loaded) save();else load().then(save); + } + + // ── Image downscale ───────────────────────────────────────────────────── + // Encode through a canvas so the sidecar carries resized bytes, not the + // raw upload. Longest side is capped at 2× the slot's rendered width + // (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG + // for photos, so there's no need for per-image format picking. + async function toDataUrl(file, targetW) { + const bitmap = await createImageBitmap(file); + try { + const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM); + const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height)); + const w = Math.max(1, Math.round(bitmap.width * scale)); + const h = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h); + return canvas.toDataURL('image/webp', 0.85); + } finally { + bitmap.close && bitmap.close(); + } + } + + // ── Custom element ────────────────────────────────────────────────────── + const stylesheet = + // Fill the container by default: slots are usually placed inside a + // sized wrapper (a hero frame, a grid cell, an inset:0 layer) and are + // expected to take that box — a fixed intrinsic size would render as + // a small tile in the corner of a full-bleed wrapper instead. + // aspect-ratio is the companion fallback that keeps a bare slot + // visible when the parent's height is indefinite: height:100% + // resolves to auto there, and the ratio then derives height from + // width instead of letting the slot collapse to zero height. + // Explicit width/height on the element override all of this. + // color:inherit (not a fixed near-black): the placeholder chrome — + // empty-state icon/caption (currentColor) and the dashed ring — must + // read on dark decks too, and the slide's own text color is the one + // color guaranteed to contrast with the slide background. The soft + // look comes from opacity on those parts, not from a baked-in alpha. + ':host{display:block;position:relative;' + ' font:13px/1.3 system-ui,-apple-system,sans-serif;' + ' width:100%;height:100%;aspect-ratio:3/2}' + '.empty .cap,.empty .sub{opacity:.75}' + '.frame{position:absolute;inset:0;overflow:hidden;background:rgba(127,127,127,.08)}' + + // .frame img (clipped) and .spill (unclipped ghost + handles) share the + // same left/top/width/height in frame-%, computed by _applyView(), so the + // inside-mask crop and the outside-mask spill stay pixel-aligned. + '.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' + ' -webkit-user-drag:none;user-select:none;touch-action:none}' + + // Reframe mode (double-click): the full image spills past the mask. The + // spill layer is sized to the IMAGE bounds so its corners are where the + // resize handles belong. The ghost <img> inside is translucent; the real + // clipped <img> underneath shows the opaque in-mask crop. + // popover=manual promotes the spill to the top layer on reframe, so it is + // not clipped by any overflow:hidden / clip-path / scroll-container + // ancestor (a plain z-index can't escape overflow clipping). UA popover + // defaults (inset:0;margin:auto) are reset; _applyView sets viewport px. + '.spill{position:fixed;margin:0;inset:auto;border:0;padding:0;background:transparent;' + ' overflow:visible;transform:translate(-50%,-50%);z-index:1;cursor:grab;touch-action:none}' + ':host([data-panning]) .spill{cursor:grabbing}' + '.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' + ' pointer-events:none;-webkit-user-drag:none;user-select:none;' + ' box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' + '.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' + ' background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' + ' transform:translate(-50%,-50%)}' + '.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' + '.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' + '.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' + '.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' + ':host([data-reframe]){z-index:10}' + ':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' + '.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' + ' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' + ' cursor:pointer;user-select:none}' + '.empty svg{opacity:.45}' + '.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' + '.empty .sub{font-size:11px}' + '.empty .sub u{text-underline-offset:2px}' + '.empty:hover .sub{opacity:1}' + ':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' + ' background:rgba(201,100,66,.10)}' + '.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed currentColor;' + ' opacity:.35;transition:border-color .12s,opacity .12s}' + ':host([data-over]) .ring{border-color:#c96442;opacity:1}' + ':host([data-filled]) .ring{display:none}' + + // Controls overlay INSIDE the frame, pinned to the top-right corner, so + // a full-bleed slot in an overflow:hidden container still shows them + // (the old below-mask placement got clipped). Credit sits bottom-left, + // so top-right avoids collision. The blurred pill background keeps them + // legible over the image. + // The UA [popover] base rule styles the element in EVERY state (only + // display:none is gated on :not(:popover-open), and the display:flex + // below overrides that) — so the UA resets live HERE, like .spill's, + // or the ordinary hover-state strip renders as a bordered Canvas box + // centered by margin:auto. inset:auto precedes top/right (shorthand). + '.ctl{position:absolute;inset:auto;top:8px;right:8px;margin:0;border:0;padding:0;' + ' background:transparent;overflow:visible;' + ' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' + ' white-space:nowrap}' + + // While reframing, the spill owns the top layer and would swallow every + // click on the in-frame controls. Promoting .ctl into the top layer + // ABOVE the spill (shown after it — later popovers stack higher) keeps + // Edit-as-toggle and Replace clickable mid-reframe. _applyView pins it + // to the frame's top-right in viewport px (translateX(-100%) + // right-aligns against the computed left edge); inset:auto clears the + // base rule's top/right so the inline left/top position it alone. + '.ctl:popover-open{position:fixed;inset:auto;transform:translateX(-100%)}' + ':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' + ' {opacity:1;pointer-events:auto}' + '.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' + ' background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' + ' backdrop-filter:blur(6px)}' + '.ctl button:hover{background:rgba(0,0,0,.8)}' + '.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' + ' background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}' + + // Replacement in flight: after a src swap the browser keeps painting + // the PREVIOUS image until the new one decodes, so a Replace would + // flash the old photo and then pop. Hide the stale frame (visibility, + // not display — _applyView geometry still applies) and spin until the + // new image reports in (load/error clears data-swapping). + ':host([data-swapping]) .frame img{visibility:hidden}' + '.loading{position:absolute;inset:0;display:none;align-items:center;' + ' justify-content:center;pointer-events:none}' + ':host([data-swapping]) .loading{display:flex}' + '.loading::after{content:"";width:22px;height:22px;border-radius:50%;' + ' border:2px solid rgba(127,127,127,.25);border-top-color:currentColor;' + ' animation:om-slot-spin .7s linear infinite}' + '@keyframes om-slot-spin{to{transform:rotate(360deg)}}' + + // Reduced motion: the static two-tone ring still reads as "working". + '@media (prefers-reduced-motion:reduce){.loading::after{animation:none}}' + '.credit{position:absolute;left:6px;bottom:6px;max-width:calc(100% - 12px);display:none;' + ' padding:3px 7px;border-radius:5px;background:rgba(0,0,0,.55);color:#fff;' + ' font:10px/1.2 system-ui,-apple-system,sans-serif;text-decoration:none;' + ' white-space:nowrap;overflow:hidden;text-overflow:ellipsis;backdrop-filter:blur(6px)}' + + // The credit is a SPAN holding one or two <a>s (Unsplash's prescribed + // form links the photographer AND Unsplash) — anchors style inline so + // the overlay reads as one line of text. + '.credit a{color:inherit;text-decoration:none}' + '.credit a:hover,.credit a:focus-visible{text-decoration:underline}' + ':host([data-filled][data-credit]) .credit{display:block}' + + // Exports must ship JUST the image — no hover controls, no credit chip + // (the host marks <html data-om-exporting> for the capture window; the + // page-level hide script can't reach shadow DOM, this rule can). + ':host-context([data-om-exporting]) .ctl,' + ':host-context([data-om-exporting]) .credit{display:none !important}' + + // Print must ship just the image too: the hover-gated controls can be + // mid-hover when print() fires, and the credit chip is screen chrome — + // the same rule the capture window gets, keyed on print media instead + // of the host's data-om-exporting mark (the print path sets no mark). + '@media print{.ctl,.credit{display:none !important}}' + + // No export-window mask rules here on purpose: the export capture + // releases the replacement mask by REMOVING data-swapping (the + // shadow-root pass in pages/export/shared.ts HIDE_EXPORT_CHROME_SCRIPT) + // — attribute removal works in every engine (:host-context is + // Chromium-only), is scoped by construction to slots actually + // mid-swap, and hides the spinner through the same gate. A masked img + // would otherwise be silently dropped from PPTX decks (the capture + // walk skips visibility:hidden imgs). + // Attribution error tile: REPLACES the photo when an Unsplash src has + // no credit attribute — rendering the photo uncredited is the terms + // violation, so the photo must not appear at all. + // Calm and neutral on purpose (review feedback): the tile informs the + // user; the fix instructions are machine-facing (usage docblock, tool + // description, and the turn-end scan's bounce copy name the attributes + // for the agent). + '.attr-error{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;' + ' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' + ' background:#f2f1ef;color:#6e6c66;user-select:none;' + ' font:13px/1.45 system-ui,-apple-system,sans-serif}' + '.attr-error svg{opacity:.55}' + '.attr-error .cap{max-width:92%;font-weight:500;letter-spacing:.01em}' + ':host([data-attribution-error]) .attr-error{display:flex}' + ':host([data-attribution-error]) .ring{display:none}'; + const icon = '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' + 'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>' + '<path d="m21 15-5-5L5 21"/></svg>'; + const warnIcon = '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' + 'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + '<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/>' + '<path d="M12 9v4"/><path d="M12 17h.01"/></svg>'; + class ImageSlot extends HTMLElement { + static get observedAttributes() { + return ['shape', 'radius', 'mask', 'fit', 'placeholder', 'src', 'id', 'credit', 'credit-href']; + } + + /** Duplicate-slide hook (called by deck-stage, see its + * _remintDuplicateIds): copy this id's stored image, if any, under a + * freshly minted key and return that key — so a duplicated slide's + * slot keeps its dropped photo instead of reverting to the + * placeholder. 'isFree' is the caller's uniqueness check (document + * ids); candidates must ALSO be unused in the sidecar, which can + * hold keys from other pages sharing the project root. (An EMPTY + * slot on another page leaves no sidecar entry, so its id is not + * detectable here — a minted key can collide with it and that slot + * would show this photo. Same blast radius as two pages reusing an + * id by hand, which the shared sidecar already permits.) Returns null + * when no id could be minted (caller strips the id, today's + * behavior). */ + static cloneSlot(fromId, isFree) { + if (typeof fromId !== 'string' || !fromId) return null; + // Pre-hydration the store can't veto candidates or source the copy + // — degrade to the strip (today's behavior) rather than mint + // against keys we can't see yet. Any rendered (= droppable) slot + // means load() has already settled. + if (!loaded) return null; + const stem = fromId.replace(/-\d+$/, '') || fromId; + for (let n = 2; n < 100; n++) { + const toId = stem + '-' + n; + if (toId === fromId) continue; + if (slots[toId] !== undefined) { + // Reuse a key holding this exact value (bytes AND crop) if no + // live element here owns it — a duplicate op the host refused + // after minting leaves such a key behind, and reusing keeps + // refused retries from accumulating one orphaned copy per + // attempt. Full equality (not just bytes) so a byte-identical + // key another PAGE owns with its own crop is stepped past, not + // adopted or rewritten. (Entries without .u never match.) + const prev = getSlot(toId); + const cur = getSlot(fromId); + if (!(prev && cur && prev.u && prev.u === cur.u && prev.s === cur.s && prev.x === cur.x && prev.y === cur.y && (typeof isFree !== 'function' || isFree(toId)))) continue; + return toId; + } + if (typeof isFree === 'function' && !isFree(toId)) continue; + const v = getSlot(fromId); + if (v) setSlot(toId, Object.assign({}, v)); + return toId; + } + return null; + } + constructor() { + super(); + // clonable: rail thumbnails deep-clone slides and carry this shadow + // along; reuse an already-cloned root so upgrade-after-clone works. + // (Deliberately NOT serializable — a getHTML consumer would embed + // multi-MB sidecar data-URLs into serialized page HTML.) + const root = this.shadowRoot || this.attachShadow({ + mode: 'open', + clonable: true + }); + // .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius + // on the frame (circle, pill, rounded) can't clip them. + root.innerHTML = '<style>' + stylesheet + '</style>' + '<div class="frame" part="frame">' + ' <img part="image" alt="" draggable="false" style="display:none">' + ' <div class="empty" part="empty">' + icon + ' <div class="cap"></div>' + ' <div class="sub">or <u>browse files</u></div></div>' + ' <div class="attr-error" part="attribution-error">' + warnIcon + ' <div class="cap">This photo needs attribution</div></div>' + ' <div class="loading" part="loading"></div>' + ' <div class="ring" part="ring"></div>' + '</div>' + + // Outside .frame, like .spill/.ctl — the frame's overflow:hidden + + // border-radius/clip-path would cut the credit off on circle/pill/mask. + // A SPAN, not an <a>: the prescribed Unsplash credit holds two links + // (photographer + Unsplash), built per-render in _render(). + '<span class="credit" part="credit"></span>' + '<div class="spill" popover="manual" data-dc-edit-transparent>' + ' <img class="ghost" alt="" draggable="false">' + ' <div class="handle" data-c="nw"></div><div class="handle" data-c="ne"></div>' + ' <div class="handle" data-c="sw"></div><div class="handle" data-c="se"></div>' + '</div>' + + // data-dc-edit-transparent: the DC editor's edit-mode picker lets + // clicks through for chrome marked with it (EDIT_TRANSPARENT_SEL) + // — without it, Replace/Edit clicks in Edit mode are swallowed by + // element selection and the controls look dead. + '<div class="ctl" popover="manual" data-dc-edit-transparent><button data-act="replace" title="Replace image">Replace</button>' + ' <button data-act="edit" title="Reframe image">Edit</button></div>' + '<input type="file" accept="' + ACCEPT.join(',') + '" hidden>'; + this._frame = root.querySelector('.frame'); + this._ring = root.querySelector('.ring'); + this._img = root.querySelector('.frame img'); + this._empty = root.querySelector('.empty'); + this._cap = root.querySelector('.cap'); + this._sub = root.querySelector('.sub'); + this._spill = root.querySelector('.spill'); + this._ctl = root.querySelector('.ctl'); + this._credit = root.querySelector('.credit'); + this._attrError = root.querySelector('.attr-error'); + // Credit clicks open the link, not browse/reframe. + this._credit.addEventListener('click', e => e.stopPropagation()); + this._credit.addEventListener('dblclick', e => e.stopPropagation()); + this._ghost = root.querySelector('.ghost'); + this._err = null; + this._input = root.querySelector('input'); + this._depth = 0; + this._gen = 0; + // Encode-in-flight marker (the owning _ingest generation): while set, + // the same-src "nothing in flight" clear in _render must not fire — + // the stored value still points at the OLD image until the encode + // lands, so that clear would unmask the stale image mid-replace. + this._swapGen = 0; + // Render-owned swap in flight: set when _render assigns a new src, + // cleared only by the img's own load/error (or the empty branch). + // img.complete CANNOT stand in for this — setting src only QUEUES + // the current-request swap (a microtask), so synchronously after an + // assignment, complete still reports the OLD settled request. The + // pick path does exactly that: the host sets src, credit, and + // credit-href back-to-back in one task, and renders #2/#3 would + // read the stale complete === true and drop the mask one render + // after it was set. + this._loadPending = false; + // See _render's empty branch: a transient attribution-error wipe of a + // showing image must make the follow-up render a replacement (spinner), + // not a first fill (blank frame). + this._hidShowing = false; + this._view = { + s: 1, + x: 0, + y: 0 + }; + this._subFn = () => this._render(); + // Shadow-DOM listeners live with the shadow DOM — bound once here so + // disconnect/reconnect (e.g. React remount) doesn't stack handlers. + this._empty.addEventListener('click', () => this._input.click()); + root.addEventListener('click', e => { + const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act'); + if (!act) return; + // The hidden controls are opacity-0 but still tabbable — without + // this gate a keyboard user could drive them on a read-only share + // link (mirrors the dblclick handler's editable gate). + if (!this.hasAttribute('data-editable')) return; + if (act === 'replace') { + this._exitReframe(true); + // Host-owned picker (Unsplash modal; it also offers local import). + this.dispatchEvent(new CustomEvent('image-slot:pick', { + bubbles: true, + composed: true, + detail: { + id: this.id || null + } + })); + } + if (act === 'edit') { + if (!this._reframes()) return; + if (this.hasAttribute('data-reframe')) this._exitReframe(true);else this._enterReframe(); + } + }); + this._input.addEventListener('change', () => { + const f = this._input.files && this._input.files[0]; + if (f) this._ingest(f); + this._input.value = ''; + }); + // naturalWidth/Height aren't known until load — re-apply so the cover + // baseline is computed from real dimensions, not the 100%×100% fallback. + // load/error also release the replacement-in-flight mask (via the + // single discipline in _releaseMask): the swap is only revealed once + // the new image can actually paint (on error the frame shows its + // background, same as a fresh slot with a broken src). + this._img.addEventListener('load', () => { + this._loadPending = false; + this._releaseMask(true); + this._applyView(); + }); + this._img.addEventListener('error', () => { + this._loadPending = false; + this._releaseMask(true); + }); + // Gated only on editable — any filled slot can be repositioned/scaled, + // regardless of fit. Share links (no writeFile) stay static. + this.addEventListener('dblclick', e => { + if (!this.hasAttribute('data-editable') || !this._reframes()) return; + e.preventDefault(); + if (this.hasAttribute('data-reframe')) this._exitReframe(true);else this._enterReframe(); + }); + // Pan + resize both originate on the spill layer. A handle pointerdown + // drives an aspect-locked resize anchored at the opposite corner; any + // other pointerdown on the spill pans. Offsets are frame-% so a + // reframed slot survives responsive resize / PPTX export. + this._spill.addEventListener('pointerdown', e => { + if (e.button !== 0 || !this.hasAttribute('data-reframe')) return; + e.preventDefault(); + e.stopPropagation(); + this._spill.setPointerCapture(e.pointerId); + const rect = this.getBoundingClientRect(); + const fw = rect.width || 1, + fh = rect.height || 1; + const corner = e.target.getAttribute && e.target.getAttribute('data-c'); + let move; + if (corner) { + // Resize about the OPPOSITE corner. Viewport-px throughout (rect + // fw/fh, not clientWidth) so the math survives a transform:scale() + // ancestor — deck_stage renders slides scaled-to-fit. + const iw = this._img.naturalWidth || 1, + ih = this._img.naturalHeight || 1; + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + const base = contain ? Math.min(fw / iw, fh / ih) : Math.max(fw / iw, fh / ih); + const sx = corner.includes('e') ? 1 : -1; + const sy = corner.includes('s') ? 1 : -1; + const s0 = this._view.s; + const w0 = iw * base * s0, + h0 = ih * base * s0; + const cx0 = (50 + this._view.x) / 100 * fw; + const cy0 = (50 + this._view.y) / 100 * fh; + const ox = cx0 - sx * w0 / 2, + oy = cy0 - sy * h0 / 2; + const diag0 = Math.hypot(w0, h0); + const ux = sx * w0 / diag0, + uy = sy * h0 / diag0; + move = ev => { + const proj = (ev.clientX - rect.left - ox) * ux + (ev.clientY - rect.top - oy) * uy; + const s = clampS(s0 * proj / diag0); + const d = diag0 * s / s0; + this._view.s = s; + this._view.x = (ox + ux * d / 2) / fw * 100 - 50; + this._view.y = (oy + uy * d / 2) / fh * 100 - 50; + this._clampView(); + this._applyView(); + }; + } else { + this.setAttribute('data-panning', ''); + const start = { + px: e.clientX, + py: e.clientY, + x: this._view.x, + y: this._view.y + }; + move = ev => { + this._view.x = start.x + (ev.clientX - start.px) / fw * 100; + this._view.y = start.y + (ev.clientY - start.py) / fh * 100; + this._clampView(); + this._applyView(); + }; + } + const up = () => { + try { + this._spill.releasePointerCapture(e.pointerId); + } catch {} + this._spill.removeEventListener('pointermove', move); + this._spill.removeEventListener('pointerup', up); + this._spill.removeEventListener('pointercancel', up); + this.removeAttribute('data-panning'); + this._dragUp = null; + }; + // Stashed so _exitReframe (Escape / outside-click mid-drag) can + // tear the capture + listeners down synchronously. + this._dragUp = up; + this._spill.addEventListener('pointermove', move); + this._spill.addEventListener('pointerup', up); + this._spill.addEventListener('pointercancel', up); + }); + // Wheel zoom stays available inside reframe mode as a trackpad nicety — + // zooms toward the cursor (offset' = cursor·(1-k) + offset·k). + this.addEventListener('wheel', e => { + if (!this.hasAttribute('data-reframe')) return; + e.preventDefault(); + const r = this.getBoundingClientRect(); + const cx = (e.clientX - r.left) / r.width * 100 - 50; + const cy = (e.clientY - r.top) / r.height * 100 - 50; + const prev = this._view.s; + const next = clampS(prev * Math.pow(1.0015, -e.deltaY)); + if (next === prev) return; + const k = next / prev; + this._view.s = next; + this._view.x = cx * (1 - k) + this._view.x * k; + this._view.y = cy * (1 - k) + this._view.y * k; + this._clampView(); + this._applyView(); + }, { + passive: false + }); + } + connectedCallback() { + // Warn once per page — an id-less slot works for the session but + // cannot persist, and two id-less slots would share nothing. + if (!this.id && !ImageSlot._warned) { + ImageSlot._warned = true; + console.warn('<image-slot> without an id will not persist its dropped image.'); + } + this.addEventListener('dragenter', this); + this.addEventListener('dragover', this); + this.addEventListener('dragleave', this); + this.addEventListener('drop', this); + subs.add(this._subFn); + // The host may inject window.omelette.writeFile AFTER the first render; + // re-render on hover so the editable-gated controls reliably appear. + this.addEventListener('pointerenter', this._subFn); + // width%/height% in _applyView encode the frame aspect at call time — + // a host resize (responsive grid, pane divider) would stretch the + // image until the next _render. Re-render on size change: _render() + // re-seeds _view from stored before clamp/apply, so a shrink→grow + // cycle round-trips instead of ratcheting x/y toward the narrower + // frame's clamp range. + this._ro = new ResizeObserver(() => this._render()); + this._ro.observe(this); + load(); + this._render(); + } + disconnectedCallback() { + subs.delete(this._subFn); + this.removeEventListener('pointerenter', this._subFn); + this.removeEventListener('dragenter', this); + this.removeEventListener('dragover', this); + this.removeEventListener('dragleave', this); + this.removeEventListener('drop', this); + if (this._ro) { + this._ro.disconnect(); + this._ro = null; + } + // commit=false: a disconnect is not a user intent — committing here + // would persist whatever half-finished drag a React remount or DOM + // splice happened to interrupt. Deliberate exits commit on their own + // paths (Escape/click-out/toggle), and unloads commit via pagehide. + this._exitReframe(false); + } + _enterReframe() { + if (this.hasAttribute('data-reframe')) return; + this.setAttribute('data-reframe', ''); + this._signalReframe(true); + // Best-effort commit when the document unloads mid-reframe (a host + // navigation racing the enter signal, a manual reload, tab close): + // the sidecar write rides the host bridge, which outlives this + // document, so the crop survives even though the mode dies with the + // DOM. Held on the instance so _exitReframe detaches exactly what + // was attached. + this._pagehide = () => { + this._exitReframe(true); + flushNow(); + }; + window.addEventListener('pagehide', this._pagehide); + // Promote spill to the top layer, then keep it pinned over the frame: + // scroll/resize cover the common cases, and a per-frame rect check + // catches layout shifts that fire neither (an image above finishing + // load, streamed DOM pushing the slot down, an ancestor transform + // change) so the overlay can't detach from the frame. + try { + this._spill.showPopover(); + } catch {} + // After the spill, so the controls stack above it in the top layer. + try { + this._ctl.showPopover(); + } catch {} + this._reposition = () => { + if (this.hasAttribute('data-reframe')) this._applyView(); + }; + window.addEventListener('scroll', this._reposition, true); + window.addEventListener('resize', this._reposition); + this._lastRect = ''; + this._watch = () => { + if (!this.hasAttribute('data-reframe')) return; + const r = this.getBoundingClientRect(); + const key = r.left + ',' + r.top + ',' + r.width + ',' + r.height; + if (key !== this._lastRect) { + this._lastRect = key; + this._applyView(); + } + this._watchId = requestAnimationFrame(this._watch); + }; + this._watchId = requestAnimationFrame(this._watch); + this._applyView(); + // Close on click outside (the spill handler stopPropagation()s so + // in-image drags don't reach this) and on Escape. Listeners are held + // on the instance so _exitReframe / disconnectedCallback can detach + // exactly what was attached. + this._outside = e => { + if (e.composedPath && e.composedPath().includes(this)) return; + this._exitReframe(true); + }; + this._esc = e => { + if (e.key === 'Escape') this._exitReframe(true); + }; + document.addEventListener('pointerdown', this._outside, true); + document.addEventListener('keydown', this._esc, true); + } + _exitReframe(commit) { + if (!this.hasAttribute('data-reframe')) return; + if (this._dragUp) this._dragUp(); + this.removeAttribute('data-reframe'); + this.removeAttribute('data-panning'); + if (this._outside) document.removeEventListener('pointerdown', this._outside, true); + if (this._esc) document.removeEventListener('keydown', this._esc, true); + this._outside = this._esc = null; + if (this._reposition) { + window.removeEventListener('scroll', this._reposition, true); + window.removeEventListener('resize', this._reposition); + this._reposition = null; + } + if (this._watchId) { + cancelAnimationFrame(this._watchId); + this._watchId = 0; + } + if (this._pagehide) { + window.removeEventListener('pagehide', this._pagehide); + this._pagehide = null; + } + try { + this._spill.hidePopover(); + } catch {} + try { + this._ctl.hidePopover(); + } catch {} + this._ctl.style.left = ''; + this._ctl.style.top = ''; + if (commit) this._commitView(); + this._signalReframe(false); + } + + // Reframe state lives only in this DOM until commit, invisible to the + // host's dirty signals — announce enter/exit so the host can hold + // auto-reloads for exactly the gesture (the guest bundle forwards + // image-slot:reframe to the host as imageSlotReframe). Dispatched on + // the element (composed, so it escapes shadow roots) while connected; + // a disconnected exit (disconnectedCallback) falls back to document so + // the host still hears it. + _signalReframe(active) { + const target = this.isConnected ? this : document; + target.dispatchEvent(new CustomEvent('image-slot:reframe', { + bubbles: true, + composed: true, + detail: { + active: active, + id: this.id || null + } + })); + } + + // Public: host's "Import from computer" calls this to run local browse. + openFilePicker() { + this._exitReframe(true); + this._input.click(); + } + + // A src write is a newer intent for this slot's content — the host + // pick path (setImageSlotImage) or an agent edit — so it must win + // over any encode still in flight from an earlier drop: left live, + // that encode lands later, passes _ingest's gen guard, and its + // setSlot silently overwrites the pick (the stored value shadows + // src in _render). Bumping _gen kills the encode before its own + // _swapGen clear runs, so clear the dead claim here too — otherwise + // _releaseMask (gated on !_swapGen) never fires and the pick's + // spinner is stranded. src ONLY: the pick sets credit/credit-href + // in the same task, and clearing _swapGen on those would let the + // same-src branch unmask the old image mid-encode. + attributeChangedCallback(name, oldVal, newVal) { + if (name === 'src' && oldVal !== newVal) { + this._gen++; + this._swapGen = 0; + } + if (this.shadowRoot) this._render(); + } + + // handleEvent — one listener object for all four drag events keeps the + // add/remove symmetric and the depth counter correct. + handleEvent(e) { + if (e.type === 'dragenter' || e.type === 'dragover') { + // Without preventDefault the browser never fires 'drop'. + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + if (e.type === 'dragenter') this._depth++; + this.setAttribute('data-over', ''); + } else if (e.type === 'dragleave') { + // dragenter/leave fire for every descendant crossing — count depth + // so hovering the icon inside the empty state doesn't flicker. + if (--this._depth <= 0) { + this._depth = 0; + this.removeAttribute('data-over'); + } + } else if (e.type === 'drop') { + e.preventDefault(); + e.stopPropagation(); + this._depth = 0; + this.removeAttribute('data-over'); + const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; + if (f) this._ingest(f); + } + } + async _ingest(file) { + this._setError(null); + if (!file || ACCEPT.indexOf(file.type) < 0) { + this._setError('Drop a PNG, JPEG, WebP, or AVIF image.'); + return; + } + // toDataUrl can take hundreds of ms on a large photo. A Clear or a + // newer drop during that window would be clobbered when this await + // resumes — bump + capture a generation so stale encodes bail. + const gen = ++this._gen; + // Replacing a shown image: surface the swap through the encode too, + // not just the decode — otherwise the old photo sits there with no + // feedback while the canvas re-encode runs. An empty slot keeps its + // placeholder (no spinner) until the encode lands, as before. + // _swapGen guards the mask against re-renders DURING the encode + // (pointerenter, ResizeObserver, another slot's store write): the + // stored value still resolves to the old image there, so _render's + // same-src clear would otherwise unmask it mid-replace. + if (this.hasAttribute('data-filled')) { + this.setAttribute('data-swapping', ''); + this._swapGen = gen; + } + try { + const w = this.clientWidth || this.offsetWidth || MAX_DIM; + const url = await toDataUrl(file, w); + if (gen !== this._gen) return; + // Only exit reframe once the new image is in hand — a rejected type + // or decode failure leaves the in-progress crop untouched. + this._exitReframe(false); + // Clear BEFORE setSlot: its synchronous re-render must see no + // pending encode, so a byte-identical re-upload (same data URL, no + // load event coming) still clears the mask via the complete branch. + this._swapGen = 0; + const val = { + u: url, + s: 1, + x: 0, + y: 0 + }; + setSlot(this.id || '', val); + // Keep a session-local copy for id-less slots so the drop still + // shows, even though it cannot persist. + if (!this.id) { + this._local = val; + this._render(); + } + } catch (err) { + if (gen !== this._gen) return; + this._swapGen = 0; + // Reveal the kept old image — unless another replacement (a + // remote pick's src swap) is still in flight, in which case the + // mask stays until THAT image settles (its load/error releases). + this._releaseMask(); + this._setError('Could not read that image.'); + console.warn('<image-slot> ingest failed:', err); + } + } + _setError(msg) { + if (this._err) { + this._err.remove(); + this._err = null; + } + if (!msg) return; + const d = document.createElement('div'); + d.className = 'err'; + d.textContent = msg; + this.shadowRoot.appendChild(d); + this._err = d; + setTimeout(() => { + if (this._err === d) { + d.remove(); + this._err = null; + } + }, 3000); + } + + // Reframing (pan/resize) is available on any filled slot — the user can + // always reposition/scale. `fit` only sets the initial baseline (see + // _geom): contain starts fully-visible, cover starts frame-filling. + _reframes() { + return this.hasAttribute('data-filled'); + } + + // The single release discipline for the replacement-in-flight mask + // (data-swapping). The mask comes off only when BOTH hold: + // - no encode is pending (_swapGen) — mid-encode the stored value + // still resolves to the old image, so any reveal paints it; + // - the frame img has settled on its current src — an unsettled src + // means some replacement is still in flight (e.g. a remote pick), + // whoever started it, and revealing would paint the previous + // frame. The load/error listeners pass settled=true (the event IS + // the settlement signal, per spec complete is true by then); + // other callers rely on the complete flag (covers loaded AND + // failed). + // Every release path funnels through here EXCEPT _render's empty + // branch (the img is being cleared — nothing will ever settle). + _releaseMask(settled) { + if (!this._swapGen && !this._loadPending && (settled || this._img.complete)) { + this.removeAttribute('data-swapping'); + } + } + + // Baseline geometry, shared by clamp/apply/resize. `base` is the scale at + // view-scale s=1: cover = fill the frame (overflow on the looser axis), + // contain = fit fully inside (letterboxed). Zooming a contain image past + // s where it overflows naturally becomes a crop. Null until the img has + // loaded (naturalWidth is 0 before that) or when the slot has no layout + // box — ResizeObserver fires with a 0×0 rect under display:none, and + // clamping against a degenerate 1×1 frame would silently pull the stored + // pan toward zero. + _geom() { + const iw = this._img.naturalWidth, + ih = this._img.naturalHeight; + const fw = this.clientWidth, + fh = this.clientHeight; + if (!iw || !ih || !fw || !fh) return null; + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + const base = contain ? Math.min(fw / iw, fh / ih) : Math.max(fw / iw, fh / ih); + return { + iw, + ih, + fw, + fh, + base + }; + } + _clampView() { + // Pan range on each axis is half the overflow past the frame edge. + const g = this._geom(); + if (!g) return; + const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50); + const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50); + this._view.x = Math.max(-mx, Math.min(mx, this._view.x)); + this._view.y = Math.max(-my, Math.min(my, this._view.y)); + } + _applyView() { + const g = this._geom(); + // Top-layer controls: pin to the frame's top-right in viewport px + // (the same 8px inset as the in-frame layout; unscaled — top-layer UI + // reads as chrome, not page content). BEFORE the geometry branch: + // placement needs only the frame rect, and a not-yet-loaded or broken + // src must not leave the promoted strip floating unpositioned. Gated + // on the popover actually being open: without the Popover API, + // showPopover() threw (swallowed in _enterReframe), .ctl stays in + // its in-frame absolute layout, and viewport-px coordinates would + // shove it off-frame — and matches(':popover-open') itself throws + // there (unknown pseudo-class), hence the try/catch. + if (this.hasAttribute('data-reframe')) { + let onTop = false; + try { + onTop = this._ctl.matches(':popover-open'); + } catch {} + if (onTop) { + const r = this.getBoundingClientRect(); + this._ctl.style.left = r.right - 8 + 'px'; + this._ctl.style.top = r.top + 8 + 'px'; + } + } + if (!g) { + // Dimensions not known yet (before img load) — centered fit so there + // is no flash of an unpositioned image before the geometry lands. + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + this._img.style.width = '100%'; + this._img.style.height = '100%'; + this._img.style.left = '50%'; + this._img.style.top = '50%'; + this._img.style.objectFit = contain ? 'contain' : 'cover'; + return; + } + // Baseline (cover-fill or contain-fit) × view scale. Width/height and + // left/top are all frame-% — depends only on the frame aspect ratio, so + // a responsive resize keeps the same crop. The spill layer mirrors the + // same box so its corners = image corners. + const k = g.base * this._view.s; + const w = g.iw * k / g.fw * 100 + '%'; + const h = g.ih * k / g.fh * 100 + '%'; + const l = 50 + this._view.x + '%'; + const t = 50 + this._view.y + '%'; + this._img.style.width = w; + this._img.style.height = h; + this._img.style.left = l; + this._img.style.top = t; + this._img.style.objectFit = ''; + if (this.hasAttribute('data-reframe')) { + // Top-layer spill: position in viewport px over the frame. The top + // layer escapes ancestor transforms entirely, so EVERY term must be + // in viewport units: getBoundingClientRect gives the frame's scaled + // origin AND size, and the rect/layout ratio rescales the ghost — + // sizing from layout px alone renders it 1/scale too large under a + // scaled deck slide. Inner ghost + handles stay box-relative. + const r = this.getBoundingClientRect(); + const sx = g.fw ? r.width / g.fw : 1; + const sy = g.fh ? r.height / g.fh : 1; + this._spill.style.width = g.iw * k * sx + 'px'; + this._spill.style.height = g.ih * k * sy + 'px'; + this._spill.style.left = r.left + (50 + this._view.x) / 100 * r.width + 'px'; + this._spill.style.top = r.top + (50 + this._view.y) / 100 * r.height + 'px'; + } + } + _commitView() { + const v = { + s: this._view.s, + x: this._view.x, + y: this._view.y + }; + if (this._userUrl) v.u = this._userUrl; + // Framing-only (no u) persists too so an author-src slot remembers its + // crop; clearing the sidecar still falls through to src=. + if (this.id) setSlot(this.id, v);else { + this._local = v; + } + } + _render() { + // Shape / mask. Presets use border-radius so the dashed ring can + // follow the rounded outline; clip-path is only applied for an + // explicit `mask` (the ring is hidden there since a rectangle + // dashed border chopped by an arbitrary polygon looks broken). + const mask = this.getAttribute('mask'); + const shape = (this.getAttribute('shape') || 'rounded').toLowerCase(); + let radius = ''; + if (shape === 'circle') radius = '50%';else if (shape === 'pill') radius = '9999px';else if (shape === 'rounded') { + const n = parseFloat(this.getAttribute('radius')); + radius = (Number.isFinite(n) ? n : 12) + 'px'; + } + this._frame.style.borderRadius = mask ? '' : radius; + this._frame.style.clipPath = mask || ''; + this._ring.style.borderRadius = mask ? '' : radius; + this._ring.style.display = mask ? 'none' : ''; + + // Controls and reframe entry gate on this so share links stay read-only. + const editable = !!(window.omelette && window.omelette.writeFile); + this.toggleAttribute('data-editable', editable); + this._sub.style.display = editable ? '' : 'none'; + + // Content. The sidecar is also writable by the agent's write_file + // tool, so its value isn't guaranteed canvas-originated — only accept + // data:image/ URLs from it. The `src` attribute is author-controlled + // (Claude wrote it into the HTML) so it passes through unchanged. + let stored = this.id ? getSlot(this.id) : this._local; + if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null; + const srcAttr = this.getAttribute('src') || ''; + this._userUrl = stored && stored.u || null; + const url = this._userUrl || srcAttr; + // Don't clobber an in-flight reframe with a store-triggered re-render. + if (!this.hasAttribute('data-reframe')) { + this._view = { + s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1, + x: stored && Number.isFinite(stored.x) ? stored.x : 0, + y: stored && Number.isFinite(stored.y) ? stored.y : 0 + }; + } + this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image'; + // Toggle via style.display — the [hidden] attribute alone loses to + // the display:flex / display:block rules in the stylesheet above. + // An Unsplash src with no credit attribute must NOT render — showing + // the photo uncredited is the Unsplash-terms violation itself. The + // error tile replaces the photo until the credit is written. A + // user-dropped image is the user's own content and always renders. + // Trimmed: credit is agent/user-editable content, and a whitespace- + // only value must count as missing — otherwise it would suppress the + // error tile AND render an empty credit box (no text, no links), + // exactly the unattributed state this gate exists to prevent. + const credit = (this.getAttribute('credit') || '').trim(); + const attrError = !!(!credit && !this._userUrl && srcAttr && isUnsplashHost(srcAttr)); + this.toggleAttribute('data-attribution-error', attrError); + if (url && !attrError) { + const prev = this._img.getAttribute('src'); + if (prev !== url) { + // Replacing an already-shown image: mark the swap BEFORE setting + // src so the stale frame is never revealed (see the data-swapping + // stylesheet rules). First fill (prev empty) keeps the existing + // placeholder-until-load behavior — no spinner. _hidShowing + // covers the pick path's transient attribution-error wipe: prev + // is gone, but an image WAS showing, so this is a replacement. + if (prev || this._hidShowing) this.setAttribute('data-swapping', ''); + // Mark the swap BEFORE assigning src: complete keeps reporting + // the old settled request until the browser's + // update-the-image-data microtask runs, so same-task re-renders + // (the pick path's credit/credit-href setAttributes) need this + // flag, not complete, to know a load is in flight. + this._loadPending = true; + this._img.src = url; + this._ghost.src = url; + } else { + // Same-src re-render — release if settled, so an ingest-set + // spinner can't stick after a byte-identical re-upload (same + // data URL, no further load event ever fires). + this._releaseMask(); + } + this._hidShowing = false; + this._img.style.display = 'block'; + this._empty.style.display = 'none'; + this.setAttribute('data-filled', ''); + this._clampView(); + this._applyView(); + } else { + this.removeAttribute('data-swapping'); + // The src is being removed — no load/error will ever fire for it. + this._loadPending = false; + // A transient attribution-error wipe of a showing image happens on + // the pick path: the host sets src one setAttribute before credit, + // so render N hides the old image (attrError) and render N+1 + // restores a URL. Remember the wipe so that restore renders as a + // replacement (spinner), not a first fill (blank frame). + this._hidShowing = attrError && !!this._img.getAttribute('src'); + this._img.style.display = 'none'; + this._img.removeAttribute('src'); + this._ghost.removeAttribute('src'); + // The error tile owns the blocked-photo state; .empty stays for + // the genuinely-empty slot. + this._empty.style.display = attrError ? 'none' : 'flex'; + this.removeAttribute('data-filled'); + } + + // Credit belongs to the author src, so a user drop hides it. + // textContent + the http(s)-only funnel keep external strings inert. + const showCredit = !!(url && credit && !this._userUrl && !attrError); + this._credit.textContent = ''; + if (showCredit) { + // Validate once (resolved against the document, http(s) only), + // then append the terms-required utm referral params to links + // that point back at unsplash.com. + let href = ''; + const rawHref = this.getAttribute('credit-href') || ''; + if (rawHref) { + try { + const u = new URL(rawHref, document.baseURI); + if (u.protocol === 'http:' || u.protocol === 'https:') { + href = withReferral(u.href); + } + } catch {} + } + const mkLink = (text, linkHref) => { + const a = document.createElement('a'); + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); + a.setAttribute('href', linkHref); + a.textContent = text; + return a; + }; + // Unsplash's prescribed credit is TWO links — the photographer's + // name to their profile (credit-href) and 'Unsplash' to the + // homepage. Render that split whenever the text has the canonical + // shape; other text keeps the legacy single-link rendering. + const m = /^Photo by (.+) on Unsplash$/.exec(credit); + if (m) { + this._credit.appendChild(document.createTextNode('Photo by ')); + this._credit.appendChild(href ? mkLink(m[1], href) : document.createTextNode(m[1])); + this._credit.appendChild(document.createTextNode(' on ')); + this._credit.appendChild(mkLink('Unsplash', UNSPLASH_HOMEPAGE_HREF)); + } else if (href) { + this._credit.appendChild(mkLink(credit, href)); + } else { + this._credit.textContent = credit; + } + } + this.toggleAttribute('data-credit', showCredit); + } + } + if (!customElements.get('image-slot')) { + customElements.define('image-slot', ImageSlot); + } +})(); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/image-slot.js", error: String((e && e.message) || e) }); } + +// ui_kits/ios_app/tweaks-panel.jsx +try { (() => { +// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) +// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected). + +/* BEGIN USAGE */ +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider, +// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw <input type="range">, segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "palette": ["#D97757", "#29261b", "#f6f4ef"], +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}> +// Hello +// <TweaksPanel> +// <TweakSection label="Typography" /> +// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px" +// onChange={(v) => setTweak('fontSize', v)} /> +// <TweakRadio label="Density" value={t.density} +// options={['compact', 'regular', 'comfy']} +// onChange={(v) => setTweak('density', v)} /> +// <TweakSection label="Theme" /> +// <TweakColor label="Primary" value={t.primaryColor} +// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']} +// onChange={(v) => setTweak('primaryColor', v)} /> +// <TweakColor label="Palette" value={t.palette} +// options={[['#D97757', '#29261b', '#f6f4ef'], +// ['#475569', '#0f172a', '#f1f5f9']]} +// onChange={(v) => setTweak('palette', v)} /> +// <TweakToggle label="Dark mode" value={t.dark} +// onChange={(v) => setTweak('dark', v)} /> +// </TweaksPanel> +// </div> +// ); +// } +// +// TweakRadio is the segmented control for 2–3 short options (auto-falls-back to +// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when +// options are many or long. For color tweaks always curate 3-4 options rather than +// a free picker; an option can also be a whole 2–5 color palette (the stored value +// is the array). The Tweak* controls are a floor, not a ceiling — build custom +// controls inside the panel if a tweak calls for UI they don't cover. +/* END USAGE */ +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} + + .twk-chips{display:flex;gap:6px} + .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px; + padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default; + box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06); + transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s} + .twk-chip:hover{transform:translateY(-1px); + box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)} + .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85), + 0 2px 6px rgba(0,0,0,.15)} + .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%; + display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)} + .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)} + .twk-chip>span>i:first-child{box-shadow:none} + .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px; + filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null ? keyOrEdits : { + [keyOrEdits]: val + }; + setValues(prev => ({ + ...prev, + ...edits + })); + window.parent.postMessage({ + type: '__edit_mode_set_keys', + edits + }, '*'); + // Same-window signal so in-page listeners (deck-stage rail thumbnails) + // can react — the parent message only reaches the host, not peers. + window.dispatchEvent(new CustomEvent('tweakchange', { + detail: edits + })); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ + title = 'Tweaks', + children +}) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ + x: 16, + y: 16 + }); + const PAD = 16; + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, + h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)) + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + React.useEffect(() => { + const onMsg = e => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true);else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ + type: '__edit_mode_available' + }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ + type: '__edit_mode_dismissed' + }, '*'); + }; + const onDragStart = e => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, + sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = ev => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy) + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + // data-om-starter: inert presence marker — Claude Design's starter-usage + // probe reads it. The closed panel renders nothing, so the marker rides + // the <html> element as an attribute instead of a rendered node — zero + // elements added, so page CSS (even structural selectors like + // :nth-child) can never observe it. It records that the page WIRES a + // tweaks panel, whether or not the panel is open. Keep this effect. + React.useEffect(() => { + document.documentElement.setAttribute('data-om-starter', 'tweaks-panel'); + return () => document.documentElement.removeAttribute('data-om-starter'); + }, []); + if (!open) return null; + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("style", null, __TWEAKS_STYLE), /*#__PURE__*/React.createElement("div", { + ref: dragRef, + className: "twk-panel", + "data-omelette-chrome": "", + style: { + right: offsetRef.current.x, + bottom: offsetRef.current.y + } + }, /*#__PURE__*/React.createElement("div", { + className: "twk-hd", + onMouseDown: onDragStart + }, /*#__PURE__*/React.createElement("b", null, title), /*#__PURE__*/React.createElement("button", { + className: "twk-x", + "aria-label": "Close tweaks", + onMouseDown: e => e.stopPropagation(), + onClick: dismiss + }, "\u2715")), /*#__PURE__*/React.createElement("div", { + className: "twk-body" + }, children))); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ + label, + children +}) { + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + className: "twk-sect" + }, label), children); +} +function TweakRow({ + label, + value, + children, + inline = false +}) { + return /*#__PURE__*/React.createElement("div", { + className: inline ? 'twk-row twk-row-h' : 'twk-row' + }, /*#__PURE__*/React.createElement("div", { + className: "twk-lbl" + }, /*#__PURE__*/React.createElement("span", null, label), value != null && /*#__PURE__*/React.createElement("span", { + className: "twk-val" + }, value)), children); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ + label, + value, + min = 0, + max = 100, + step = 1, + unit = '', + onChange +}) { + return /*#__PURE__*/React.createElement(TweakRow, { + label: label, + value: `${value}${unit}` + }, /*#__PURE__*/React.createElement("input", { + type: "range", + className: "twk-slider", + min: min, + max: max, + step: step, + value: value, + onChange: e => onChange(Number(e.target.value)) + })); +} +function TweakToggle({ + label, + value, + onChange +}) { + return /*#__PURE__*/React.createElement("div", { + className: "twk-row twk-row-h" + }, /*#__PURE__*/React.createElement("div", { + className: "twk-lbl" + }, /*#__PURE__*/React.createElement("span", null, label)), /*#__PURE__*/React.createElement("button", { + type: "button", + className: "twk-toggle", + "data-on": value ? '1' : '0', + role: "switch", + "aria-checked": !!value, + onClick: () => onChange(!value) + }, /*#__PURE__*/React.createElement("i", null))); +} +function TweakRadio({ + label, + value, + options, + onChange +}) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag — ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + // Segments wrap mid-word once per-segment width runs out. The track is + // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px + // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2 + // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall + // back to a dropdown rather than wrap. + const labelLen = o => String(typeof o === 'object' ? o.label : o).length; + const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0); + const fitsAsSegments = maxLen <= ({ + 2: 16, + 3: 10 + }[options.length] ?? 0); + if (!fitsAsSegments) { + // <select> emits strings — map back to the original option value so the + // fallback stays type-preserving (numbers, booleans) like the segment path. + const resolve = s => { + const m = options.find(o => String(typeof o === 'object' ? o.value : o) === s); + return m === undefined ? s : typeof m === 'object' ? m.value : m; + }; + return /*#__PURE__*/React.createElement(TweakSelect, { + label: label, + value: value, + options: options, + onChange: s => onChange(resolve(s)) + }); + } + const opts = options.map(o => typeof o === 'object' ? o : { + value: o, + label: o + }); + const idx = Math.max(0, opts.findIndex(o => o.value === value)); + const n = opts.length; + const segAt = clientX => { + const r = trackRef.current.getBoundingClientRect(); + const inner = r.width - 4; + const i = Math.floor((clientX - r.left - 2) / inner * n); + return opts[Math.max(0, Math.min(n - 1, i))].value; + }; + const onPointerDown = e => { + setDragging(true); + const v0 = segAt(e.clientX); + if (v0 !== valueRef.current) onChange(v0); + const move = ev => { + if (!trackRef.current) return; + const v = segAt(ev.clientX); + if (v !== valueRef.current) onChange(v); + }; + const up = () => { + setDragging(false); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return /*#__PURE__*/React.createElement(TweakRow, { + label: label + }, /*#__PURE__*/React.createElement("div", { + ref: trackRef, + role: "radiogroup", + onPointerDown: onPointerDown, + className: dragging ? 'twk-seg dragging' : 'twk-seg' + }, /*#__PURE__*/React.createElement("div", { + className: "twk-seg-thumb", + style: { + left: `calc(2px + ${idx} * (100% - 4px) / ${n})`, + width: `calc((100% - 4px) / ${n})` + } + }), opts.map(o => /*#__PURE__*/React.createElement("button", { + key: o.value, + type: "button", + role: "radio", + "aria-checked": o.value === value + }, o.label)))); +} +function TweakSelect({ + label, + value, + options, + onChange +}) { + return /*#__PURE__*/React.createElement(TweakRow, { + label: label + }, /*#__PURE__*/React.createElement("select", { + className: "twk-field", + value: value, + onChange: e => onChange(e.target.value) + }, options.map(o => { + const v = typeof o === 'object' ? o.value : o; + const l = typeof o === 'object' ? o.label : o; + return /*#__PURE__*/React.createElement("option", { + key: v, + value: v + }, l); + }))); +} +function TweakText({ + label, + value, + placeholder, + onChange +}) { + return /*#__PURE__*/React.createElement(TweakRow, { + label: label + }, /*#__PURE__*/React.createElement("input", { + className: "twk-field", + type: "text", + value: value, + placeholder: placeholder, + onChange: e => onChange(e.target.value) + })); +} +function TweakNumber({ + label, + value, + min, + max, + step = 1, + unit = '', + onChange +}) { + const clamp = n => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ + x: 0, + val: 0 + }); + const onScrubStart = e => { + e.preventDefault(); + startRef.current = { + x: e.clientX, + val: value + }; + const decimals = (String(step).split('.')[1] || '').length; + const move = ev => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return /*#__PURE__*/React.createElement("div", { + className: "twk-num" + }, /*#__PURE__*/React.createElement("span", { + className: "twk-num-lbl", + onPointerDown: onScrubStart + }, label), /*#__PURE__*/React.createElement("input", { + type: "number", + value: value, + min: min, + max: max, + step: step, + onChange: e => onChange(clamp(Number(e.target.value))) + }), unit && /*#__PURE__*/React.createElement("span", { + className: "twk-num-unit" + }, unit)); +} + +// Relative-luminance contrast pick — checkmarks drawn over a swatch need to +// read on both #111 and #fafafa without per-option configuration. Hex input +// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light". +function __twkIsLight(hex) { + const h = String(hex).replace('#', ''); + const x = h.length === 3 ? h.replace(/./g, c => c + c) : h.padEnd(6, '0'); + const n = parseInt(x.slice(0, 6), 16); + if (Number.isNaN(n)) return true; + const r = n >> 16 & 255, + g = n >> 8 & 255, + b = n & 255; + return r * 299 + g * 587 + b * 114 > 148000; +} +const __TwkCheck = ({ + light +}) => /*#__PURE__*/React.createElement("svg", { + viewBox: "0 0 14 14", + "aria-hidden": "true" +}, /*#__PURE__*/React.createElement("path", { + d: "M3 7.2 5.8 10 11 4.2", + fill: "none", + strokeWidth: "2.2", + strokeLinecap: "round", + strokeLinejoin: "round", + stroke: light ? 'rgba(0,0,0,.78)' : '#fff' +})); + +// TweakColor — curated color/palette picker. Each option is either a single +// hex string or an array of 1-5 hex strings; the card adapts — a lone color +// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the +// rest stacked in a sharp column on the right. onChange emits the +// option in the shape it was passed (string stays string, array stays array). +// Without options it falls back to the native color input for back-compat. +function TweakColor({ + label, + value, + options, + onChange +}) { + if (!options || !options.length) { + return /*#__PURE__*/React.createElement("div", { + className: "twk-row twk-row-h" + }, /*#__PURE__*/React.createElement("div", { + className: "twk-lbl" + }, /*#__PURE__*/React.createElement("span", null, label)), /*#__PURE__*/React.createElement("input", { + type: "color", + className: "twk-swatch", + value: value, + onChange: e => onChange(e.target.value) + })); + } + // Native <input type=color> emits lowercase hex per the HTML spec, so + // compare case-insensitively. String() guards JSON.stringify(undefined), + // which returns the primitive undefined (no .toLowerCase). + const key = o => String(JSON.stringify(o)).toLowerCase(); + const cur = key(value); + return /*#__PURE__*/React.createElement(TweakRow, { + label: label + }, /*#__PURE__*/React.createElement("div", { + className: "twk-chips", + role: "radiogroup" + }, options.map((o, i) => { + const colors = Array.isArray(o) ? o : [o]; + const [hero, ...rest] = colors; + const sup = rest.slice(0, 4); + const on = key(o) === cur; + return /*#__PURE__*/React.createElement("button", { + key: i, + type: "button", + className: "twk-chip", + role: "radio", + "aria-checked": on, + "data-on": on ? '1' : '0', + "aria-label": colors.join(', '), + title: colors.join(' · '), + style: { + background: hero + }, + onClick: () => onChange(o) + }, sup.length > 0 && /*#__PURE__*/React.createElement("span", null, sup.map((c, j) => /*#__PURE__*/React.createElement("i", { + key: j, + style: { + background: c + } + }))), on && /*#__PURE__*/React.createElement(__TwkCheck, { + light: __twkIsLight(hero) + })); + }))); +} +function TweakButton({ + label, + onClick, + secondary = false +}) { + return /*#__PURE__*/React.createElement("button", { + type: "button", + className: secondary ? 'twk-btn secondary' : 'twk-btn', + onClick: onClick + }, label); +} +Object.assign(window, { + useTweaks, + TweaksPanel, + TweakSection, + TweakRow, + TweakSlider, + TweakToggle, + TweakRadio, + TweakSelect, + TweakText, + TweakNumber, + TweakColor, + TweakButton +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/ios_app/tweaks-panel.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/watch_app/WatchScreens.jsx +try { (() => { +const { + Icon, + ProgressRing, + RecoveryDot +} = window.BetterFitDesignSystem_6a70ea; +const SESSION_ICON = name => /pull/i.test(name) ? 'arrow-fat-line-down' : /push/i.test(name) ? 'arrows-in-line-horizontal' : /leg/i.test(name) ? 'person-simple-run' : /cond|cardio|interval/i.test(name) ? 'heartbeat' : 'barbell'; +function WatchList({ + onStart +}) { + const d = window.BFData; + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(WatchBar, { + title: "BetterFit" + }), /*#__PURE__*/React.createElement(WatchScroll, null, /*#__PURE__*/React.createElement("div", { + style: { + background: 'var(--bf-yellow)', + color: 'var(--bf-black)', + borderRadius: 'var(--radius-md)', + padding: '10px 12px' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + gap: 5 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: SESSION_ICON(d.today.name), + size: 11, + weight: "bold" + }), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 9, + fontWeight: 700, + textTransform: 'uppercase', + letterSpacing: '.1em', + opacity: .62 + } + }, "Today")), /*#__PURE__*/React.createElement("div", { + className: "bf-display", + style: { + fontSize: 22, + marginTop: 2 + } + }, d.today.name), /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + fontSize: 10, + fontWeight: 600, + marginTop: 4 + } + }, d.today.exercises, " exercises \xB7 ", d.today.duration), /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onStart, + style: { + marginTop: 10, + width: '100%', + minHeight: 44, + border: 'none', + cursor: 'pointer', + background: 'var(--bf-black)', + color: 'var(--bf-yellow)', + borderRadius: 'var(--radius-md)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + fontFamily: 'var(--font-ui)', + fontSize: 14, + fontWeight: 600 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "play", + size: 13 + }), "Start")), ['Push A', 'Legs A', 'Conditioning'].map(n => /*#__PURE__*/React.createElement("div", { + key: n, + style: { + background: 'var(--surface)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-md)', + padding: '10px 12px', + display: 'flex', + alignItems: 'center', + gap: 8 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: SESSION_ICON(n), + size: 14, + color: "var(--accent-text)" + }), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 13, + fontWeight: 600, + flex: 1 + } + }, n), /*#__PURE__*/React.createElement(Icon, { + name: "caret-right", + size: 11, + weight: "bold", + color: "var(--text-tertiary)" + }))))); +} +function WatchActive({ + onFinish +}) { + const d = window.BFData; + const [set, setSet] = React.useState(1); + const [paused, setPaused] = React.useState(false); + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(WatchBar, { + title: d.today.name + }), /*#__PURE__*/React.createElement(WatchScroll, null, /*#__PURE__*/React.createElement("div", { + style: { + textAlign: 'center' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 5, + color: paused ? 'var(--text-secondary)' : 'var(--accent-text)' + } + }, /*#__PURE__*/React.createElement(Icon, { + name: paused ? 'pause' : 'timer', + size: 11, + weight: "bold" + }), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 9, + fontWeight: 700, + textTransform: 'uppercase', + letterSpacing: '.1em' + } + }, paused ? 'Paused' : 'Elapsed')), /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + fontFamily: 'var(--font-mono)', + fontSize: 30, + fontWeight: 700, + marginTop: 2, + color: paused ? 'var(--text-secondary)' : 'var(--text-primary)' + } + }, "18:42")), /*#__PURE__*/React.createElement("div", { + style: { + background: 'var(--surface)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-md)', + padding: 12, + textAlign: 'center' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 5 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: "arrow-fat-line-down", + size: 12, + weight: "bold", + color: "var(--accent-text)" + }), /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 12, + fontWeight: 600 + } + }, d.plan[0].name)), /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + fontSize: 26, + fontWeight: 700, + marginTop: 4 + } + }, "245 ", /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 12, + color: 'var(--text-secondary)' + } + }, "lb")), /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + fontSize: 11, + color: 'var(--text-secondary)' + } + }, "Set ", set, " of 4 \xB7 5 reps")), /*#__PURE__*/React.createElement(WatchButton, { + icon: "check", + onClick: () => setSet(s => Math.min(4, s + 1)) + }, "Log set"), /*#__PURE__*/React.createElement(WatchButton, { + icon: paused ? 'play' : 'pause', + tone: "surface", + onClick: () => setPaused(p => !p) + }, paused ? 'Resume' : 'Pause'), /*#__PURE__*/React.createElement(WatchButton, { + tone: "danger", + onClick: onFinish + }, "End workout"))); +} +function WatchSummary({ + onDone +}) { + const d = window.BFData; + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(WatchBar, { + title: "Complete" + }), /*#__PURE__*/React.createElement(WatchScroll, null, /*#__PURE__*/React.createElement("div", { + style: { + display: 'grid', + placeItems: 'center', + paddingTop: 4 + } + }, /*#__PURE__*/React.createElement(ProgressRing, { + progress: 1, + size: 78, + lineWidth: 9, + tint: "var(--accent)" + }, /*#__PURE__*/React.createElement(Icon, { + name: "check", + size: 26, + weight: "bold", + color: "var(--text-primary)" + }))), /*#__PURE__*/React.createElement("div", { + style: { + textAlign: 'center' + } + }, /*#__PURE__*/React.createElement("div", { + className: "bf-display", + style: { + fontSize: 18 + } + }, "Good work"), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 5, + fontSize: 10, + color: 'var(--text-secondary)', + marginTop: 2 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: SESSION_ICON(d.today.name), + size: 11, + weight: "bold", + color: "var(--accent-text)" + }), /*#__PURE__*/React.createElement("span", { + style: { + whiteSpace: 'nowrap' + } + }, d.today.name, " \xB7 42 min"))), /*#__PURE__*/React.createElement("div", { + style: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: 6 + } + }, [['14.2k', 'lb lifted', 'scales'], ['18', 'sets', 'stack-simple'], ['13', 'day streak', 'flame'], ['412', 'kcal', 'fire-simple']].map(([v, l, ic]) => /*#__PURE__*/React.createElement("div", { + key: l, + style: { + background: 'var(--surface)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-sm)', + padding: '8px 10px' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + gap: 4 + } + }, /*#__PURE__*/React.createElement(Icon, { + name: ic, + size: 11, + weight: "bold", + color: "var(--accent-text)" + }), /*#__PURE__*/React.createElement("div", { + className: "bf-num", + style: { + fontSize: 15, + fontWeight: 700 + } + }, v)), /*#__PURE__*/React.createElement("div", { + style: { + fontSize: 9, + color: 'var(--text-secondary)', + marginTop: 1 + } + }, l)))), /*#__PURE__*/React.createElement("div", { + style: { + display: 'flex', + alignItems: 'center', + gap: 6, + fontSize: 10, + color: 'var(--text-secondary)' + } + }, /*#__PURE__*/React.createElement(RecoveryDot, { + status: "fatigued", + size: 8 + }), /*#__PURE__*/React.createElement("span", null, "Back is now fatigued")), /*#__PURE__*/React.createElement(WatchButton, { + tone: "surface", + onClick: onDone + }, "Done"))); +} +Object.assign(window, { + WatchList, + WatchActive, + WatchSummary +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/watch_app/WatchScreens.jsx", error: String((e && e.message) || e) }); } + +// ui_kits/watch_app/WatchShell.jsx +try { (() => { +const { + Icon +} = window.BetterFitDesignSystem_6a70ea; + +/** 45mm Apple Watch screen: 396×484 logical points, 44px corner radius. */ +function Watch({ + children +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + position: 'relative', + padding: 14, + background: 'linear-gradient(160deg,#3a3a3f,#111114)', + borderRadius: 74, + boxShadow: '0 24px 60px rgba(0,0,0,.6)' + } + }, /*#__PURE__*/React.createElement("div", { + style: { + width: 198, + height: 242, + background: 'var(--bg-page)', + color: 'var(--text-primary)', + borderRadius: 44, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + fontFamily: 'var(--font-ui)' + } + }, children)); +} +function WatchBar({ + title +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + flex: '0 0 auto', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '8px 14px 4px' + } + }, /*#__PURE__*/React.createElement("span", { + style: { + fontSize: 11, + fontWeight: 600, + color: 'var(--accent-text)' + } + }, title), /*#__PURE__*/React.createElement("span", { + className: "bf-num", + style: { + fontSize: 11, + fontWeight: 600 + } + }, "9:41")); +} +function WatchScroll({ + children +}) { + return /*#__PURE__*/React.createElement("div", { + style: { + flex: 1, + overflowY: 'auto', + padding: '4px 10px 12px', + display: 'flex', + flexDirection: 'column', + gap: 8, + scrollbarWidth: 'none', + msOverflowStyle: 'none' + } + }, children); +} + +/** Full-width watch control — larger and simpler than its phone equivalent. */ +function WatchButton({ + children, + icon, + tone = 'accent', + onClick +}) { + const tones = { + accent: { + background: 'var(--accent)', + color: 'var(--text-on-accent)', + border: '1px solid transparent' + }, + surface: { + background: 'var(--surface-raised)', + color: 'var(--text-primary)', + border: '1px solid var(--border)' + }, + success: { + background: 'var(--yellow-bright)', + color: 'var(--bf-black)', + border: '1px solid transparent' + }, + danger: { + background: 'transparent', + color: 'var(--text-primary)', + border: '1px solid var(--border-strong)' + } + }; + return /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: onClick, + style: { + ...tones[tone], + width: '100%', + minHeight: 44, + borderRadius: 'var(--radius-md)', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + fontSize: 14, + fontWeight: 600, + fontFamily: 'var(--font-ui)' + } + }, icon && /*#__PURE__*/React.createElement(Icon, { + name: icon, + size: 14 + }), children); +} +Object.assign(window, { + Watch, + WatchBar, + WatchScroll, + WatchButton +}); +})(); } catch (e) { __ds_ns.__errors.push({ path: "ui_kits/watch_app/WatchShell.jsx", error: String((e && e.message) || e) }); } + +__ds_ns.Button = __ds_scope.Button; + +__ds_ns.Card = __ds_scope.Card; + +__ds_ns.Chip = __ds_scope.Chip; + +__ds_ns.Divider = __ds_scope.Divider; + +__ds_ns.DropdownChip = __ds_scope.DropdownChip; + +__ds_ns.Icon = __ds_scope.Icon; + +__ds_ns.IconButton = __ds_scope.IconButton; + +__ds_ns.Logo = __ds_scope.Logo; + +__ds_ns.MetricPill = __ds_scope.MetricPill; + +__ds_ns.PhotoSlot = __ds_scope.PhotoSlot; + +__ds_ns.SectionHeader = __ds_scope.SectionHeader; + +__ds_ns.ContributionHeatmap = __ds_scope.ContributionHeatmap; + +__ds_ns.Gauge = __ds_scope.Gauge; + +__ds_ns.LegendDot = __ds_scope.LegendDot; + +__ds_ns.MuscleChip = __ds_scope.MuscleChip; + +__ds_ns.OverviewStat = __ds_scope.OverviewStat; + +__ds_ns.ProgressRing = __ds_scope.ProgressRing; + +__ds_ns.StatTile = __ds_scope.StatTile; + +__ds_ns.Banner = __ds_scope.Banner; + +__ds_ns.EmptyState = __ds_scope.EmptyState; + +__ds_ns.RecoveryBadge = __ds_scope.RecoveryBadge; + +__ds_ns.RECOVERY_COLORS = __ds_scope.RECOVERY_COLORS; + +__ds_ns.RecoveryDot = __ds_scope.RecoveryDot; + +__ds_ns.RestTimerBar = __ds_scope.RestTimerBar; + +__ds_ns.ChevronRow = __ds_scope.ChevronRow; + +__ds_ns.ExerciseRow = __ds_scope.ExerciseRow; + +__ds_ns.ListRow = __ds_scope.ListRow; + +__ds_ns.SettingsRow = __ds_scope.SettingsRow; + +__ds_ns.SetLogField = __ds_scope.SetLogField; + +__ds_ns.SupersetIndicator = __ds_scope.SupersetIndicator; + +__ds_ns.WeightUnitToggle = __ds_scope.WeightUnitToggle; + +__ds_ns.WorkoutCard = __ds_scope.WorkoutCard; + +})(); diff --git a/design-system/_ds_manifest.json b/design-system/_ds_manifest.json new file mode 100644 index 0000000..3df230d --- /dev/null +++ b/design-system/_ds_manifest.json @@ -0,0 +1 @@ +{"namespace":"BetterFitDesignSystem_6a70ea","components":[{"name":"Button","sourcePath":"components/core/Button.jsx"},{"name":"Card","sourcePath":"components/core/Card.jsx"},{"name":"Chip","sourcePath":"components/core/Chip.jsx"},{"name":"Divider","sourcePath":"components/core/Divider.jsx"},{"name":"DropdownChip","sourcePath":"components/core/DropdownChip.jsx"},{"name":"Icon","sourcePath":"components/core/Icon.jsx"},{"name":"IconButton","sourcePath":"components/core/IconButton.jsx"},{"name":"Logo","sourcePath":"components/core/Logo.jsx"},{"name":"MetricPill","sourcePath":"components/core/MetricPill.jsx"},{"name":"PhotoSlot","sourcePath":"components/core/PhotoSlot.jsx"},{"name":"SectionHeader","sourcePath":"components/core/SectionHeader.jsx"},{"name":"ContributionHeatmap","sourcePath":"components/data/ContributionHeatmap.jsx"},{"name":"Gauge","sourcePath":"components/data/Gauge.jsx"},{"name":"LegendDot","sourcePath":"components/data/LegendDot.jsx"},{"name":"MuscleChip","sourcePath":"components/data/MuscleChip.jsx"},{"name":"OverviewStat","sourcePath":"components/data/OverviewStat.jsx"},{"name":"ProgressRing","sourcePath":"components/data/ProgressRing.jsx"},{"name":"StatTile","sourcePath":"components/data/StatTile.jsx"},{"name":"Banner","sourcePath":"components/feedback/Banner.jsx"},{"name":"EmptyState","sourcePath":"components/feedback/EmptyState.jsx"},{"name":"RecoveryBadge","sourcePath":"components/feedback/RecoveryBadge.jsx"},{"name":"RECOVERY_COLORS","sourcePath":"components/feedback/RecoveryDot.jsx"},{"name":"RecoveryDot","sourcePath":"components/feedback/RecoveryDot.jsx"},{"name":"RestTimerBar","sourcePath":"components/feedback/RestTimerBar.jsx"},{"name":"ChevronRow","sourcePath":"components/lists/ChevronRow.jsx"},{"name":"ExerciseRow","sourcePath":"components/lists/ExerciseRow.jsx"},{"name":"ListRow","sourcePath":"components/lists/ListRow.jsx"},{"name":"SettingsRow","sourcePath":"components/lists/SettingsRow.jsx"},{"name":"SetLogField","sourcePath":"components/workout/SetLogField.jsx"},{"name":"SupersetIndicator","sourcePath":"components/workout/SupersetIndicator.jsx"},{"name":"WeightUnitToggle","sourcePath":"components/workout/WeightUnitToggle.jsx"},{"name":"WorkoutCard","sourcePath":"components/workout/WorkoutCard.jsx"}],"startingPoints":[],"cards":[{"path":"guidelines/brand-app-marks.html","group":"Brand","viewport":"700x170","subtitle":"Lettermark, combo mark, and shipped app icon","name":"App marks"},{"path":"guidelines/brand-iconography.html","group":"Brand","viewport":"700x180","subtitle":"Phosphor fill weight standing in for SF Symbols — substitution, please confirm","name":"Iconography"},{"path":"guidelines/brand-identity-palette.html","group":"Brand","viewport":"700x150","subtitle":"Yellow, black, white — the entire palette, identity and product alike","name":"Identity palette"},{"path":"guidelines/brand-primary-lockup.html","group":"Brand","viewport":"700x180","subtitle":"Supplied artwork — never retype or redraw the mark","name":"Primary lockup"},{"path":"guidelines/brand-stacked.html","group":"Brand","viewport":"700x170","subtitle":"Wordmark-only lockups — white field and reversed black field","name":"Stacked lockups"},{"path":"guidelines/color-action.html","group":"Colors","viewport":"700x150","subtitle":"One accent: electric yellow with black labels","name":"Action — yellow"},{"path":"guidelines/color-glass.html","group":"Colors","viewport":"700x300","subtitle":"The only translucency in the system: nav, dock, swipe actions","name":"Glass — floating chrome"},{"path":"guidelines/color-heatmap-ramp.html","group":"Colors","viewport":"700x150","subtitle":"Five-step yellow ramp for the consistency heatmap","name":"Activity ramp"},{"path":"guidelines/color-recovery.html","group":"Colors","viewport":"700x180","subtitle":"Brightest = recovered, dimmest = sore. Never the only signal","name":"Recovery scale"},{"path":"guidelines/color-schemes.html","group":"Colors","viewport":"700x230","subtitle":"Same two colours, mirrored neutrals — [data-scheme] switches everything","name":"Light and dark"},{"path":"guidelines/color-status.html","group":"Colors","viewport":"700x180","subtitle":"Brightness plus wording carries meaning — no red, amber or green","name":"Status without hue"},{"path":"guidelines/color-surfaces-dark.html","group":"Colors","viewport":"700x150","subtitle":"Default scheme: page, elevated, surface, raised","name":"Surfaces — dark"},{"path":"guidelines/color-surfaces-light.html","group":"Colors","viewport":"700x150","subtitle":"[data-scheme=light] overrides","name":"Surfaces — light"},{"path":"guidelines/color-text.html","group":"Colors","viewport":"700x160","subtitle":"Primary, secondary, tertiary, on-accent, on-identity","name":"Text colors"},{"path":"components/core/core.card.html","group":"Components","viewport":"700x340","subtitle":"Buttons, icon buttons, cards, chips, dropdowns, pills, photo slots","name":"Core"},{"path":"components/data/data.card.html","group":"Components","viewport":"700x300","subtitle":"Rings, gauges, stat tiles, muscle split, activity heatmap","name":"Data"},{"path":"components/feedback/feedback.card.html","group":"Components","viewport":"700x330","subtitle":"Recovery dots and badges, banners, rest timer, empty state","name":"Feedback"},{"path":"components/lists/lists.card.html","group":"Components","viewport":"700x340","subtitle":"List rows, chevron rows, exercise timeline, settings rows","name":"Lists"},{"path":"components/workout/workout.card.html","group":"Components","viewport":"700x290","subtitle":"Session cards, set logging, unit toggle, superset marker","name":"Workout"},{"path":"ui_kits/ios_app/index.html","group":"iOS App","viewport":"440x880","subtitle":"Click-through: plan, live session, body, targets, log, summary, search, profile, settings","name":"BetterFit iPhone app"},{"path":"guidelines/motion-press.html","group":"Motion","viewport":"700x170","subtitle":"Opacity 0.85 and scale 0.985 on primary; 0.7 on secondary; 0.6 on ghost","name":"Press states"},{"path":"guidelines/motion-tokens.html","group":"Motion","viewport":"700x190","subtitle":"Short and responsive; no ambient loops","name":"Duration & easing"},{"path":"guidelines/space-controls.html","group":"Spacing","viewport":"700x200","subtitle":"54 primary CTA, 44 standard and minimum tap target, 34 compact","name":"Control sizes"},{"path":"guidelines/space-page.html","group":"Spacing","viewport":"700x190","subtitle":"20px page margins, 16px card padding, left-aligned stack","name":"Page rhythm"},{"path":"guidelines/space-radii.html","group":"Spacing","viewport":"700x170","subtitle":"16 cards, 14 buttons, 12 controls, capsule for chips only","name":"Corner radii"},{"path":"guidelines/space-scale.html","group":"Spacing","viewport":"700x170","subtitle":"4-point rhythm; 8/12/16/20/24/32 do most of the work","name":"Spacing scale"},{"path":"guidelines/type-body.html","group":"Type","viewport":"700x210","subtitle":"17 body, 15 supporting, 13/12 captions","name":"Body — system UI"},{"path":"guidelines/type-display.html","group":"Type","viewport":"700x230","subtitle":"Hero and title sizes, tracking -0.02em","name":"Display — BBH Hegarty"},{"path":"guidelines/type-labels.html","group":"Type","viewport":"700x160","subtitle":"Uppercase 12px, 0.1em tracking, secondary text","name":"Section labels"},{"path":"guidelines/type-numeric.html","group":"Type","viewport":"700x200","subtitle":"Tabular figures so changing values never jitter","name":"Numeric & timers"},{"path":"ui_kits/watch_app/index.html","group":"Watch App","viewport":"700x420","subtitle":"Workout list, live set tracking, and completion summary","name":"BetterFit Apple Watch app"}],"templates":[{"name":"BetterFit app screen","description":"iPhone screen scaffold: yellow identity band with black CTA, stat card, exercise timeline","folder":"templates/app-screen","entryPath":"templates/app-screen/AppScreen.dc.html","thumbnail":{"path":"templates/app-screen/.thumbnail","kind":"captured"}}],"hasThumbnailHtml":true,"globalCssPaths":["tokens/fonts.css","tokens/colors.css","tokens/typography.css","tokens/spacing.css","tokens/radii.css","tokens/elevation.css","tokens/motion.css","tokens/base.css","styles.css"],"tokens":[{"name":"--font-display","value":"\"BBH Hegarty\",\"Arial Black\",system-ui,sans-serif","kind":"font","definedIn":"tokens/fonts.css"},{"name":"--font-ui","value":"-apple-system,BlinkMacSystemFont,\"Segoe UI\",system-ui,sans-serif","kind":"font","definedIn":"tokens/fonts.css"},{"name":"--font-mono","value":"ui-monospace,\"SF Mono\",\"SFMono-Regular\",Menlo,monospace","kind":"font","definedIn":"tokens/fonts.css"},{"name":"--font-numeric","value":"var(--font-ui)","kind":"font","definedIn":"tokens/fonts.css"},{"name":"--bf-yellow","value":"#FFD60A","kind":"color","definedIn":"tokens/colors.css"},{"name":"--bf-black","value":"#000000","kind":"color","definedIn":"tokens/colors.css"},{"name":"--bf-white","value":"#FFFFFF","kind":"color","definedIn":"tokens/colors.css"},{"name":"--yellow-bright","value":"#FFE860","kind":"color","definedIn":"tokens/colors.css"},{"name":"--yellow-deep","value":"#C9A800","kind":"color","definedIn":"tokens/colors.css"},{"name":"--yellow-dim","value":"#7A6600","kind":"color","definedIn":"tokens/colors.css"},{"name":"--grey-950","value":"#0B0B0D","kind":"color","definedIn":"tokens/colors.css"},{"name":"--grey-900","value":"#131316","kind":"color","definedIn":"tokens/colors.css"},{"name":"--grey-880","value":"#17171B","kind":"color","definedIn":"tokens/colors.css"},{"name":"--grey-850","value":"#1F1F25","kind":"color","definedIn":"tokens/colors.css"},{"name":"--grey-500","value":"#9C9CA6","kind":"color","definedIn":"tokens/colors.css"},{"name":"--grey-400","value":"#63636D","kind":"color","definedIn":"tokens/colors.css"},{"name":"--grey-100","value":"#EFEFF3","kind":"color","definedIn":"tokens/colors.css"},{"name":"--grey-050","value":"#F6F6F8","kind":"color","definedIn":"tokens/colors.css"},{"name":"--grey-ink","value":"#131317","kind":"color","definedIn":"tokens/colors.css"},{"name":"--bg-page","value":"var(--grey-950)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--bg-elevated","value":"var(--grey-900)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--surface","value":"var(--grey-880)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--surface-raised","value":"var(--grey-850)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--surface-inverse","value":"var(--bf-yellow)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--border","value":"rgba(255,255,255,.07)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--border-strong","value":"rgba(255,255,255,.14)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--separator","value":"rgba(255,255,255,.06)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--text-primary","value":"#FFFFFF","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-secondary","value":"var(--grey-500)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-tertiary","value":"var(--grey-400)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-on-accent","value":"var(--bf-black)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--text-on-inverse","value":"var(--bf-black)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--accent","value":"var(--bf-yellow)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--accent-pressed","value":"var(--yellow-deep)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--accent-surface","value":"rgba(255,214,10,.15)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--glass-fill","value":"rgba(24,24,29,.72)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--glass-fill-strong","value":"rgba(18,18,22,.86)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--glass-border","value":"rgba(255,255,255,.12)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--glass-highlight","value":"rgba(255,255,255,.06)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--glass-blur","value":"blur(28px) saturate(180%)","kind":"other","definedIn":"tokens/colors.css","annotation":"other"},{"name":"--glass-shadow","value":"0 12px 40px rgba(0,0,0,.55)","kind":"shadow","definedIn":"tokens/colors.css"},{"name":"--accent-text","value":"var(--bf-yellow)","kind":"font","definedIn":"tokens/colors.css"},{"name":"--focus-ring","value":"var(--text-primary)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--success","value":"var(--yellow-bright)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--warning","value":"var(--bf-yellow)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--danger","value":"var(--text-primary)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--info","value":"var(--yellow-deep)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--success-surface","value":"rgba(255,232,96,.15)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--warning-surface","value":"rgba(255,214,10,.15)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--danger-surface","value":"rgba(255,255,255,.10)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--info-surface","value":"rgba(201,168,0,.18)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--recovery-recovered","value":"var(--yellow-bright)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--recovery-fresh","value":"var(--bf-yellow)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--recovery-fatigued","value":"var(--yellow-deep)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--recovery-sore","value":"var(--yellow-dim)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--heat-0","value":"rgba(255,255,255,.05)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--heat-1","value":"rgba(255,214,10,.22)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--heat-2","value":"rgba(255,214,10,.45)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--heat-3","value":"rgba(255,214,10,.7)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--heat-4","value":"var(--bf-yellow)","kind":"color","definedIn":"tokens/colors.css"},{"name":"--bg-page","value":"var(--grey-050)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--bg-elevated","value":"#FFFFFF","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--surface","value":"#FFFFFF","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--surface-raised","value":"var(--grey-100)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--border","value":"rgba(0,0,0,.08)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--border-strong","value":"rgba(0,0,0,.16)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--separator","value":"rgba(0,0,0,.06)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--text-primary","value":"var(--grey-ink)","kind":"font","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--text-secondary","value":"#61616B","kind":"font","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--text-tertiary","value":"#94949E","kind":"font","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--accent-text","value":"#8A7100","kind":"font","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--glass-fill","value":"rgba(255,255,255,.72)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--glass-fill-strong","value":"rgba(255,255,255,.88)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--glass-border","value":"rgba(0,0,0,.10)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--glass-highlight","value":"rgba(255,255,255,.6)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--glass-shadow","value":"0 12px 40px rgba(0,0,0,.16)","kind":"shadow","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--recovery-recovered","value":"var(--yellow-deep)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--recovery-fresh","value":"#A88C00","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--recovery-fatigued","value":"var(--yellow-dim)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--recovery-sore","value":"#4A3D00","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--info","value":"#8A7100","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--danger-surface","value":"rgba(0,0,0,.08)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--heat-0","value":"rgba(0,0,0,.06)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--heat-1","value":"rgba(201,168,0,.3)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--heat-2","value":"rgba(201,168,0,.55)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--heat-3","value":"rgba(201,168,0,.8)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--heat-4","value":"var(--yellow-deep)","kind":"color","definedIn":"tokens/colors.css","scope":"[data-scheme=\"light\"]"},{"name":"--text-hero","value":"56px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-large-title","value":"34px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-title-1","value":"28px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-title-2","value":"22px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-title-3","value":"20px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-headline","value":"17px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-body","value":"17px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-callout","value":"16px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-subheadline","value":"15px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-footnote","value":"13px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-caption","value":"12px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-timer","value":"40px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-stat-lg","value":"28px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-stat-md","value":"20px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--text-stat-sm","value":"15px","kind":"font","definedIn":"tokens/typography.css"},{"name":"--weight-regular","value":"400","kind":"font","definedIn":"tokens/typography.css"},{"name":"--weight-medium","value":"500","kind":"font","definedIn":"tokens/typography.css"},{"name":"--weight-semibold","value":"600","kind":"font","definedIn":"tokens/typography.css"},{"name":"--weight-bold","value":"700","kind":"font","definedIn":"tokens/typography.css"},{"name":"--weight-black","value":"800","kind":"font","definedIn":"tokens/typography.css"},{"name":"--leading-tight","value":"1.05","kind":"font","definedIn":"tokens/typography.css"},{"name":"--leading-snug","value":"1.2","kind":"font","definedIn":"tokens/typography.css"},{"name":"--leading-normal","value":"1.35","kind":"font","definedIn":"tokens/typography.css"},{"name":"--leading-relaxed","value":"1.5","kind":"font","definedIn":"tokens/typography.css"},{"name":"--tracking-display","value":".005em","kind":"font","definedIn":"tokens/typography.css"},{"name":"--tracking-normal","value":"0","kind":"font","definedIn":"tokens/typography.css"},{"name":"--tracking-label","value":".1em","kind":"font","definedIn":"tokens/typography.css"},{"name":"--space-xs","value":"4px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-sm","value":"8px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-md","value":"12px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-lg","value":"16px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-xl","value":"20px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-2xl","value":"24px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-3xl","value":"32px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--space-4xl","value":"48px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--page-inline","value":"20px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--card-padding","value":"16px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--control-lg","value":"54px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--control-md","value":"44px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--control-sm","value":"44px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--pill-sm","value":"34px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--tap-min","value":"44px","kind":"spacing","definedIn":"tokens/spacing.css"},{"name":"--radius-sm","value":"8px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-md","value":"12px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-button","value":"14px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-card","value":"16px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-sheet","value":"24px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-pill","value":"999px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--radius-band","value":"0px","kind":"radius","definedIn":"tokens/radii.css"},{"name":"--shadow-none","value":"none","kind":"shadow","definedIn":"tokens/elevation.css"},{"name":"--shadow-card","value":"none","kind":"shadow","definedIn":"tokens/elevation.css"},{"name":"--shadow-sheet","value":"0 -8px 30px rgba(0,0,0,.45)","kind":"shadow","definedIn":"tokens/elevation.css"},{"name":"--shadow-pop","value":"0 10px 30px rgba(0,0,0,.35)","kind":"shadow","definedIn":"tokens/elevation.css"},{"name":"--shadow-card","value":"0 1px 2px rgba(0,0,0,.05)","kind":"shadow","definedIn":"tokens/elevation.css","scope":"[data-scheme=\"light\"]"},{"name":"--shadow-sheet","value":"0 -8px 30px rgba(0,0,0,.12)","kind":"shadow","definedIn":"tokens/elevation.css","scope":"[data-scheme=\"light\"]"},{"name":"--shadow-pop","value":"0 10px 24px rgba(0,0,0,.12)","kind":"shadow","definedIn":"tokens/elevation.css","scope":"[data-scheme=\"light\"]"},{"name":"--dur-press","value":"0ms","kind":"other","definedIn":"tokens/motion.css","annotation":"other"},{"name":"--dur-fast","value":"0ms","kind":"other","definedIn":"tokens/motion.css","annotation":"other"},{"name":"--dur-standard","value":"0ms","kind":"other","definedIn":"tokens/motion.css","annotation":"other"},{"name":"--dur-slow","value":"0ms","kind":"other","definedIn":"tokens/motion.css","annotation":"other"},{"name":"--ease-out","value":"cubic-bezier(.2,.8,.3,1)","kind":"other","definedIn":"tokens/motion.css","annotation":"other"},{"name":"--ease-snappy","value":"cubic-bezier(.32,.72,0,1)","kind":"other","definedIn":"tokens/motion.css","annotation":"other"},{"name":"--ease-spring","value":"cubic-bezier(.34,1.36,.64,1)","kind":"other","definedIn":"tokens/motion.css","annotation":"other"},{"name":"--press-opacity","value":".85","kind":"other","definedIn":"tokens/motion.css","annotation":"other"},{"name":"--press-scale","value":".985","kind":"other","definedIn":"tokens/motion.css","annotation":"other"}],"themes":[{"selector":"[data-scheme=\"light\"]","label":"Scheme Light"}],"fonts":[{"family":"BBH Hegarty","weight":"400","style":"normal","cssPath":"tokens/fonts.css","files":["assets/fonts/BBHHegarty-Regular.ttf"]}],"brandFonts":[{"family":"BBH Hegarty","status":"ok","tokens":["--font-display"],"path":"tokens/fonts.css"}],"source":"spa"} \ No newline at end of file diff --git a/design-system/assets/app-icon-1024.png b/design-system/assets/app-icon-1024.png new file mode 100644 index 0000000..c31db1f Binary files /dev/null and b/design-system/assets/app-icon-1024.png differ diff --git a/design-system/assets/fonts/BBHHegarty-Regular.ttf b/design-system/assets/fonts/BBHHegarty-Regular.ttf new file mode 100644 index 0000000..ac9a63f Binary files /dev/null and b/design-system/assets/fonts/BBHHegarty-Regular.ttf differ diff --git a/design-system/assets/fonts/OFL.txt b/design-system/assets/fonts/OFL.txt new file mode 100644 index 0000000..6baf686 --- /dev/null +++ b/design-system/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2025 The BBH Project Authors (https://github.com/Studio-DRAMA/BBH) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/design-system/assets/icon-combo.png b/design-system/assets/icon-combo.png new file mode 100644 index 0000000..59c5085 Binary files /dev/null and b/design-system/assets/icon-combo.png differ diff --git a/design-system/assets/icon-lettermark.png b/design-system/assets/icon-lettermark.png new file mode 100644 index 0000000..9af7c5a Binary files /dev/null and b/design-system/assets/icon-lettermark.png differ diff --git a/design-system/assets/logo-stacked-dark.png b/design-system/assets/logo-stacked-dark.png new file mode 100644 index 0000000..f6a12b1 Binary files /dev/null and b/design-system/assets/logo-stacked-dark.png differ diff --git a/design-system/assets/logo-stacked.png b/design-system/assets/logo-stacked.png new file mode 100644 index 0000000..f2d8372 Binary files /dev/null and b/design-system/assets/logo-stacked.png differ diff --git a/design-system/assets/logo.png b/design-system/assets/logo.png new file mode 100644 index 0000000..72e7e60 Binary files /dev/null and b/design-system/assets/logo.png differ diff --git a/design-system/components/core/Button.d.ts b/design-system/components/core/Button.d.ts new file mode 100644 index 0000000..1d8d6c4 --- /dev/null +++ b/design-system/components/core/Button.d.ts @@ -0,0 +1,18 @@ +export interface ButtonProps { + children?: React.ReactNode; + /** primary = yellow fill with black label; destructive = inverted (no red exists in this system). */ + variant?: 'primary' | 'secondary' | 'ghost' | 'destructive'; + /** lg = 54px CTA, md = 44px, sm = 34px. */ + size?: 'lg' | 'md' | 'sm'; + /** Defaults true for primary. */ + fullWidth?: boolean; + /** Phosphor icon slug rendered before the label. */ + icon?: string; + /** Phosphor icon slug rendered after the label, at 70% opacity. */ + trailingIcon?: string; + disabled?: boolean; + onClick?: () => void; + style?: React.CSSProperties; +} + +export declare function Button(props: ButtonProps): JSX.Element; diff --git a/design-system/components/core/Button.jsx b/design-system/components/core/Button.jsx new file mode 100644 index 0000000..26e6492 --- /dev/null +++ b/design-system/components/core/Button.jsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import { Icon } from './Icon.jsx'; + +const HEIGHT = { lg: 'var(--control-lg)', md: 'var(--control-md)', sm: 'var(--control-sm)' }; +const FONT = { lg: 'var(--text-headline)', md: 'var(--text-headline)', sm: 'var(--text-subheadline)' }; + +const VARIANTS = { + primary: { background: 'var(--accent)', color: 'var(--text-on-accent)', border: '1px solid transparent' }, + secondary: { background: 'var(--surface-raised)', color: 'var(--text-primary)', border: '1px solid var(--border)' }, + ghost: { background: 'transparent', color: 'var(--accent-text)', border: '1px solid transparent' }, + /* Destructive is inverted, not red — the system has no alarm hue. */ + destructive: { background: 'var(--text-primary)', color: 'var(--bg-page)', border: '1px solid transparent' } +}; + +/** Actions. One full-width primary per view; secondary/ghost for everything else. */ +export function Button({ + children, variant = 'primary', size = 'lg', fullWidth = variant === 'primary', + icon, trailingIcon, disabled = false, onClick, style, ...rest +}) { + const [pressed, setPressed] = useState(false); + const v = VARIANTS[variant] || VARIANTS.primary; + const isGhost = variant === 'ghost'; + return ( + <button + type="button" + disabled={disabled} + onClick={onClick} + onPointerDown={() => setPressed(true)} + onPointerUp={() => setPressed(false)} + onPointerLeave={() => setPressed(false)} + style={{ + ...v, + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8, + height: isGhost ? 'auto' : HEIGHT[size], + /* Ghost has no container, but it still needs a thumb-sized hit area. */ + minHeight: 'var(--tap-min)', + padding: isGhost ? '0 2px' : `0 ${size === 'sm' ? 16 : 20}px`, + width: fullWidth ? '100%' : 'auto', + fontFamily: 'var(--font-ui)', fontSize: FONT[size], + fontWeight: 'var(--weight-semibold)', + borderRadius: isGhost ? 0 : 'var(--radius-button)', + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.4 : pressed ? (variant === 'secondary' ? 0.7 : isGhost ? 0.6 : 'var(--press-opacity)') : 1, + transform: pressed && !isGhost ? 'scale(var(--press-scale))' : 'none', + transition: 'opacity var(--dur-press) var(--ease-out), transform var(--dur-press) var(--ease-out)', + ...style + }} + {...rest} + > + {icon && <Icon name={icon} size={size === 'sm' ? 14 : 17} />} + <span>{children}</span> + {trailingIcon && <Icon name={trailingIcon} size={size === 'sm' ? 12 : 14} style={{ opacity: 0.7 }} />} + </button> + ); +} diff --git a/design-system/components/core/Button.prompt.md b/design-system/components/core/Button.prompt.md new file mode 100644 index 0000000..eaf5148 --- /dev/null +++ b/design-system/components/core/Button.prompt.md @@ -0,0 +1,11 @@ +The action primitive — one full-width yellow `primary` per screen, everything else secondary or ghost. + +```jsx +<Button icon="play" trailingIcon="caret-right">Start workout</Button> +<Button variant="secondary" fullWidth={false}>Swap exercise</Button> +<Button variant="ghost" fullWidth={false}>View details</Button> +``` + +Variants: `primary` (electric yellow fill, black label), `secondary` (raised surface + hairline), `ghost` (accent text, no container), `destructive` (inverted — white fill with black label on dark, black fill with light label on light; the system has no red). Sizes `lg`/`md`/`sm` map to 54/44/34px; never go below a 44px tap target for touch. + +Because the primary action *is* the brand yellow, a screen gets exactly one. A second yellow fill next to it destroys the hierarchy. diff --git a/design-system/components/core/Card.d.ts b/design-system/components/core/Card.d.ts new file mode 100644 index 0000000..7b77fc0 --- /dev/null +++ b/design-system/components/core/Card.d.ts @@ -0,0 +1,12 @@ +export interface CardProps { + children?: React.ReactNode; + /** Internal padding in px. 16 is the standard. */ + padding?: number; + /** CSS length. Defaults to var(--radius-card) = 16px. */ + radius?: string; + /** identity = yellow field with black content, for brand moments only. */ + tone?: 'surface' | 'raised' | 'identity' | 'outline'; + style?: React.CSSProperties; +} + +export declare function Card(props: CardProps): JSX.Element; diff --git a/design-system/components/core/Card.jsx b/design-system/components/core/Card.jsx new file mode 100644 index 0000000..16544a6 --- /dev/null +++ b/design-system/components/core/Card.jsx @@ -0,0 +1,16 @@ +import React from 'react'; + +/** Flat card: solid surface + hairline border + 16px continuous corners. No glass, no blur. */ +export function Card({ children, padding = 16, radius = 'var(--radius-card)', tone = 'surface', style, ...rest }) { + const tones = { + surface: { background: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--text-primary)' }, + raised: { background: 'var(--surface-raised)', border: '1px solid var(--border)', color: 'var(--text-primary)' }, + identity: { background: 'var(--bf-yellow)', border: '1px solid transparent', color: 'var(--bf-black)' }, + outline: { background: 'transparent', border: '1px solid var(--border-strong)', color: 'var(--text-primary)' } + }; + return ( + <div style={{ ...tones[tone], padding, borderRadius: radius, boxShadow: 'var(--shadow-card)', ...style }} {...rest}> + {children} + </div> + ); +} diff --git a/design-system/components/core/Card.prompt.md b/design-system/components/core/Card.prompt.md new file mode 100644 index 0000000..aafc7c5 --- /dev/null +++ b/design-system/components/core/Card.prompt.md @@ -0,0 +1,10 @@ +Groups a related decision or set of information on a flat solid surface. + +```jsx +<Card> + <div className="bf-label">Today</div> + <div className="bf-display" style={{ fontSize: 'var(--text-title-1)' }}>Push A</div> +</Card> +``` + +Tones: `surface` (default), `raised` (nested wells, segmented backgrounds), `outline`, `identity` (yellow brand field). A card must earn its container — content that could sit directly on the page should. diff --git a/design-system/components/core/Chip.d.ts b/design-system/components/core/Chip.d.ts new file mode 100644 index 0000000..8c5b0a5 --- /dev/null +++ b/design-system/components/core/Chip.d.ts @@ -0,0 +1,10 @@ +export interface ChipProps { + label: string; + /** Phosphor icon slug rendered at 12px before the label. */ + icon?: string; + selected?: boolean; + onClick?: () => void; + style?: React.CSSProperties; +} + +export declare function Chip(props: ChipProps): JSX.Element; diff --git a/design-system/components/core/Chip.jsx b/design-system/components/core/Chip.jsx new file mode 100644 index 0000000..3238d11 --- /dev/null +++ b/design-system/components/core/Chip.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Icon } from './Icon.jsx'; + +/** Filter / selection capsule. Selected = accent fill, unselected = raised surface + hairline. */ +export function Chip({ label, icon, selected = false, onClick, style, ...rest }) { + return ( + <button + type="button" onClick={onClick} aria-pressed={selected} + style={{ + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, minHeight: 'var(--tap-min)', padding: '0 16px', + borderRadius: 'var(--radius-pill)', cursor: onClick ? 'pointer' : 'default', + background: selected ? 'var(--accent)' : 'var(--surface-raised)', + color: selected ? 'var(--text-on-accent)' : 'var(--text-primary)', + border: selected ? '1px solid transparent' : '1px solid var(--border)', + fontSize: 'var(--text-footnote)', fontWeight: 'var(--weight-semibold)', fontFamily: 'var(--font-ui)', + transition: 'background var(--dur-fast) var(--ease-out)', ...style + }} + {...rest} + > + {icon && <Icon name={icon} size={14} />} + {label} + </button> + ); +} diff --git a/design-system/components/core/Chip.prompt.md b/design-system/components/core/Chip.prompt.md new file mode 100644 index 0000000..b58ac39 --- /dev/null +++ b/design-system/components/core/Chip.prompt.md @@ -0,0 +1,8 @@ +Compact filter or selection capsule — equipment, muscle groups, date ranges. + +```jsx +<Chip label="Barbell" icon="barbell" selected /> +<Chip label="Dumbbell" /> +``` + +Capsule radius is reserved for chips; larger containers use 12–16px corners. diff --git a/design-system/components/core/Divider.d.ts b/design-system/components/core/Divider.d.ts new file mode 100644 index 0000000..73a2127 --- /dev/null +++ b/design-system/components/core/Divider.d.ts @@ -0,0 +1,7 @@ +export interface DividerProps { + /** Horizontal inset in px on both ends. */ + inset?: number; + style?: React.CSSProperties; +} + +export declare function Divider(props: DividerProps): JSX.Element; diff --git a/design-system/components/core/Divider.jsx b/design-system/components/core/Divider.jsx new file mode 100644 index 0000000..b2d69ae --- /dev/null +++ b/design-system/components/core/Divider.jsx @@ -0,0 +1,6 @@ +import React from 'react'; + +/** 1px separator. Use sparingly — spacing usually does the job. */ +export function Divider({ inset = 0, style, ...rest }) { + return <div role="separator" style={{ height: 1, background: 'var(--separator)', marginLeft: inset, marginRight: inset, ...style }} {...rest} />; +} diff --git a/design-system/components/core/Divider.prompt.md b/design-system/components/core/Divider.prompt.md new file mode 100644 index 0000000..8df03d2 --- /dev/null +++ b/design-system/components/core/Divider.prompt.md @@ -0,0 +1,8 @@ +A hairline separator between stacked groups. + +```jsx +<Divider /> +<Divider inset={56} /> +``` + +Inset it to the content edge inside list rows that have a leading icon. diff --git a/design-system/components/core/DropdownChip.d.ts b/design-system/components/core/DropdownChip.d.ts new file mode 100644 index 0000000..17ea25b --- /dev/null +++ b/design-system/components/core/DropdownChip.d.ts @@ -0,0 +1,10 @@ +export interface DropdownChipProps { + /** The current value, not the field name — "45m", not "Duration". */ + label: string; + /** lg = 48px header chip; md = 34px inline chip. */ + size?: 'md' | 'lg'; + onClick?: () => void; + style?: React.CSSProperties; +} + +export declare function DropdownChip(props: DropdownChipProps): JSX.Element; diff --git a/design-system/components/core/DropdownChip.jsx b/design-system/components/core/DropdownChip.jsx new file mode 100644 index 0000000..0bfb878 --- /dev/null +++ b/design-system/components/core/DropdownChip.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Icon } from './Icon.jsx'; + +/** Capsule that opens a menu. Reads as a current value, not a filter. */ +export function DropdownChip({ label, size = 'md', onClick, style, ...rest }) { + const big = size === 'lg'; + return ( + <button + type="button" onClick={onClick} + style={{ + display: 'inline-flex', alignItems: 'center', gap: big ? 10 : 6, + padding: big ? '12px 20px' : '8px 14px', minHeight: big ? 48 : 34, + borderRadius: 'var(--radius-pill)', cursor: 'pointer', + background: 'var(--surface-raised)', border: '1px solid var(--border)', + color: 'var(--text-primary)', fontFamily: 'var(--font-ui)', + fontSize: big ? 'var(--text-callout)' : 'var(--text-footnote)', + fontWeight: 'var(--weight-semibold)', ...style + }} + {...rest} + > + {label} + <Icon name="caret-down" size={big ? 13 : 11} weight="bold" style={{ opacity: 0.8 }} /> + </button> + ); +} diff --git a/design-system/components/core/DropdownChip.prompt.md b/design-system/components/core/DropdownChip.prompt.md new file mode 100644 index 0000000..ba55a50 --- /dev/null +++ b/design-system/components/core/DropdownChip.prompt.md @@ -0,0 +1,8 @@ +A capsule showing a current selection that opens a menu when tapped — duration, gym, date range. + +```jsx +<DropdownChip label="45m" size="lg" onClick={openDuration} /> +<DropdownChip label="Anytime Fitness" size="lg" onClick={openGym} /> +``` + +Always label it with the chosen value. Use `Chip` instead when the control toggles rather than opens. diff --git a/design-system/components/core/Icon.d.ts b/design-system/components/core/Icon.d.ts new file mode 100644 index 0000000..826e14c --- /dev/null +++ b/design-system/components/core/Icon.d.ts @@ -0,0 +1,18 @@ +export type IconWeight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'; + +export interface IconProps { + /** Phosphor icon slug, e.g. "barbell", "fire-simple", "heartbeat". */ + name: string; + /** Rendered px size. Match adjacent text size or step up one. */ + size?: number; + /** + * Defaults to "fill" — closest match to SF Symbols semibold. Stroke-only + * glyphs (plus, check, carets, arrows, magnifying-glass, …) have no fill cut + * and fall back to "bold" automatically. + */ + weight?: IconWeight; + color?: string; + style?: React.CSSProperties; +} + +export declare function Icon(props: IconProps): JSX.Element; diff --git a/design-system/components/core/Icon.jsx b/design-system/components/core/Icon.jsx new file mode 100644 index 0000000..11d9a22 --- /dev/null +++ b/design-system/components/core/Icon.jsx @@ -0,0 +1,32 @@ +import React from 'react'; + +/** + * Phosphor glyphs that are pure strokes have no `fill` cut — asking for one + * renders tofu. These silently fall back to `bold`, which is the correct + * visual match anyway. + */ +const STROKE_ONLY = new Set([ + 'plus', 'minus', 'x', 'check', 'equals', 'divide', + 'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right', + 'arrow-up-right', 'arrow-up-left', 'arrow-down-right', 'arrow-down-left', + 'arrows-clockwise', 'arrows-counter-clockwise', 'arrows-left-right', 'arrows-out', 'arrows-in', + 'caret-up', 'caret-down', 'caret-left', 'caret-right', + 'caret-double-up', 'caret-double-down', 'caret-double-left', 'caret-double-right', + 'dots-three', 'dots-three-vertical', 'dots-six', + 'magnifying-glass', 'magnifying-glass-plus', 'magnifying-glass-minus', + 'list', 'list-checks', 'list-bullets', 'sliders-horizontal', 'sliders', + 'chart-line', 'chart-line-up', 'chart-line-down', 'wifi-high', 'wifi-medium' +]); + +/** Phosphor Icons stand-in for SF Symbols. See readme.md → ICONOGRAPHY. */ +export function Icon({ name, size = 16, weight = 'fill', color = 'currentColor', style, ...rest }) { + const w = weight === 'fill' && STROKE_ONLY.has(name) ? 'bold' : weight; + return ( + <i + className={`ph-${w} ph-${name}`} + aria-hidden="true" + style={{ fontSize: size, lineHeight: 1, color, display: 'inline-flex', ...style }} + {...rest} + /> + ); +} diff --git a/design-system/components/core/Icon.prompt.md b/design-system/components/core/Icon.prompt.md new file mode 100644 index 0000000..ac25566 --- /dev/null +++ b/design-system/components/core/Icon.prompt.md @@ -0,0 +1,14 @@ +Renders one Phosphor icon; use it anywhere the iOS app uses an SF Symbol. + +```jsx +<Icon name="barbell" size={18} /> +<Icon name="fire-simple" size={16} color="var(--accent)" /> +``` + +Requires the Phosphor webfont in the page head: +`<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/fill/style.css">` +(add `/bold/style.css` too if you use `weight="bold"`). + +Default weight is `fill`. Keep one weight per screen — BetterFit never mixes stroke and fill icons. + +Stroke-only glyphs (`plus`, `check`, `x`, carets, arrows, `magnifying-glass`, `list-checks`, `sliders-horizontal`, `chart-line*`) have no fill cut in Phosphor; the component falls back to `bold` for those so they never render as tofu. Load the bold stylesheet alongside fill. diff --git a/design-system/components/core/IconButton.d.ts b/design-system/components/core/IconButton.d.ts new file mode 100644 index 0000000..0bd5e3b --- /dev/null +++ b/design-system/components/core/IconButton.d.ts @@ -0,0 +1,13 @@ +export interface IconButtonProps { + /** Phosphor icon slug. */ + icon: string; + /** Required accessible label — the glyph alone is not a name. */ + label: string; + /** Diameter in px. 40 default; use 44 for primary touch targets. */ + size?: number; + variant?: 'surface' | 'accent' | 'quiet'; + onClick?: () => void; + style?: React.CSSProperties; +} + +export declare function IconButton(props: IconButtonProps): JSX.Element; diff --git a/design-system/components/core/IconButton.jsx b/design-system/components/core/IconButton.jsx new file mode 100644 index 0000000..5152be5 --- /dev/null +++ b/design-system/components/core/IconButton.jsx @@ -0,0 +1,27 @@ +import React, { useState } from 'react'; +import { Icon } from './Icon.jsx'; + +/** Circular icon control on a raised surface. Chrome buttons, toolbars, row affordances. + `size` is the visible circle; the hit area never drops below `--tap-min`. */ +export function IconButton({ icon, label, size = 44, variant = 'surface', onClick, style, ...rest }) { + const [pressed, setPressed] = useState(false); + const fills = { + surface: { background: 'var(--surface-raised)', color: 'var(--text-primary)', border: '1px solid var(--border)' }, + accent: { background: 'var(--accent)', color: 'var(--text-on-accent)', border: '1px solid transparent' }, + quiet: { background: 'transparent', color: 'var(--text-secondary)', border: '1px solid transparent' } + }; + return ( + <button + type="button" aria-label={label} onClick={onClick} + onPointerDown={() => setPressed(true)} onPointerUp={() => setPressed(false)} onPointerLeave={() => setPressed(false)} + style={{ + ...fills[variant], width: size, height: size, minWidth: 'var(--tap-min)', minHeight: 'var(--tap-min)', borderRadius: 'var(--radius-pill)', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0, + opacity: pressed ? 0.7 : 1, transition: 'opacity var(--dur-press) var(--ease-out)', ...style + }} + {...rest} + > + <Icon name={icon} size={Math.round(size * 0.4)} /> + </button> + ); +} diff --git a/design-system/components/core/IconButton.prompt.md b/design-system/components/core/IconButton.prompt.md new file mode 100644 index 0000000..56544ec --- /dev/null +++ b/design-system/components/core/IconButton.prompt.md @@ -0,0 +1,8 @@ +Circular icon-only control — nav chrome, card overflow menus, compact toolbars. + +```jsx +<IconButton icon="chart-bar" label="Stats" /> +<IconButton icon="plus" label="Add exercise" variant="accent" size={44} /> +``` + +`variant="surface"` is the default raised chrome treatment; `accent` for a single emphasised add action; `quiet` for inline row affordances. diff --git a/design-system/components/core/Logo.d.ts b/design-system/components/core/Logo.d.ts new file mode 100644 index 0000000..5e27e3c --- /dev/null +++ b/design-system/components/core/Logo.d.ts @@ -0,0 +1,17 @@ +export type LogoVariant = 'wordmark' | 'stacked' | 'stacked-dark' | 'lettermark' | 'combo' | 'appicon'; + +export interface LogoProps { + /** + * wordmark = mark over BETTER FIT on electric yellow — the only variant that includes the mark. + * stacked / stacked-dark = wordmark only, on white / black (their mark area renders blank in the + * supplied artwork). lettermark / combo / appicon = square app marks on yellow. + */ + variant?: LogoVariant; + /** Rendered height in px. Width follows the artwork. */ + height?: number; + /** Path prefix to /assets from the consuming page. Default "assets". */ + assetBase?: string; + style?: React.CSSProperties; +} + +export declare function Logo(props: LogoProps): JSX.Element; diff --git a/design-system/components/core/Logo.jsx b/design-system/components/core/Logo.jsx new file mode 100644 index 0000000..2121ae0 --- /dev/null +++ b/design-system/components/core/Logo.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const FILES = { + wordmark: 'logo.png', + stacked: 'logo-stacked.png', + 'stacked-dark': 'logo-stacked-dark.png', + lettermark: 'icon-lettermark.png', + combo: 'icon-combo.png', + appicon: 'app-icon-1024.png' +}; + +/** Renders supplied BetterFit artwork. Never redraw or retype the mark. */ +export function Logo({ variant = 'wordmark', height = 40, assetBase = 'assets', style, ...rest }) { + return ( + <img + src={`${assetBase}/${FILES[variant] || FILES.wordmark}`} + alt="BetterFit" + style={{ height, width: 'auto', display: 'block', ...style }} + {...rest} + /> + ); +} diff --git a/design-system/components/core/Logo.prompt.md b/design-system/components/core/Logo.prompt.md new file mode 100644 index 0000000..11dac83 --- /dev/null +++ b/design-system/components/core/Logo.prompt.md @@ -0,0 +1,9 @@ +Places the supplied BetterFit logo artwork — the only correct way to show the brand mark. + +```jsx +<Logo variant="wordmark" height={120} assetBase="../../assets" /> +``` + +Variants: `wordmark` (mark + BETTER FIT on yellow — the only file containing the mark), `stacked` / `stacked-dark` (wordmark only, white / black field; their mark area is blank in the supplied artwork), `lettermark`, `combo`, `appicon` (square app marks). + +On a dark screen use `wordmark` or `lettermark`; `stacked` is a white slab and will read as an empty panel. Give the mark clear space of at least its own cap height and never place elements inside it, recolour it, or set the name in live type as a substitute. diff --git a/design-system/components/core/MetricPill.d.ts b/design-system/components/core/MetricPill.d.ts new file mode 100644 index 0000000..bc20c38 --- /dev/null +++ b/design-system/components/core/MetricPill.d.ts @@ -0,0 +1,11 @@ +export interface MetricPillProps { + /** Word part, e.g. "Weekly". */ + label?: string; + /** Numeric part, rendered with tabular figures. */ + value?: string | number; + icon?: string; + tone?: 'neutral' | 'accent' | 'success'; + style?: React.CSSProperties; +} + +export declare function MetricPill(props: MetricPillProps): JSX.Element; diff --git a/design-system/components/core/MetricPill.jsx b/design-system/components/core/MetricPill.jsx new file mode 100644 index 0000000..29b1540 --- /dev/null +++ b/design-system/components/core/MetricPill.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Icon } from './Icon.jsx'; + +/** Small labelled metric capsule. Weekly counts, durations, ranges. */ +export function MetricPill({ label, value, icon, tone = 'neutral', style, ...rest }) { + const tones = { + neutral: { color: 'var(--text-secondary)', background: 'var(--surface-raised)', border: '1px solid var(--border)' }, + accent: { color: 'var(--accent-text)', background: 'var(--accent-surface)', border: '1px solid transparent' }, + success: { color: 'var(--text-primary)', background: 'var(--success-surface)', border: '1px solid transparent' } + }; + return ( + <span style={{ + ...tones[tone], display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 10px', + borderRadius: 'var(--radius-pill)', fontSize: 'var(--text-caption)', fontWeight: 'var(--weight-semibold)', ...style + }} {...rest}> + {icon && <Icon name={icon} size={12} />} + {label && <span>{label}</span>} + {value != null && <span className="bf-num">{value}</span>} + </span> + ); +} diff --git a/design-system/components/core/MetricPill.prompt.md b/design-system/components/core/MetricPill.prompt.md new file mode 100644 index 0000000..1cd077b --- /dev/null +++ b/design-system/components/core/MetricPill.prompt.md @@ -0,0 +1,8 @@ +A read-only metric capsule for secondary counts beside a heading. + +```jsx +<MetricPill label="Weekly" value="3/5" /> +<MetricPill icon="fire-simple" value="12" tone="accent" /> +``` + +Not interactive — reach for `Chip` when the user can tap it. diff --git a/design-system/components/core/PhotoSlot.d.ts b/design-system/components/core/PhotoSlot.d.ts new file mode 100644 index 0000000..5a52a68 --- /dev/null +++ b/design-system/components/core/PhotoSlot.d.ts @@ -0,0 +1,14 @@ +export interface PhotoSlotProps { + /** Real image URL. Omit to render the placeholder. */ + src?: string; + alt?: string; + width?: number | string; + height?: number | string; + /** CSS length. Defaults to var(--radius-md). */ + radius?: string; + /** Short note describing what belongs here, shown in the placeholder. */ + label?: string; + style?: React.CSSProperties; +} + +export declare function PhotoSlot(props: PhotoSlotProps): JSX.Element; diff --git a/design-system/components/core/PhotoSlot.jsx b/design-system/components/core/PhotoSlot.jsx new file mode 100644 index 0000000..be99575 --- /dev/null +++ b/design-system/components/core/PhotoSlot.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Icon } from './Icon.jsx'; + +/** + * Placeholder for exercise / editorial photography. BetterFit ships no image + * assets, so every photo position renders as a labelled slot until real + * artwork is supplied. Pass `src` once you have it. + */ +export function PhotoSlot({ src, alt = '', width = 168, height = 240, radius = 'var(--radius-md)', label, style, ...rest }) { + if (src) { + return <img src={src} alt={alt} style={{ width, height, objectFit: 'cover', borderRadius: radius, display: 'block', ...style }} {...rest} />; + } + return ( + <div + role="img" aria-label={alt || 'Photography placeholder'} + style={{ + width, height, borderRadius: radius, background: 'var(--surface-raised)', + border: '1px dashed var(--border-strong)', display: 'flex', flexDirection: 'column', + alignItems: 'center', justifyContent: 'center', gap: 6, padding: 8, textAlign: 'center', ...style + }} + {...rest} + > + <Icon name="image" size={18} color="var(--text-tertiary)" /> + {label && <span style={{ fontSize: 'var(--text-caption)', color: 'var(--text-tertiary)', lineHeight: 1.2 }}>{label}</span>} + </div> + ); +} diff --git a/design-system/components/core/PhotoSlot.prompt.md b/design-system/components/core/PhotoSlot.prompt.md new file mode 100644 index 0000000..30cc45f --- /dev/null +++ b/design-system/components/core/PhotoSlot.prompt.md @@ -0,0 +1,8 @@ +Holds a photograph. Renders a labelled dashed slot until a real `src` exists — BetterFit ships no imagery. + +```jsx +<PhotoSlot width={168} height={240} label="Lat pulldown" alt="Lat pulldown demonstration" /> +<PhotoSlot src="/photos/lat-pulldown.jpg" alt="Lat pulldown demonstration" /> +``` + +Never fill a photo position with stock imagery, an illustration, or a flat colour block — leave the slot visible so the missing asset stays obvious. diff --git a/design-system/components/core/SectionHeader.d.ts b/design-system/components/core/SectionHeader.d.ts new file mode 100644 index 0000000..8cf5202 --- /dev/null +++ b/design-system/components/core/SectionHeader.d.ts @@ -0,0 +1,9 @@ +export interface SectionHeaderProps { + /** Short label, rendered uppercase with generous tracking. */ + title: string; + /** Trailing node — a ghost Button, count, or IconButton. */ + action?: React.ReactNode; + style?: React.CSSProperties; +} + +export declare function SectionHeader(props: SectionHeaderProps): JSX.Element; diff --git a/design-system/components/core/SectionHeader.jsx b/design-system/components/core/SectionHeader.jsx new file mode 100644 index 0000000..73e72d9 --- /dev/null +++ b/design-system/components/core/SectionHeader.jsx @@ -0,0 +1,14 @@ +import React from 'react'; + +/** Uppercased tracked section marker with an optional trailing action. */ +export function SectionHeader({ title, action, style, ...rest }) { + return ( + <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, ...style }} {...rest}> + <span style={{ + fontSize: 'var(--text-caption)', fontWeight: 'var(--weight-semibold)', textTransform: 'uppercase', + letterSpacing: 'var(--tracking-label)', color: 'var(--text-secondary)' + }}>{title}</span> + {action} + </div> + ); +} diff --git a/design-system/components/core/SectionHeader.prompt.md b/design-system/components/core/SectionHeader.prompt.md new file mode 100644 index 0000000..9e971e9 --- /dev/null +++ b/design-system/components/core/SectionHeader.prompt.md @@ -0,0 +1,7 @@ +Marks a section of a screen. Uppercase, tracked, secondary text — never a display heading. + +```jsx +<SectionHeader title="Up next" action={<Button variant="ghost" fullWidth={false}>Swap</Button>} /> +``` + +Use sentence-case words in the string; the component uppercases them. diff --git a/design-system/components/core/core.card.html b/design-system/components/core/core.card.html new file mode 100644 index 0000000..2d992a8 --- /dev/null +++ b/design-system/components/core/core.card.html @@ -0,0 +1,46 @@ +<!-- @dsCard group="Components" viewport="700x340" name="Core" subtitle="Buttons, icon buttons, cards, chips, dropdowns, pills, photo slots" --> +<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"> +<link rel="stylesheet" href="../../styles.css"> +<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/fill/style.css"> +<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/bold/style.css"> +<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script> +<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script> +<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script> +<script src="../../_ds_bundle.js"></script> +<style>body{padding:20px;font-family:var(--font-ui)}.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.col{display:flex;flex-direction:column;gap:12px}</style> +</head><body><div id="root"></div> +<script type="text/babel"> +const { Button, IconButton, Card, Chip, DropdownChip, MetricPill, SectionHeader, Divider, Logo, PhotoSlot } = window.BetterFitDesignSystem_6a70ea; +function Demo(){return ( + <div className="col" style={{gap:18}}> + <div className="row"> + <Button fullWidth={false} icon="play" trailingIcon="caret-right">Start workout</Button> + <Button variant="secondary" fullWidth={false}>Swap exercise</Button> + <Button variant="destructive" fullWidth={false}>End workout</Button> + <Button variant="ghost" fullWidth={false}>View details</Button> + <Button variant="secondary" fullWidth={false} disabled>Disabled</Button> + </div> + <div className="row"> + <Button size="md" fullWidth={false}>44px</Button> + <Button size="sm" fullWidth={false}>34px</Button> + <IconButton icon="chart-bar" label="Stats" /> + <IconButton icon="plus" label="Add" variant="accent" size={44} /> + <IconButton icon="dots-three" label="More" variant="quiet" /> + <Chip label="Barbell" icon="barbell" selected /> + <Chip label="Dumbbell" /> + <DropdownChip label="45m" /> + <DropdownChip label="Anytime Fitness" /> + <MetricPill label="Weekly" value="3/5" /> + <MetricPill icon="fire-simple" value="12" tone="accent" /> + </div> + <div className="row" style={{alignItems:'stretch'}}> + <Card style={{flex:1}}><SectionHeader title="Today" action={<Button variant="ghost" fullWidth={false}>Swap</Button>} /><div className="bf-display" style={{fontSize:'var(--text-title-2)',marginTop:8}}>Push A</div><Divider style={{margin:'12px 0'}} /><div style={{fontSize:'var(--text-footnote)',color:'var(--text-secondary)'}}>6 exercises · 48 min</div></Card> + <Card tone="raised" style={{flex:1}}><div className="bf-label">Raised</div><div style={{marginTop:8,fontSize:'var(--text-subheadline)'}}>Nested well</div></Card> + <Card tone="identity" style={{flex:1}}><div className="bf-label" style={{color:'var(--bf-black)',opacity:.65}}>Identity</div><div className="bf-display" style={{fontSize:'var(--text-title-2)',marginTop:8,color:'var(--bf-black)'}}>Better</div></Card> + <Card tone="outline" style={{flex:'0 0 120px',display:'grid',placeItems:'center'}}><Logo variant="lettermark" height={44} assetBase="../../assets" /></Card> + <PhotoSlot width={90} height={90} label="Photo slot" /> + </div> + </div> +);} +ReactDOM.createRoot(document.getElementById('root')).render(<Demo />); +</script></body></html> diff --git a/design-system/components/data/ContributionHeatmap.d.ts b/design-system/components/data/ContributionHeatmap.d.ts new file mode 100644 index 0000000..726ceef --- /dev/null +++ b/design-system/components/data/ContributionHeatmap.d.ts @@ -0,0 +1,12 @@ +export interface ContributionHeatmapProps { + /** Intensity per day, 0–4, oldest first. Short arrays are padded with empty days. */ + values?: number[]; + /** Number of week columns to render. */ + weeks?: number; + /** Cell edge in px. */ + cell?: number; + gap?: number; + style?: React.CSSProperties; +} + +export declare function ContributionHeatmap(props: ContributionHeatmapProps): JSX.Element; diff --git a/design-system/components/data/ContributionHeatmap.jsx b/design-system/components/data/ContributionHeatmap.jsx new file mode 100644 index 0000000..bb7b3a2 --- /dev/null +++ b/design-system/components/data/ContributionHeatmap.jsx @@ -0,0 +1,25 @@ +import React from 'react'; + +const RAMP = ['var(--heat-0)', 'var(--heat-1)', 'var(--heat-2)', 'var(--heat-3)', 'var(--heat-4)']; + +/** Week-column activity heatmap. `values` is one intensity (0–4) per day, oldest first. */ +export function ContributionHeatmap({ values = [], weeks = 26, cell = 10, gap = 3, style, ...rest }) { + const total = weeks * 7; + const days = values.length >= total ? values.slice(-total) : [...Array(total - values.length).fill(0), ...values]; + const cols = []; + for (let w = 0; w < weeks; w++) cols.push(days.slice(w * 7, w * 7 + 7)); + return ( + <div style={{ display: 'flex', gap, ...style }} {...rest}> + {cols.map((col, i) => ( + <div key={i} style={{ display: 'grid', gap, gridTemplateRows: `repeat(7, ${cell}px)` }}> + {col.map((v, j) => ( + <div key={j} title={`${v} workouts`} style={{ + width: cell, height: cell, borderRadius: 2, + background: RAMP[Math.max(0, Math.min(4, v))] + }} /> + ))} + </div> + ))} + </div> + ); +} diff --git a/design-system/components/data/ContributionHeatmap.prompt.md b/design-system/components/data/ContributionHeatmap.prompt.md new file mode 100644 index 0000000..0343e6d --- /dev/null +++ b/design-system/components/data/ContributionHeatmap.prompt.md @@ -0,0 +1,7 @@ +Long-range consistency view — one cell per day, weeks as columns. + +```jsx +<ContributionHeatmap values={days} weeks={26} /> +``` + +Pair it with a plain-language conclusion ("Twelve weeks unbroken") — the grid is context, not the point. diff --git a/design-system/components/data/Gauge.d.ts b/design-system/components/data/Gauge.d.ts new file mode 100644 index 0000000..8253626 --- /dev/null +++ b/design-system/components/data/Gauge.d.ts @@ -0,0 +1,15 @@ +export interface GaugeProps { + /** 0…1. */ + progress?: number; + /** Overall width in px; height is roughly half. */ + width?: number; + thickness?: number; + tint?: string; + /** Big tabular readout under the arc. */ + value?: string | number; + /** Short caption under the value. */ + label?: string; + style?: React.CSSProperties; +} + +export declare function Gauge(props: GaugeProps): JSX.Element; diff --git a/design-system/components/data/Gauge.jsx b/design-system/components/data/Gauge.jsx new file mode 100644 index 0000000..baf878a --- /dev/null +++ b/design-system/components/data/Gauge.jsx @@ -0,0 +1,25 @@ +import React from 'react'; + +/** Semi-circular gauge. Reads as a conclusion, not a chart. */ +export function Gauge({ progress = 0, width = 160, thickness = 12, tint = 'var(--accent)', label, value, style, ...rest }) { + const p = Math.max(0, Math.min(1, progress)); + const r = (width - thickness) / 2; + const len = Math.PI * r; + const h = width / 2 + thickness / 2; + return ( + <div style={{ width, ...style }} {...rest}> + <svg width={width} height={h} viewBox={`0 0 ${width} ${h}`}> + <path d={`M ${thickness / 2} ${h - thickness / 2} A ${r} ${r} 0 0 1 ${width - thickness / 2} ${h - thickness / 2}`} + fill="none" stroke="var(--surface-raised)" strokeWidth={thickness} strokeLinecap="round" /> + <path d={`M ${thickness / 2} ${h - thickness / 2} A ${r} ${r} 0 0 1 ${width - thickness / 2} ${h - thickness / 2}`} + fill="none" stroke={tint} strokeWidth={thickness} strokeLinecap="round" + strokeDasharray={len} strokeDashoffset={len * (1 - p)} + style={{ transition: 'stroke-dashoffset var(--dur-slow) var(--ease-snappy)' }} /> + </svg> + <div style={{ textAlign: 'center', marginTop: -8 }}> + {value != null && <div className="bf-num" style={{ fontSize: 'var(--text-stat-lg)', fontWeight: 'var(--weight-bold)' }}>{value}</div>} + {label && <div style={{ fontSize: 'var(--text-caption)', color: 'var(--text-secondary)' }}>{label}</div>} + </div> + </div> + ); +} diff --git a/design-system/components/data/Gauge.prompt.md b/design-system/components/data/Gauge.prompt.md new file mode 100644 index 0000000..6ff757b --- /dev/null +++ b/design-system/components/data/Gauge.prompt.md @@ -0,0 +1,7 @@ +Half-ring readout for a headline percentage — weekly goal, overall recovery. + +```jsx +<Gauge progress={0.6} value="60%" label="weekly goal" /> +``` + +One gauge per screen at most; more than one turns a training screen into a dashboard. diff --git a/design-system/components/data/LegendDot.d.ts b/design-system/components/data/LegendDot.d.ts new file mode 100644 index 0000000..1e92c6d --- /dev/null +++ b/design-system/components/data/LegendDot.d.ts @@ -0,0 +1,9 @@ +export interface LegendDotProps { + label: string; + /** Whole number percentage; omit for a plain key. */ + percent?: number; + color?: string; + style?: React.CSSProperties; +} + +export declare function LegendDot(props: LegendDotProps): JSX.Element; diff --git a/design-system/components/data/LegendDot.jsx b/design-system/components/data/LegendDot.jsx new file mode 100644 index 0000000..10d71ed --- /dev/null +++ b/design-system/components/data/LegendDot.jsx @@ -0,0 +1,12 @@ +import React from 'react'; + +/** Colour key for a split or chart series. */ +export function LegendDot({ label, percent, color = 'var(--accent)', style, ...rest }) { + return ( + <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 'var(--text-caption)', color: 'var(--text-secondary)', ...style }} {...rest}> + <span style={{ width: 8, height: 8, borderRadius: 'var(--radius-pill)', background: color }} /> + <span>{label}</span> + {percent != null && <span className="bf-num" style={{ color: 'var(--text-primary)', fontWeight: 'var(--weight-semibold)' }}>{percent}%</span>} + </span> + ); +} diff --git a/design-system/components/data/LegendDot.prompt.md b/design-system/components/data/LegendDot.prompt.md new file mode 100644 index 0000000..0b2443c --- /dev/null +++ b/design-system/components/data/LegendDot.prompt.md @@ -0,0 +1,8 @@ +Legend entry for a training-split breakdown. + +```jsx +<LegendDot label="Strength" percent={54} /> +<LegendDot label="Cardio" percent={21} color="rgba(255,90,60,.45)" /> +``` + +Always label the series — colour alone is never the message. diff --git a/design-system/components/data/MuscleChip.d.ts b/design-system/components/data/MuscleChip.d.ts new file mode 100644 index 0000000..527040a --- /dev/null +++ b/design-system/components/data/MuscleChip.d.ts @@ -0,0 +1,10 @@ +export interface MuscleChipProps { + /** Display name, e.g. "Chest", "Lats". */ + muscle: string; + /** Share of the session, 0…100. */ + percent?: number; + tint?: string; + style?: React.CSSProperties; +} + +export declare function MuscleChip(props: MuscleChipProps): JSX.Element; diff --git a/design-system/components/data/MuscleChip.jsx b/design-system/components/data/MuscleChip.jsx new file mode 100644 index 0000000..0a6c0de --- /dev/null +++ b/design-system/components/data/MuscleChip.jsx @@ -0,0 +1,17 @@ +import React from 'react'; + +/** Muscle group + share of the session, with a proportional bar. */ +export function MuscleChip({ muscle, percent = 0, tint = 'var(--accent)', style, ...rest }) { + return ( + <div style={{ + flex: 1, minWidth: 72, padding: 12, borderRadius: 'var(--radius-md)', + background: 'var(--surface)', border: '1px solid var(--border)', ...style + }} {...rest}> + <div className="bf-num" style={{ fontSize: 'var(--text-stat-sm)', fontWeight: 'var(--weight-bold)' }}>{percent}%</div> + <div style={{ fontSize: 'var(--text-caption)', color: 'var(--text-secondary)', marginTop: 2, marginBottom: 8 }}>{muscle}</div> + <div style={{ height: 4, borderRadius: 2, background: 'var(--surface-raised)', overflow: 'hidden' }}> + <div style={{ width: `${Math.max(0, Math.min(100, percent))}%`, height: '100%', background: tint, transition: 'width var(--dur-standard) var(--ease-out)' }} /> + </div> + </div> + ); +} diff --git a/design-system/components/data/MuscleChip.prompt.md b/design-system/components/data/MuscleChip.prompt.md new file mode 100644 index 0000000..a40469f --- /dev/null +++ b/design-system/components/data/MuscleChip.prompt.md @@ -0,0 +1,10 @@ +Shows which muscle groups a planned session hits and by how much. + +```jsx +<div style={{ display: 'flex', gap: 12 }}> + <MuscleChip muscle="Chest" percent={42} /> + <MuscleChip muscle="Triceps" percent={33} /> +</div> +``` + +Cap at four chips — beyond that the split stops being a decision aid. diff --git a/design-system/components/data/OverviewStat.d.ts b/design-system/components/data/OverviewStat.d.ts new file mode 100644 index 0000000..2fb1050 --- /dev/null +++ b/design-system/components/data/OverviewStat.d.ts @@ -0,0 +1,9 @@ +export interface OverviewStatProps { + icon?: string; + value: string | number; + label: string; + tint?: string; + style?: React.CSSProperties; +} + +export declare function OverviewStat(props: OverviewStatProps): JSX.Element; diff --git a/design-system/components/data/OverviewStat.jsx b/design-system/components/data/OverviewStat.jsx new file mode 100644 index 0000000..e6edadd --- /dev/null +++ b/design-system/components/data/OverviewStat.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Icon } from '../core/Icon.jsx'; + +/** Inline icon + value + caption. Sits in a row under a headline, no container. */ +export function OverviewStat({ icon, value, label, tint = 'var(--accent-text)', style, ...rest }) { + return ( + <div style={{ display: 'flex', flexDirection: 'column', gap: 2, ...style }} {...rest}> + <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> + {icon && <Icon name={icon} size={13} color={tint} />} + <span className="bf-num" style={{ fontSize: 'var(--text-stat-sm)', fontWeight: 'var(--weight-semibold)' }}>{value}</span> + </div> + <span style={{ fontSize: 'var(--text-caption)', color: 'var(--text-secondary)' }}>{label}</span> + </div> + ); +} diff --git a/design-system/components/data/OverviewStat.prompt.md b/design-system/components/data/OverviewStat.prompt.md new file mode 100644 index 0000000..fe7afec --- /dev/null +++ b/design-system/components/data/OverviewStat.prompt.md @@ -0,0 +1,10 @@ +Containerless stat for a supporting row beneath a headline number. + +```jsx +<div style={{ display: 'flex', gap: 20 }}> + <OverviewStat icon="calendar" value="3" label="This week" /> + <OverviewStat icon="heartbeat" value="72%" label="Recovery" /> +</div> +``` + +Use `StatTile` when the stats need their own cards. diff --git a/design-system/components/data/ProgressRing.d.ts b/design-system/components/data/ProgressRing.d.ts new file mode 100644 index 0000000..be5883e --- /dev/null +++ b/design-system/components/data/ProgressRing.d.ts @@ -0,0 +1,14 @@ +export interface ProgressRingProps { + /** 0…1. Values outside the range are clamped. */ + progress?: number; + /** Outer diameter in px. */ + size?: number; + lineWidth?: number; + /** Stroke colour — pass a recovery or semantic token when the ring encodes status. */ + tint?: string; + /** Centred content, usually a tabular percentage. */ + children?: React.ReactNode; + style?: React.CSSProperties; +} + +export declare function ProgressRing(props: ProgressRingProps): JSX.Element; diff --git a/design-system/components/data/ProgressRing.jsx b/design-system/components/data/ProgressRing.jsx new file mode 100644 index 0000000..29afd9c --- /dev/null +++ b/design-system/components/data/ProgressRing.jsx @@ -0,0 +1,20 @@ +import React from 'react'; + +/** Flat progress ring — solid track, accent fill, round caps. */ +export function ProgressRing({ progress = 0, size = 86, lineWidth = 10, tint = 'var(--accent)', children, style, ...rest }) { + const p = Math.max(0, Math.min(1, progress)); + const r = (size - lineWidth) / 2; + const c = 2 * Math.PI * r; + return ( + <div style={{ position: 'relative', width: size, height: size, ...style }} + role="progressbar" aria-valuenow={Math.round(p * 100)} aria-valuemin={0} aria-valuemax={100} {...rest}> + <svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }}> + <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--surface-raised)" strokeWidth={lineWidth} /> + <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={tint} strokeWidth={lineWidth} + strokeLinecap="round" strokeDasharray={c} strokeDashoffset={c * (1 - p)} + style={{ transition: 'stroke-dashoffset var(--dur-slow) var(--ease-snappy)' }} /> + </svg> + <div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center' }}>{children}</div> + </div> + ); +} diff --git a/design-system/components/data/ProgressRing.prompt.md b/design-system/components/data/ProgressRing.prompt.md new file mode 100644 index 0000000..f6de92b --- /dev/null +++ b/design-system/components/data/ProgressRing.prompt.md @@ -0,0 +1,9 @@ +A single-value ring for recovery, weekly goal, or set completion. + +```jsx +<ProgressRing progress={0.72} size={96}> + <span className="bf-num" style={{ fontSize: 'var(--text-stat-md)', fontWeight: 700 }}>72%</span> +</ProgressRing> +``` + +Always pair the ring with a label — never rely on the arc alone. diff --git a/design-system/components/data/StatTile.d.ts b/design-system/components/data/StatTile.d.ts new file mode 100644 index 0000000..97b4ac8 --- /dev/null +++ b/design-system/components/data/StatTile.d.ts @@ -0,0 +1,11 @@ +export interface StatTileProps { + /** Phosphor icon slug. */ + icon?: string; + value: string | number; + label: string; + /** Icon colour. Use a semantic token when the stat has status meaning. */ + tint?: string; + style?: React.CSSProperties; +} + +export declare function StatTile(props: StatTileProps): JSX.Element; diff --git a/design-system/components/data/StatTile.jsx b/design-system/components/data/StatTile.jsx new file mode 100644 index 0000000..779f305 --- /dev/null +++ b/design-system/components/data/StatTile.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Icon } from '../core/Icon.jsx'; +import { Card } from '../core/Card.jsx'; + +/** Icon + value + label tile. Used in grids for streaks, volume, recovery. */ +export function StatTile({ icon, value, label, tint = 'var(--accent-text)', style, ...rest }) { + return ( + <Card padding={12} style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }} {...rest}> + {icon && <Icon name={icon} size={16} color={tint} />} + <div className="bf-num" style={{ fontSize: 'var(--text-stat-md)', fontWeight: 'var(--weight-bold)' }}>{value}</div> + <div style={{ fontSize: 'var(--text-caption)', color: 'var(--text-secondary)' }}>{label}</div> + </Card> + ); +} diff --git a/design-system/components/data/StatTile.prompt.md b/design-system/components/data/StatTile.prompt.md new file mode 100644 index 0000000..074d14b --- /dev/null +++ b/design-system/components/data/StatTile.prompt.md @@ -0,0 +1,9 @@ +One number in a card, for 2- or 3-up stat grids. + +```jsx +<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 12 }}> + <StatTile icon="fire-simple" value="12" label="Day streak" /> + <StatTile icon="barbell" value="18.4k" label="Volume (lb)" /> + <StatTile icon="heartbeat" value="72%" label="Recovery" tint="var(--info)" /> +</div> +``` diff --git a/design-system/components/data/data.card.html b/design-system/components/data/data.card.html new file mode 100644 index 0000000..36cd900 --- /dev/null +++ b/design-system/components/data/data.card.html @@ -0,0 +1,44 @@ +<!-- @dsCard group="Components" viewport="700x300" name="Data" subtitle="Rings, gauges, stat tiles, muscle split, activity heatmap" --> +<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"> +<link rel="stylesheet" href="../../styles.css"> +<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/fill/style.css"> +<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/bold/style.css"> +<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script> +<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script> +<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script> +<script src="../../_ds_bundle.js"></script> +<style>body{padding:20px;font-family:var(--font-ui)}.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.col{display:flex;flex-direction:column;gap:12px}</style> +</head><body><div id="root"></div> +<script type="text/babel"> +const { ProgressRing, Gauge, StatTile, OverviewStat, LegendDot, MuscleChip, ContributionHeatmap } = window.BetterFitDesignSystem_6a70ea; +const days = Array.from({length:182},(_,i)=> (i%7===2||i%7===5) ? 0 : Math.floor(Math.abs(Math.sin(i*.7))*4.9)); +function Demo(){return ( + <div className="col" style={{gap:18}}> + <div className="row" style={{gap:24}}> + <ProgressRing progress={.72} size={86}><span className="bf-num" style={{fontSize:'var(--text-stat-md)',fontWeight:700}}>72%</span></ProgressRing> + <Gauge progress={.6} value="60%" label="weekly goal" width={150} /> + <div className="col" style={{gap:10}}> + <div className="row" style={{gap:20}}> + <OverviewStat icon="calendar" value="3" label="This week" /> + <OverviewStat icon="heartbeat" value="72%" label="Recovery" /> + <OverviewStat icon="sparkle" value="41" label="Active days" /> + </div> + <div className="row" style={{gap:14}}> + <LegendDot label="Strength" percent={54} /> + <LegendDot label="Cardio" percent={21} color="var(--yellow-deep)" /> + <LegendDot label="Lifting" percent={25} color="var(--yellow-dim)" /> + </div> + </div> + </div> + <div className="row" style={{alignItems:'stretch'}}> + <StatTile icon="fire-simple" value="12" label="Day streak" style={{flex:1}} /> + <StatTile icon="barbell" value="18.4k" label="Volume (lb)" style={{flex:1}} /> + <MuscleChip muscle="Chest" percent={42} /> + <MuscleChip muscle="Triceps" percent={33} /> + <MuscleChip muscle="Delts" percent={25} /> + </div> + <ContributionHeatmap values={days} weeks={26} /> + </div> +);} +ReactDOM.createRoot(document.getElementById('root')).render(<Demo />); +</script></body></html> diff --git a/design-system/components/feedback/Banner.d.ts b/design-system/components/feedback/Banner.d.ts new file mode 100644 index 0000000..91100a8 --- /dev/null +++ b/design-system/components/feedback/Banner.d.ts @@ -0,0 +1,14 @@ +export interface BannerProps { + tone?: 'info' | 'success' | 'warning' | 'danger'; + /** Overrides the tone's default Phosphor icon. */ + icon?: string; + title: string; + message?: string; + /** Usually a small secondary Button. */ + action?: React.ReactNode; + /** Renders a dismiss affordance when provided. */ + onDismiss?: () => void; + style?: React.CSSProperties; +} + +export declare function Banner(props: BannerProps): JSX.Element; diff --git a/design-system/components/feedback/Banner.jsx b/design-system/components/feedback/Banner.jsx new file mode 100644 index 0000000..58d5d48 --- /dev/null +++ b/design-system/components/feedback/Banner.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Icon } from '../core/Icon.jsx'; + +const TONES = { + info: { border: 'var(--info)', surface: 'var(--info-surface)', icon: 'info', ink: 'var(--accent-text)' }, + success: { border: 'var(--success)', surface: 'var(--success-surface)', icon: 'check-circle', ink: 'var(--accent-text)' }, + warning: { border: 'var(--warning)', surface: 'var(--warning-surface)', icon: 'warning', ink: 'var(--accent-text)' }, + danger: { border: 'var(--danger)', surface: 'var(--danger-surface)', icon: 'warning-octagon', ink: 'var(--text-primary)' } +}; + +/** Inline notice with an optional action. Connection prompts, sync errors, plan changes. */ +export function Banner({ tone = 'info', icon, title, message, action, onDismiss, style, ...rest }) { + const t = TONES[tone]; + return ( + <div style={{ + display: 'flex', gap: 12, alignItems: 'flex-start', padding: 14, + background: t.surface, border: `1px solid color-mix(in srgb, ${t.border} 40%, transparent)`, + borderRadius: 'var(--radius-card)', ...style + }} {...rest}> + <Icon name={icon || t.icon} size={18} color={t.ink} /> + <div style={{ flex: 1, minWidth: 0 }}> + <div style={{ fontSize: 'var(--text-subheadline)', fontWeight: 'var(--weight-semibold)' }}>{title}</div> + {message && <div style={{ fontSize: 'var(--text-footnote)', color: 'var(--text-secondary)', marginTop: 2 }}>{message}</div>} + {action && <div style={{ marginTop: 10 }}>{action}</div>} + </div> + {onDismiss && ( + <button type="button" aria-label="Dismiss" onClick={onDismiss} style={{ background: 'none', border: 'none', color: 'var(--text-tertiary)', cursor: 'pointer', padding: 0 }}> + <Icon name="x" size={14} weight="bold" /> + </button> + )} + </div> + ); +} diff --git a/design-system/components/feedback/Banner.prompt.md b/design-system/components/feedback/Banner.prompt.md new file mode 100644 index 0000000..65eaa5e --- /dev/null +++ b/design-system/components/feedback/Banner.prompt.md @@ -0,0 +1,9 @@ +Inline notice above the content it concerns. + +```jsx +<Banner tone="info" icon="heartbeat" title="Connect Apple Health" + message="Import your workouts and resting heart rate." + action={<Button variant="secondary" size="sm" fullWidth={false}>Connect</Button>} /> +``` + +Say what happened in plain language and offer recovery. Never stack more than one banner. diff --git a/design-system/components/feedback/EmptyState.d.ts b/design-system/components/feedback/EmptyState.d.ts new file mode 100644 index 0000000..fc091d2 --- /dev/null +++ b/design-system/components/feedback/EmptyState.d.ts @@ -0,0 +1,12 @@ +export interface EmptyStateProps { + icon?: string; + /** Plain statement of what is missing. */ + title: string; + /** One sentence on what will appear here. */ + message?: string; + /** Usually a small Button that creates the missing thing. */ + action?: React.ReactNode; + style?: React.CSSProperties; +} + +export declare function EmptyState(props: EmptyStateProps): JSX.Element; diff --git a/design-system/components/feedback/EmptyState.jsx b/design-system/components/feedback/EmptyState.jsx new file mode 100644 index 0000000..6413156 --- /dev/null +++ b/design-system/components/feedback/EmptyState.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Icon } from '../core/Icon.jsx'; + +/** Explains what will appear here and offers the next step. */ +export function EmptyState({ icon = 'list-checks', title, message, action, style, ...rest }) { + return ( + <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '32px 20px', textAlign: 'center', ...style }} {...rest}> + <Icon name={icon} size={36} color="var(--text-tertiary)" /> + <div style={{ fontSize: 'var(--text-headline)', fontWeight: 'var(--weight-semibold)' }}>{title}</div> + {message && <div style={{ fontSize: 'var(--text-subheadline)', color: 'var(--text-secondary)', maxWidth: 320 }}>{message}</div>} + {action} + </div> + ); +} diff --git a/design-system/components/feedback/EmptyState.prompt.md b/design-system/components/feedback/EmptyState.prompt.md new file mode 100644 index 0000000..e8424ae --- /dev/null +++ b/design-system/components/feedback/EmptyState.prompt.md @@ -0,0 +1,9 @@ +Fills an empty list or screen with an explanation and a way forward. + +```jsx +<EmptyState icon="list-checks" title="No exercises planned" + message="Add a lift, or generate a session from your recovery and equipment." + action={<Button size="sm" fullWidth={false}>Add exercise</Button>} /> +``` + +Never leave an empty state without a next step, and never shame the user for the gap. diff --git a/design-system/components/feedback/RecoveryBadge.d.ts b/design-system/components/feedback/RecoveryBadge.d.ts new file mode 100644 index 0000000..f19a5cd --- /dev/null +++ b/design-system/components/feedback/RecoveryBadge.d.ts @@ -0,0 +1,8 @@ +import type { RecoveryStatus } from './RecoveryDot'; + +export interface RecoveryBadgeProps { + status?: RecoveryStatus; + style?: React.CSSProperties; +} + +export declare function RecoveryBadge(props: RecoveryBadgeProps): JSX.Element; diff --git a/design-system/components/feedback/RecoveryBadge.jsx b/design-system/components/feedback/RecoveryBadge.jsx new file mode 100644 index 0000000..79a92d9 --- /dev/null +++ b/design-system/components/feedback/RecoveryBadge.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { RECOVERY_COLORS } from './RecoveryDot.jsx'; + +const LABEL = { recovered: 'Recovered', fresh: 'Fresh', fatigued: 'Fatigued', sore: 'Sore' }; + +/** Tinted capsule stating a recovery status in words. */ +export function RecoveryBadge({ status = 'recovered', style, ...rest }) { + const c = RECOVERY_COLORS[status]; + return ( + <span style={{ + color: 'var(--text-primary)', background: `color-mix(in srgb, ${c} 22%, transparent)`, + border: `1px solid color-mix(in srgb, ${c} 45%, transparent)`, + padding: '3px 10px', borderRadius: 'var(--radius-pill)', display: 'inline-flex', alignItems: 'center', gap: 6, + fontSize: 'var(--text-caption)', fontWeight: 'var(--weight-semibold)', ...style + }} {...rest}> + <span style={{ width: 7, height: 7, borderRadius: 'var(--radius-pill)', background: c }} /> + {LABEL[status]} + </span> + ); +} diff --git a/design-system/components/feedback/RecoveryBadge.prompt.md b/design-system/components/feedback/RecoveryBadge.prompt.md new file mode 100644 index 0000000..ad71ee8 --- /dev/null +++ b/design-system/components/feedback/RecoveryBadge.prompt.md @@ -0,0 +1,7 @@ +Names a recovery status in a tinted capsule — the readable half of the recovery pair. + +```jsx +<RecoveryBadge status="fatigued" /> +``` + +Statuses: `recovered`, `fresh`, `fatigued`, `sore`. diff --git a/design-system/components/feedback/RecoveryDot.d.ts b/design-system/components/feedback/RecoveryDot.d.ts new file mode 100644 index 0000000..7a89938 --- /dev/null +++ b/design-system/components/feedback/RecoveryDot.d.ts @@ -0,0 +1,11 @@ +export type RecoveryStatus = 'recovered' | 'fresh' | 'fatigued' | 'sore'; + +export interface RecoveryDotProps { + /** recovered (blue) → fresh (green) → fatigued (amber) → sore (red). */ + status?: RecoveryStatus; + size?: number; + style?: React.CSSProperties; +} + +export declare function RecoveryDot(props: RecoveryDotProps): JSX.Element; +export declare const RECOVERY_COLORS: Record<RecoveryStatus, string>; diff --git a/design-system/components/feedback/RecoveryDot.jsx b/design-system/components/feedback/RecoveryDot.jsx new file mode 100644 index 0000000..dc8f15b --- /dev/null +++ b/design-system/components/feedback/RecoveryDot.jsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export const RECOVERY_COLORS = { + recovered: 'var(--recovery-recovered)', + fresh: 'var(--recovery-fresh)', + fatigued: 'var(--recovery-fatigued)', + sore: 'var(--recovery-sore)' +}; + +/** Small coloured dot for a region's recovery status. */ +export function RecoveryDot({ status = 'recovered', size = 10, style, ...rest }) { + return <span aria-label={`Recovery: ${status}`} style={{ width: size, height: size, borderRadius: 'var(--radius-pill)', background: RECOVERY_COLORS[status], display: 'inline-block', ...style }} {...rest} />; +} diff --git a/design-system/components/feedback/RecoveryDot.prompt.md b/design-system/components/feedback/RecoveryDot.prompt.md new file mode 100644 index 0000000..edeb7e4 --- /dev/null +++ b/design-system/components/feedback/RecoveryDot.prompt.md @@ -0,0 +1,7 @@ +Marks a muscle region's recovery state beside its label. + +```jsx +<RecoveryDot status="sore" /> <span>Chest</span> +``` + +The dot never stands alone — always next to the written status, because recovery must not be encoded by colour only. diff --git a/design-system/components/feedback/RestTimerBar.d.ts b/design-system/components/feedback/RestTimerBar.d.ts new file mode 100644 index 0000000..5d876b7 --- /dev/null +++ b/design-system/components/feedback/RestTimerBar.d.ts @@ -0,0 +1,11 @@ +export interface RestTimerBarProps { + /** Seconds left. */ + remaining?: number; + /** Seconds the rest started at — drives the progress line. */ + total?: number; + onAdd?: () => void; + onSkip?: () => void; + style?: React.CSSProperties; +} + +export declare function RestTimerBar(props: RestTimerBarProps): JSX.Element; diff --git a/design-system/components/feedback/RestTimerBar.jsx b/design-system/components/feedback/RestTimerBar.jsx new file mode 100644 index 0000000..2ad18da --- /dev/null +++ b/design-system/components/feedback/RestTimerBar.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Icon } from '../core/Icon.jsx'; + +/** Sticky rest countdown with add-time and skip. Digits are tabular so they don't jitter. */ +export function RestTimerBar({ remaining = 90, total = 90, onAdd, onSkip, style, ...rest }) { + const mm = String(Math.floor(Math.max(0, remaining) / 60)).padStart(2, '0'); + const ss = String(Math.max(0, remaining) % 60).padStart(2, '0'); + const pct = total > 0 ? Math.max(0, Math.min(1, remaining / total)) : 0; + return ( + <div style={{ background: 'var(--bg-elevated)', borderBottom: '1px solid var(--border)', ...style }} {...rest}> + <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 20px' }}> + <Icon name="timer" size={18} color="var(--accent)" /> + <div style={{ flex: 1 }}> + <div style={{ fontSize: 'var(--text-caption)', color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-label)', fontWeight: 'var(--weight-semibold)' }}>Rest</div> + <div className="bf-num" style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-stat-md)', fontWeight: 'var(--weight-bold)' }}>{mm}:{ss}</div> + </div> + <button type="button" onClick={onAdd} style={{ background: 'var(--surface-raised)', border: '1px solid var(--border)', color: 'var(--text-primary)', borderRadius: 'var(--radius-pill)', padding: '8px 14px', fontSize: 'var(--text-footnote)', fontWeight: 'var(--weight-semibold)', cursor: 'pointer' }}>+15s</button> + <button type="button" onClick={onSkip} style={{ background: 'transparent', border: 'none', color: 'var(--accent-text)', fontSize: 'var(--text-footnote)', fontWeight: 'var(--weight-semibold)', cursor: 'pointer', padding: '8px 4px' }}>Skip</button> + </div> + <div style={{ height: 3, background: 'var(--surface-raised)' }}> + <div style={{ width: `${pct * 100}%`, height: '100%', background: 'var(--accent)', transition: 'width 1s linear' }} /> + </div> + </div> + ); +} diff --git a/design-system/components/feedback/RestTimerBar.prompt.md b/design-system/components/feedback/RestTimerBar.prompt.md new file mode 100644 index 0000000..8605d13 --- /dev/null +++ b/design-system/components/feedback/RestTimerBar.prompt.md @@ -0,0 +1,7 @@ +Pinned rest countdown during an active session. + +```jsx +<RestTimerBar remaining={64} total={90} onAdd={add15} onSkip={skip} /> +``` + +Mount it at the top of the session view so it stays readable one-handed. Timer digits use the mono stack and tabular figures. diff --git a/design-system/components/feedback/feedback.card.html b/design-system/components/feedback/feedback.card.html new file mode 100644 index 0000000..b874903 --- /dev/null +++ b/design-system/components/feedback/feedback.card.html @@ -0,0 +1,32 @@ +<!-- @dsCard group="Components" viewport="700x330" name="Feedback" subtitle="Recovery dots and badges, banners, rest timer, empty state" --> +<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"> +<link rel="stylesheet" href="../../styles.css"> +<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/fill/style.css"> +<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/bold/style.css"> +<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script> +<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script> +<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script> +<script src="../../_ds_bundle.js"></script> +<style>body{padding:20px;font-family:var(--font-ui)}.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.col{display:flex;flex-direction:column;gap:12px}</style> +</head><body><div id="root"></div> +<script type="text/babel"> +const { RecoveryDot, RecoveryBadge, Banner, RestTimerBar, EmptyState, Button, Card } = window.BetterFitDesignSystem_6a70ea; +const S=['recovered','fresh','fatigued','sore']; +const NAMES={recovered:'Chest',fresh:'Back',fatigued:'Legs',sore:'Shoulders'}; +function Demo(){return ( + <div className="col" style={{gap:16}}> + <div className="row" style={{gap:18}}> + {S.map(s=>(<span key={s} className="row" style={{gap:8}}><RecoveryDot status={s} /><span style={{fontSize:'var(--text-subheadline)',color:'var(--text-secondary)'}}>{NAMES[s]}</span><RecoveryBadge status={s} /></span>))} + </div> + <div className="row" style={{alignItems:'stretch',gap:12}}> + <Banner tone="info" icon="heartbeat" title="Connect Apple Health" message="Import your workouts and resting heart rate." action={<Button variant="secondary" size="sm" fullWidth={false}>Connect</Button>} style={{flex:1}} onDismiss={()=>{}} /> + <Banner tone="warning" title="Chest is still recovering" message="Train back today and come back to push on Thursday." style={{flex:1}} /> + </div> + <div className="row" style={{alignItems:'stretch',gap:12}}> + <div style={{flex:1,border:'1px solid var(--border)',borderRadius:'var(--radius-card)',overflow:'hidden'}}><RestTimerBar remaining={64} total={90} /></div> + <Card style={{flex:1,padding:0}}><EmptyState icon="list-checks" title="No exercises planned" message="Add a lift, or generate a session from your recovery." action={<Button size="sm" fullWidth={false}>Add exercise</Button>} /></Card> + </div> + </div> +);} +ReactDOM.createRoot(document.getElementById('root')).render(<Demo />); +</script></body></html> diff --git a/design-system/components/lists/ChevronRow.d.ts b/design-system/components/lists/ChevronRow.d.ts new file mode 100644 index 0000000..0eda578 --- /dev/null +++ b/design-system/components/lists/ChevronRow.d.ts @@ -0,0 +1,12 @@ +export interface ChevronRowProps { + icon?: string; + title: string; + subtitle?: string; + iconTint?: string; + /** Current value shown before the chevron, e.g. "Pounds". */ + value?: string; + onClick?: () => void; + style?: React.CSSProperties; +} + +export declare function ChevronRow(props: ChevronRowProps): JSX.Element; diff --git a/design-system/components/lists/ChevronRow.jsx b/design-system/components/lists/ChevronRow.jsx new file mode 100644 index 0000000..fe3c9dd --- /dev/null +++ b/design-system/components/lists/ChevronRow.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { ListRow } from './ListRow.jsx'; +import { Icon } from '../core/Icon.jsx'; + +/** Navigation row — a ListRow that always ends in a chevron. */ +export function ChevronRow({ icon, title, subtitle, iconTint, value, onClick, style, ...rest }) { + return ( + <ListRow + icon={icon} title={title} subtitle={subtitle} iconTint={iconTint} onClick={onClick} style={style} + trailing={ + <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, color: 'var(--text-tertiary)' }}> + {value && <span style={{ fontSize: 'var(--text-footnote)', color: 'var(--text-secondary)' }}>{value}</span>} + <Icon name="caret-right" size={13} weight="bold" /> + </span> + } + {...rest} + /> + ); +} diff --git a/design-system/components/lists/ChevronRow.prompt.md b/design-system/components/lists/ChevronRow.prompt.md new file mode 100644 index 0000000..87d7db4 --- /dev/null +++ b/design-system/components/lists/ChevronRow.prompt.md @@ -0,0 +1,5 @@ +Drills into a detail screen — settings, profile, exercise detail. + +```jsx +<ChevronRow icon="gear" title="Units" value="Pounds" onClick={openUnits} /> +``` diff --git a/design-system/components/lists/ExerciseRow.d.ts b/design-system/components/lists/ExerciseRow.d.ts new file mode 100644 index 0000000..76bddb1 --- /dev/null +++ b/design-system/components/lists/ExerciseRow.d.ts @@ -0,0 +1,16 @@ +export interface ExerciseRowProps { + /** 1-based position in the session. */ + index: number; + name: string; + /** Sets × reps × load, e.g. "4 × 8 · 135 lb". Rendered with tabular figures. */ + prescription?: string; + /** Comma-joined muscle groups. */ + muscles?: string; + state?: 'planned' | 'active' | 'done'; + /** Suppresses the connector line on the final row. */ + isLast?: boolean; + onClick?: () => void; + style?: React.CSSProperties; +} + +export declare function ExerciseRow(props: ExerciseRowProps): JSX.Element; diff --git a/design-system/components/lists/ExerciseRow.jsx b/design-system/components/lists/ExerciseRow.jsx new file mode 100644 index 0000000..3534c85 --- /dev/null +++ b/design-system/components/lists/ExerciseRow.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Icon } from '../core/Icon.jsx'; + +/** Timeline row for a planned exercise: index marker, name, prescription, state. */ +export function ExerciseRow({ index, name, prescription, muscles, state = 'planned', isLast = false, onClick, style, ...rest }) { + const done = state === 'done'; + const active = state === 'active'; + const markerBg = done ? 'var(--surface-raised)' : active ? 'var(--accent)' : 'var(--surface-raised)'; + const markerFg = active ? 'var(--text-on-accent)' : 'var(--text-secondary)'; + return ( + <div onClick={onClick} style={{ display: 'flex', gap: 12, cursor: onClick ? 'pointer' : 'default', ...style }} {...rest}> + <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flex: '0 0 28px' }}> + <span style={{ + width: 28, height: 28, borderRadius: 'var(--radius-pill)', background: markerBg, color: markerFg, + display: 'grid', placeItems: 'center', fontSize: 'var(--text-caption)', fontWeight: 'var(--weight-bold)', + border: active ? '1px solid transparent' : '1px solid var(--border)' + }} className="bf-num"> + {done ? <Icon name="check" size={13} weight="bold" color="var(--accent-text)" /> : index} + </span> + {!isLast && <span style={{ flex: 1, width: 1, background: 'var(--separator)', marginTop: 4, minHeight: 24 }} />} + </div> + <div style={{ flex: 1, minWidth: 0, paddingBottom: isLast ? 0 : 16 }}> + <div style={{ + fontSize: 'var(--text-subheadline)', fontWeight: 'var(--weight-semibold)', + color: done ? 'var(--text-secondary)' : 'var(--text-primary)', + textDecoration: done ? 'line-through' : 'none' + }}>{name}</div> + {prescription && <div className="bf-num" style={{ fontSize: 'var(--text-footnote)', color: 'var(--text-secondary)', marginTop: 2 }}>{prescription}</div>} + {muscles && <div style={{ fontSize: 'var(--text-caption)', color: 'var(--text-tertiary)', marginTop: 4 }}>{muscles}</div>} + </div> + {active && <span style={{ alignSelf: 'flex-start', fontSize: 'var(--text-caption)', fontWeight: 'var(--weight-semibold)', color: 'var(--accent-text)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-label)' }}>Now</span>} + </div> + ); +} diff --git a/design-system/components/lists/ExerciseRow.prompt.md b/design-system/components/lists/ExerciseRow.prompt.md new file mode 100644 index 0000000..49d431b --- /dev/null +++ b/design-system/components/lists/ExerciseRow.prompt.md @@ -0,0 +1,9 @@ +A single exercise in the session timeline, with its completion state. + +```jsx +<ExerciseRow index={1} name="Back squat" prescription="5 × 5 · 225 lb" state="done" /> +<ExerciseRow index={2} name="Romanian deadlift" prescription="3 × 8 · 165 lb" state="active" /> +<ExerciseRow index={3} name="Leg press" prescription="3 × 12" isLast /> +``` + +Exactly one row should be `active`. Completion is shown by a check *and* strikethrough, never by colour alone. diff --git a/design-system/components/lists/ListRow.d.ts b/design-system/components/lists/ListRow.d.ts new file mode 100644 index 0000000..fb618b1 --- /dev/null +++ b/design-system/components/lists/ListRow.d.ts @@ -0,0 +1,14 @@ +export interface ListRowProps { + /** Phosphor icon slug shown in a 44px tinted circle. */ + icon?: string; + title: string; + subtitle?: string; + /** Circle tint; 15% of it fills the disc. */ + iconTint?: string; + /** Trailing node — a value, MetricPill, or IconButton. */ + trailing?: React.ReactNode; + onClick?: () => void; + style?: React.CSSProperties; +} + +export declare function ListRow(props: ListRowProps): JSX.Element; diff --git a/design-system/components/lists/ListRow.jsx b/design-system/components/lists/ListRow.jsx new file mode 100644 index 0000000..34e6ffa --- /dev/null +++ b/design-system/components/lists/ListRow.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Icon } from '../core/Icon.jsx'; + +/** Circular tinted icon + title + subtitle + optional trailing content. */ +export function ListRow({ icon, title, subtitle, iconTint = 'var(--accent-text)', trailing, onClick, style, ...rest }) { + return ( + <div + onClick={onClick} + role={onClick ? 'button' : undefined} + style={{ + display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', + minHeight: 'var(--tap-min)', cursor: onClick ? 'pointer' : 'default', ...style + }} + {...rest} + > + {icon && ( + <span style={{ + width: 44, height: 44, flex: '0 0 44px', borderRadius: 'var(--radius-pill)', + background: 'color-mix(in srgb, ' + iconTint + ' 15%, transparent)', + display: 'grid', placeItems: 'center' + }}> + <Icon name={icon} size={18} color={iconTint} /> + </span> + )} + <div style={{ minWidth: 0, flex: 1 }}> + <div style={{ fontSize: 'var(--text-subheadline)', fontWeight: 'var(--weight-semibold)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</div> + {subtitle && <div style={{ fontSize: 'var(--text-footnote)', color: 'var(--text-secondary)', marginTop: 2 }}>{subtitle}</div>} + </div> + {trailing} + </div> + ); +} diff --git a/design-system/components/lists/ListRow.prompt.md b/design-system/components/lists/ListRow.prompt.md new file mode 100644 index 0000000..d464b1f --- /dev/null +++ b/design-system/components/lists/ListRow.prompt.md @@ -0,0 +1,7 @@ +The general list row — exercises, history entries, equipment. + +```jsx +<ListRow icon="barbell" title="Bench press" subtitle="4 sets · 8 reps" trailing={<MetricPill value="135 lb" />} /> +``` + +Row height is at least 44px so it stays tappable between sets. diff --git a/design-system/components/lists/SettingsRow.d.ts b/design-system/components/lists/SettingsRow.d.ts new file mode 100644 index 0000000..0304dec --- /dev/null +++ b/design-system/components/lists/SettingsRow.d.ts @@ -0,0 +1,16 @@ +export interface SettingsRowProps { + /** toggle = switch, radio = single-select check, static = read-only value, link = opens externally. */ + kind?: 'toggle' | 'radio' | 'static' | 'link'; + icon?: string; + title: string; + subtitle?: string; + /** Read-only value for kind="static". */ + value?: string; + /** Switch / radio state. */ + checked?: boolean; + onChange?: (next: boolean) => void; + onClick?: () => void; + style?: React.CSSProperties; +} + +export declare function SettingsRow(props: SettingsRowProps): JSX.Element; diff --git a/design-system/components/lists/SettingsRow.jsx b/design-system/components/lists/SettingsRow.jsx new file mode 100644 index 0000000..ca9a87a --- /dev/null +++ b/design-system/components/lists/SettingsRow.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Icon } from '../core/Icon.jsx'; + +/** Settings row in four shapes: toggle, radio, static value, external link. */ +export function SettingsRow({ kind = 'static', icon, title, subtitle, value, checked = false, onChange, onClick, style, ...rest }) { + const interactive = kind === 'toggle' || kind === 'radio' || kind === 'link'; + const handle = () => { + if (kind === 'toggle' || kind === 'radio') onChange && onChange(!checked); + else if (onClick) onClick(); + }; + return ( + <div + onClick={interactive ? handle : onClick} + style={{ + display: 'flex', alignItems: 'center', gap: 12, padding: '12px 0', minHeight: 'var(--tap-min)', + cursor: interactive || onClick ? 'pointer' : 'default', ...style + }} + {...rest} + > + {icon && <Icon name={icon} size={17} color="var(--text-secondary)" style={{ flex: '0 0 20px' }} />} + <div style={{ flex: 1, minWidth: 0 }}> + <div style={{ fontSize: 'var(--text-body)' }}>{title}</div> + {subtitle && <div style={{ fontSize: 'var(--text-footnote)', color: 'var(--text-secondary)', marginTop: 2 }}>{subtitle}</div>} + </div> + {kind === 'toggle' && ( + <span role="switch" aria-checked={checked} style={{ + width: 46, height: 28, flex: '0 0 46px', borderRadius: 'var(--radius-pill)', padding: 2, + background: checked ? 'var(--accent)' : 'var(--surface-raised)', border: checked ? '1px solid transparent' : '1px solid var(--border)', + display: 'flex', justifyContent: checked ? 'flex-end' : 'flex-start', + transition: 'background var(--dur-fast) var(--ease-out)' + }}> + <span style={{ + width: 24, height: 24, borderRadius: 'var(--radius-pill)', + background: checked ? 'var(--bf-black)' : 'var(--bg-elevated)', + border: checked ? '1px solid transparent' : '1px solid var(--border)' + }} /> + </span> + )} + {kind === 'radio' && checked && <Icon name="check" size={16} weight="bold" color="var(--accent-text)" />} + {kind === 'static' && value && <span style={{ fontSize: 'var(--text-subheadline)', color: 'var(--text-secondary)' }}>{value}</span>} + {kind === 'link' && <Icon name="arrow-up-right" size={14} weight="bold" color="var(--text-tertiary)" />} + </div> + ); +} diff --git a/design-system/components/lists/SettingsRow.prompt.md b/design-system/components/lists/SettingsRow.prompt.md new file mode 100644 index 0000000..138728a --- /dev/null +++ b/design-system/components/lists/SettingsRow.prompt.md @@ -0,0 +1,10 @@ +Rows for the settings and preferences surfaces. + +```jsx +<SettingsRow kind="toggle" icon="bell" title="Workout reminders" checked onChange={setOn} /> +<SettingsRow kind="radio" title="Pounds" checked /> +<SettingsRow kind="static" title="Version" value="1.8.2" /> +<SettingsRow kind="link" title="Privacy policy" /> +``` + +Group rows inside a `Card` with `Divider`s between them; use a `SectionHeader` above each group. diff --git a/design-system/components/lists/lists.card.html b/design-system/components/lists/lists.card.html new file mode 100644 index 0000000..a573cb5 --- /dev/null +++ b/design-system/components/lists/lists.card.html @@ -0,0 +1,43 @@ +<!-- @dsCard group="Components" viewport="700x340" name="Lists" subtitle="List rows, chevron rows, exercise timeline, settings rows" --> +<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"> +<link rel="stylesheet" href="../../styles.css"> +<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/fill/style.css"> +<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/bold/style.css"> +<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script> +<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script> +<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script> +<script src="../../_ds_bundle.js"></script> +<style>body{padding:20px;font-family:var(--font-ui)}.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.col{display:flex;flex-direction:column;gap:12px}</style> +</head><body><div id="root"></div> +<script type="text/babel"> +const { ListRow, ChevronRow, ExerciseRow, SettingsRow, Card, MetricPill, Divider } = window.BetterFitDesignSystem_6a70ea; +function Demo(){const [on,setOn]=React.useState(true);return ( + <div className="row" style={{alignItems:'flex-start',gap:20}}> + <div style={{flex:1,minWidth:0}}> + <Card> + <ListRow icon="barbell" title="Bench press" subtitle="4 sets · 8 reps" trailing={<MetricPill value="135 lb" />} /> + <Divider inset={56} /> + <ChevronRow icon="gear-six" title="Units" value="Pounds" onClick={()=>{}} /> + <Divider inset={56} /> + <ChevronRow icon="bell" title="Reminders" subtitle="Weekdays, 6:30 pm" onClick={()=>{}} /> + </Card> + <Card style={{marginTop:12}}> + <SettingsRow kind="toggle" icon="bell" title="Workout reminders" checked={on} onChange={setOn} /> + <Divider /> + <SettingsRow kind="radio" title="Pounds" checked /> + <Divider /> + <SettingsRow kind="static" title="Version" value="1.8.2" /> + <Divider /> + <SettingsRow kind="link" title="Privacy policy" /> + </Card> + </div> + <Card style={{flex:1,minWidth:0}}> + <ExerciseRow index={1} name="Back squat" prescription="5 × 5 · 225 lb" muscles="Quads, Glutes" state="done" /> + <ExerciseRow index={2} name="Romanian deadlift" prescription="3 × 8 · 165 lb" muscles="Hamstrings" state="active" /> + <ExerciseRow index={3} name="Leg press" prescription="3 × 12 · 270 lb" muscles="Quads" /> + <ExerciseRow index={4} name="Standing calf raise" prescription="4 × 15" muscles="Calves" isLast /> + </Card> + </div> +);} +ReactDOM.createRoot(document.getElementById('root')).render(<Demo />); +</script></body></html> diff --git a/design-system/components/workout/SetLogField.d.ts b/design-system/components/workout/SetLogField.d.ts new file mode 100644 index 0000000..338541d --- /dev/null +++ b/design-system/components/workout/SetLogField.d.ts @@ -0,0 +1,11 @@ +export interface SetLogFieldProps { + /** Uppercase field name, e.g. "Weight". */ + label: string; + /** Unit suffix, e.g. "lbs", "reps". */ + unit?: string; + value: string | number; + onChange?: (next: string) => void; + style?: React.CSSProperties; +} + +export declare function SetLogField(props: SetLogFieldProps): JSX.Element; diff --git a/design-system/components/workout/SetLogField.jsx b/design-system/components/workout/SetLogField.jsx new file mode 100644 index 0000000..3cc988d --- /dev/null +++ b/design-system/components/workout/SetLogField.jsx @@ -0,0 +1,30 @@ +import React from 'react'; + +/** Labelled numeric field for logging a set. Big tabular value, unit suffix. */ +export function SetLogField({ label, unit, value, onChange, style, ...rest }) { + return ( + <label style={{ + flex: 1, minWidth: 0, display: 'block', background: 'var(--surface-raised)', border: '1px solid var(--border)', + borderRadius: 'var(--radius-md)', padding: '10px 14px', ...style + }} {...rest}> + <span style={{ + display: 'block', fontSize: 'var(--text-caption)', fontWeight: 'var(--weight-semibold)', + textTransform: 'uppercase', letterSpacing: 'var(--tracking-label)', color: 'var(--text-secondary)' + }}>{label}</span> + <span style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}> + <input + value={value} + onChange={(e) => onChange && onChange(e.target.value)} + inputMode="decimal" + size={1} + style={{ + flex: 1, width: '100%', minWidth: 0, background: 'transparent', border: 'none', outline: 'none', padding: 0, + color: 'var(--text-primary)', fontFamily: 'var(--font-mono)', fontSize: 'var(--text-stat-lg)', + fontWeight: 'var(--weight-bold)', fontVariantNumeric: 'tabular-nums' + }} + /> + {unit && <span style={{ fontSize: 'var(--text-footnote)', color: 'var(--text-secondary)' }}>{unit}</span>} + </span> + </label> + ); +} diff --git a/design-system/components/workout/SetLogField.prompt.md b/design-system/components/workout/SetLogField.prompt.md new file mode 100644 index 0000000..7e7f22a --- /dev/null +++ b/design-system/components/workout/SetLogField.prompt.md @@ -0,0 +1,10 @@ +Weight and reps entry during a live set. Two side by side, then a primary "Log set". + +```jsx +<div style={{ display: 'flex', gap: 12 }}> + <SetLogField label="Weight" unit="lbs" value={weight} onChange={setWeight} /> + <SetLogField label="Reps" unit="reps" value={reps} onChange={setReps} /> +</div> +``` + +Prefill the previous session's numbers so a set can be logged in one tap. diff --git a/design-system/components/workout/SupersetIndicator.d.ts b/design-system/components/workout/SupersetIndicator.d.ts new file mode 100644 index 0000000..1972d0d --- /dev/null +++ b/design-system/components/workout/SupersetIndicator.d.ts @@ -0,0 +1,9 @@ +export interface SupersetIndicatorProps { + /** Number of rounds through the grouped exercises. */ + rounds?: number; + /** Group name; "Superset" by default, also used for "Circuit". */ + label?: string; + style?: React.CSSProperties; +} + +export declare function SupersetIndicator(props: SupersetIndicatorProps): JSX.Element; diff --git a/design-system/components/workout/SupersetIndicator.jsx b/design-system/components/workout/SupersetIndicator.jsx new file mode 100644 index 0000000..f7b6221 --- /dev/null +++ b/design-system/components/workout/SupersetIndicator.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Icon } from '../core/Icon.jsx'; + +/** Bracket label grouping exercises performed back to back. */ +export function SupersetIndicator({ rounds = 3, label = 'Superset', style, ...rest }) { + return ( + <div style={{ display: 'flex', alignItems: 'center', gap: 8, ...style }} {...rest}> + <span style={{ width: 2, height: 16, background: 'var(--accent)', borderRadius: 1 }} /> + <Icon name="arrows-clockwise" size={12} color="var(--accent-text)" /> + <span style={{ + fontSize: 'var(--text-caption)', fontWeight: 'var(--weight-semibold)', textTransform: 'uppercase', + letterSpacing: 'var(--tracking-label)', color: 'var(--text-secondary)' + }}>{label} · <span className="bf-num">{rounds}</span> rounds</span> + <span style={{ flex: 1, height: 1, background: 'var(--separator)' }} /> + </div> + ); +} diff --git a/design-system/components/workout/SupersetIndicator.prompt.md b/design-system/components/workout/SupersetIndicator.prompt.md new file mode 100644 index 0000000..cc38b65 --- /dev/null +++ b/design-system/components/workout/SupersetIndicator.prompt.md @@ -0,0 +1,7 @@ +Marks the start of a group of exercises done back to back. + +```jsx +<SupersetIndicator rounds={3} /> +<ExerciseRow index={2} name="Incline press" prescription="3 × 10" /> +<ExerciseRow index={3} name="Cable fly" prescription="3 × 12" isLast /> +``` diff --git a/design-system/components/workout/WeightUnitToggle.d.ts b/design-system/components/workout/WeightUnitToggle.d.ts new file mode 100644 index 0000000..554c540 --- /dev/null +++ b/design-system/components/workout/WeightUnitToggle.d.ts @@ -0,0 +1,10 @@ +export interface WeightUnitToggleProps { + /** Currently selected option. */ + value?: string; + /** Two or three short options. Defaults to ["lb","kg"]. */ + options?: string[]; + onChange?: (next: string) => void; + style?: React.CSSProperties; +} + +export declare function WeightUnitToggle(props: WeightUnitToggleProps): JSX.Element; diff --git a/design-system/components/workout/WeightUnitToggle.jsx b/design-system/components/workout/WeightUnitToggle.jsx new file mode 100644 index 0000000..0108880 --- /dev/null +++ b/design-system/components/workout/WeightUnitToggle.jsx @@ -0,0 +1,24 @@ +import React from 'react'; + +/** Two-option segmented control on a raised well. */ +export function WeightUnitToggle({ value = 'lb', options = ['lb', 'kg'], onChange, style, ...rest }) { + return ( + <div role="group" style={{ + display: 'inline-flex', gap: 2, padding: 2, borderRadius: 'var(--radius-pill)', + background: 'var(--surface-raised)', border: '1px solid var(--border)', ...style + }} {...rest}> + {options.map((o) => { + const on = o === value; + return ( + <button key={o} type="button" onClick={() => onChange && onChange(o)} aria-pressed={on} + style={{ + minHeight: 'var(--tap-min)', padding: '0 18px', borderRadius: 'var(--radius-pill)', border: 'none', cursor: 'pointer', + background: on ? 'var(--accent)' : 'transparent', color: on ? 'var(--text-on-accent)' : 'var(--text-secondary)', + fontFamily: 'var(--font-ui)', fontSize: 'var(--text-footnote)', fontWeight: 'var(--weight-semibold)', + transition: 'background var(--dur-fast) var(--ease-out)' + }}>{o}</button> + ); + })} + </div> + ); +} diff --git a/design-system/components/workout/WeightUnitToggle.prompt.md b/design-system/components/workout/WeightUnitToggle.prompt.md new file mode 100644 index 0000000..a2c6a1b --- /dev/null +++ b/design-system/components/workout/WeightUnitToggle.prompt.md @@ -0,0 +1,6 @@ +Segmented switch for units, and for any other two- or three-way choice with short labels. + +```jsx +<WeightUnitToggle value={unit} onChange={setUnit} /> +<WeightUnitToggle value={range} options={['Week', 'Month', 'Year']} onChange={setRange} /> +``` diff --git a/design-system/components/workout/WorkoutCard.d.ts b/design-system/components/workout/WorkoutCard.d.ts new file mode 100644 index 0000000..29c2919 --- /dev/null +++ b/design-system/components/workout/WorkoutCard.d.ts @@ -0,0 +1,21 @@ +export interface WorkoutCardProps { + /** Session name, e.g. "Push A". */ + name: string; + /** Uppercase eyebrow — "Up next", "Suggested", "Last workout". */ + focus?: string; + exercises?: number; + /** Human duration, e.g. "48 min". */ + duration?: string; + /** Planned volume, e.g. "18.4k lb". */ + volume?: string; + /** Muscle group tags, max four. */ + muscles?: string[]; + /** identity = yellow field with black type, for the single hero session. */ + tone?: 'surface' | 'identity'; + /** Small trailing capsule, e.g. "Today". */ + badge?: string; + onClick?: () => void; + style?: React.CSSProperties; +} + +export declare function WorkoutCard(props: WorkoutCardProps): JSX.Element; diff --git a/design-system/components/workout/WorkoutCard.jsx b/design-system/components/workout/WorkoutCard.jsx new file mode 100644 index 0000000..83111b8 --- /dev/null +++ b/design-system/components/workout/WorkoutCard.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Icon } from '../core/Icon.jsx'; + +/** A suggested or scheduled session: name, focus, prescription, muscle summary. */ +export function WorkoutCard({ + name, focus, exercises, duration, volume, muscles = [], tone = 'surface', + badge, onClick, style, ...rest +}) { + const identity = tone === 'identity'; + return ( + <div + onClick={onClick} + style={{ + background: identity ? 'var(--bf-yellow)' : 'var(--surface)', + color: identity ? 'var(--bf-black)' : 'var(--text-primary)', + border: identity ? '1px solid transparent' : '1px solid var(--border)', + borderRadius: 'var(--radius-card)', padding: 16, cursor: onClick ? 'pointer' : 'default', + display: 'flex', flexDirection: 'column', gap: 12, ...style + }} + {...rest} + > + <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> + <span style={{ + fontSize: 'var(--text-caption)', fontWeight: 'var(--weight-semibold)', textTransform: 'uppercase', + letterSpacing: 'var(--tracking-label)', opacity: identity ? 0.7 : 1, + color: identity ? 'var(--bf-black)' : 'var(--text-secondary)' + }}>{focus}</span> + {badge && ( + <span style={{ + fontSize: 'var(--text-caption)', fontWeight: 'var(--weight-semibold)', padding: '4px 10px', + borderRadius: 'var(--radius-pill)', + background: identity ? 'rgba(0,0,0,.12)' : 'var(--surface-raised)', + border: identity ? '1px solid transparent' : '1px solid var(--border)' + }}>{badge}</span> + )} + </div> + + <div className="bf-display" style={{ fontSize: 'var(--text-title-1)' }}>{name}</div> + + <div className="bf-num" style={{ display: 'flex', gap: 16, fontSize: 'var(--text-footnote)', opacity: identity ? 0.8 : 1, color: identity ? 'var(--bf-black)' : 'var(--text-secondary)' }}> + {exercises != null && <span style={{ display: 'inline-flex', gap: 5, alignItems: 'center' }}><Icon name="list-checks" size={13} />{exercises} exercises</span>} + {duration && <span style={{ display: 'inline-flex', gap: 5, alignItems: 'center' }}><Icon name="clock" size={13} />{duration}</span>} + {volume && <span style={{ display: 'inline-flex', gap: 5, alignItems: 'center' }}><Icon name="barbell" size={13} />{volume}</span>} + </div> + + {muscles.length > 0 && ( + <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}> + {muscles.map((m) => ( + <span key={m} style={{ + fontSize: 'var(--text-caption)', padding: '4px 8px', borderRadius: 'var(--radius-sm)', + background: identity ? 'rgba(0,0,0,.1)' : 'var(--surface-raised)', + color: identity ? 'var(--bf-black)' : 'var(--text-secondary)' + }}>{m}</span> + ))} + </div> + )} + </div> + ); +} diff --git a/design-system/components/workout/WorkoutCard.prompt.md b/design-system/components/workout/WorkoutCard.prompt.md new file mode 100644 index 0000000..b372d88 --- /dev/null +++ b/design-system/components/workout/WorkoutCard.prompt.md @@ -0,0 +1,8 @@ +The session card — what the app recommends training next. + +```jsx +<WorkoutCard tone="identity" focus="Up next" badge="Today" name="Push A" + exercises={6} duration="48 min" volume="18.4k lb" muscles={['Chest', 'Shoulders', 'Triceps']} /> +``` + +Use `tone="identity"` for at most one card per screen — the yellow field is the brand moment, everything else stays on dark surface. diff --git a/design-system/components/workout/workout.card.html b/design-system/components/workout/workout.card.html new file mode 100644 index 0000000..6493a9b --- /dev/null +++ b/design-system/components/workout/workout.card.html @@ -0,0 +1,32 @@ +<!-- @dsCard group="Components" viewport="700x290" name="Workout" subtitle="Session cards, set logging, unit toggle, superset marker" --> +<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"> +<link rel="stylesheet" href="../../styles.css"> +<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/fill/style.css"> +<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/bold/style.css"> +<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script> +<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script> +<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script> +<script src="../../_ds_bundle.js"></script> +<style>body{padding:20px;font-family:var(--font-ui)}.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.col{display:flex;flex-direction:column;gap:12px}</style> +</head><body><div id="root"></div> +<script type="text/babel"> +const { WorkoutCard, SetLogField, WeightUnitToggle, SupersetIndicator, Button, Card } = window.BetterFitDesignSystem_6a70ea; +function Demo(){const [w,setW]=React.useState('135');const [r,setR]=React.useState('8');const [u,setU]=React.useState('lb');return ( + <div className="row" style={{alignItems:'flex-start',gap:16}}> + <WorkoutCard tone="identity" focus="Up next" badge="Today" name="Push A" exercises={6} duration="48 min" volume="18.4k lb" muscles={['Chest','Shoulders','Triceps']} style={{flex:1}} /> + <WorkoutCard focus="Suggested" name="Pull B" exercises={5} duration="42 min" muscles={['Lats','Biceps']} style={{flex:1}} /> + <div className="col" style={{flex:1,gap:12}}> + <div className="row" style={{gap:12}}> + <SetLogField label="Weight" unit={u} value={w} onChange={setW} /> + <SetLogField label="Reps" unit="reps" value={r} onChange={setR} /> + </div> + <div className="row" style={{justifyContent:'space-between'}}> + <WeightUnitToggle value={u} onChange={setU} /> + <Button size="md" fullWidth={false} icon="check-circle">Log set</Button> + </div> + <SupersetIndicator rounds={3} /> + </div> + </div> +);} +ReactDOM.createRoot(document.getElementById('root')).render(<Demo />); +</script></body></html> diff --git a/design-system/design.md b/design-system/design.md new file mode 100644 index 0000000..2057751 --- /dev/null +++ b/design-system/design.md @@ -0,0 +1,256 @@ +# BetterFit Design + +BetterFit is a personal strength training coach for iPhone and Apple Watch. It helps people decide what to train, move through a workout with less friction, understand recovery, and keep building momentum. + +The brand should feel strong, direct, and energising without becoming aggressive. BetterFit is a capable training partner, not a drill sergeant, lifestyle influencer, or clinical dashboard. + +## Brand Idea + +**Train with direction. Get better with every session.** + +BetterFit turns workout history, performance, and recovery into a clear next action. The product should make training feel more understandable and achievable while still respecting the effort involved. + +The name carries the promise: not perfect, extreme, or finished. Better. + +## Brand Character + +BetterFit is: + +- **Strong**: bold type, high contrast, decisive actions. +- **Useful**: information appears when it helps someone train, not to decorate a screen. +- **Adaptive**: recommendations respond to performance, equipment, plans, and recovery. +- **Encouraging**: progress is acknowledged without guilt, hype, or empty praise. +- **Human**: language stays plain and practical. Data supports the person rather than defining them. +- **Focused**: one clear primary action should lead each state. + +BetterFit is not: + +- Militaristic, macho, or intimidating +- Sterile medical software +- A neon cyberpunk fitness game +- A dense analytics terminal +- A soft wellness or mindfulness brand +- A social feed built around comparison + +## Audience + +BetterFit is for people who want to become stronger but do not want to spend their training time programming workouts or interpreting raw data. It should work for a new lifter who needs confidence and an experienced lifter who values speed, control, and useful adaptation. + +Design for someone who may be tired, moving, wearing headphones, or glancing at a watch between sets. The interface must remain legible and operable under those conditions. + +## Voice + +Write like a knowledgeable training partner standing nearby. + +- Use short, active sentences. +- Lead with the action or useful fact. +- Prefer everyday training language over technical or AI language. +- Explain recommendations when the reason improves trust. +- Celebrate completed work, consistency, and progress without exaggeration. +- Never shame missed sessions, low performance, body shape, or recovery needs. +- Avoid slogans about pain, punishment, domination, or earning food. +- Avoid calling ordinary automation "magic" or repeatedly advertising that it is AI. + +Prefer: + +- "Start workout" +- "Swap exercise" +- "Chest is still recovering. Train back today." +- "Two workouts this week. One more reaches your goal." +- "Ready for your next set?" + +Avoid: + +- "Crush your limits!" +- "No excuses" +- "AI-powered gains, unlocked" +- "You failed to meet your goal" +- "Summer body loading" + +## Visual Identity + +The identity is built from bold black forms on electric yellow. The mark combines three compressed oval forms, which suggest plates on a barbell, repetition, and forward momentum, with a heavy geometric letterform. The wordmark is wide, compact, and unapologetically strong. + +Use the supplied logo artwork. Do not redraw it with text, alter its proportions, add effects, or place other elements inside its clear space. + +BetterFit is a two-colour brand: yellow and black, with white as the reversed ink. There is no secondary brand hue. Do not introduce an additional accent colour — orange, teal, purple, or otherwise — for actions, illustrations, charts, or marketing. Yellow is the accent everywhere. + +### Identity Palette + +| Role | Value | Use | +| --- | --- | --- | +| BetterFit yellow | `#FFD60A` | Primary identity field, primary action, high-impact highlights | +| Black | `#000000` | Mark, display type, ink on yellow, strong contrast | +| White | `#FFFFFF` | Reversed marks and type on dark fields | + +Large yellow fields always carry black content. Yellow is used with intent: one dominant yellow element per screen, not a yellow wash across every surface. + +### Product Palette + +The training interface is dark-first, calm, and functional. Yellow carries the primary action and the single most important value on a screen; everything else is neutral so that yellow always means "this is what matters". + +| Role | Dark | Light | +| --- | --- | --- | +| Page background | `#0B0B0D` | `#F6F6F8` | +| Elevated background | `#131316` | `#FFFFFF` | +| Surface | `#17171B` | `#FFFFFF` | +| Raised surface | `#1F1F25` | `#EFEFF3` | +| Primary text | `#FFFFFF` | `#131317` | +| Secondary text | `#9C9CA6` | approximately `#61616B` | +| Tertiary text | `#63636D` | approximately `#94949E` | +| Primary action | `#FFD60A` | `#FFD60A` | +| Action ink | `#000000` | `#000000` | +| Action tint (subtle) | `#FFD60A` at 15% | `#FFD60A` at 15% | +| Yellow ladder — bright (recovered, success) | `#FFE860` | `#FFE860` | +| Yellow ladder — base (fresh, warning) | `#FFD60A` | `#FFD60A` | +| Yellow ladder — deep (fatigued, information) | `#C9A800` | `#C9A800` | +| Yellow ladder — dim (sore) | `#7A6600` | `#7A6600` | + +Use semantic colors consistently. Status and recovery ride the yellow ladder plus a word or label, never a competing hue: brightness ramps up as readiness improves — dim for sore, deep for fatigued, base for fresh, bright for recovered. Success wears the bright step, information the deep step, and danger is expressed by inverting the control rather than adding red. On light backgrounds the ramp shifts to deeper steps so yellow stays legible. Never rely on color alone to communicate status. + +**Yellow versus amber.** Brand yellow and warning amber sit close together, so they must never compete. Yellow appears as a solid fill on interactive elements and on the hero value of a screen. Amber only ever appears as a small indicator — a dot, a bar segment, an icon, a text colour — and never as a filled button or a large field. If a warning needs emphasis, add an icon and a label rather than more colour. + +User-selectable themes may change the background atmosphere and the density of yellow, but they must preserve yellow as the accent, the same hierarchy, semantic colors, contrast, and component behaviour. + +## Typography + +Typography should feel sturdy and immediate. + +- Use BBH Hegarty for branded display headings when licensed assets are available. +- Fall back to a heavy rounded system face for headings. +- Use the native system typeface for interface copy and controls. +- Use bold, high-contrast headings and sentence case for most labels. +- Use monospaced digits for timers, weights, reps, percentages, and changing statistics. +- Use uppercase labels sparingly for compact section markers, with generous tracking. +- Support Dynamic Type. Do not fix layouts around one text size. + +The core scale is 34, 28, 22, and 20 points for titles; 17 points for body and primary controls; 15 points for supporting copy; and 12 to 13 points for captions. Workout-critical values may be larger, especially on Apple Watch. + +## Layout And Shape + +- Build on a 4-point spacing rhythm, using 8, 12, 16, 20, 24, and 32 points most often. +- Use 20 points of standard horizontal page padding on iPhone. +- Keep layouts vertically rhythmic, left aligned, and easy to scan. +- Use continuous rounded corners: 16 points for cards, 14 for buttons, 12 for controls, and full capsules only for compact chips. +- Prefer flat, solid surfaces with a subtle one-pixel border for content. +- Avoid glossy gradients, thick shadows, and floating cards without hierarchy. +- Give data room to breathe. Do not fill every space with a metric. +- Use SF Symbols for familiar interface and fitness concepts. Keep icon weights semibold and visually consistent. +- Lay out screens for BetterFit's own hierarchy. Do not reproduce another training app's screen structure, card order, or navigation model. + +### Floating Chrome And Translucency + +Chrome floats; content does not. Navigation and the primary action ride over the scrolling page in translucent, blurred containers so the page reads as one continuous surface underneath. + +- The tab bar is a floating capsule inset from the screen edges, not a bar welded to the bottom of the layout. +- Translucency is **only** for chrome that overlays scrolling content: the nav, the action dock, and revealed swipe actions. Never for cards, list rows, sheets' content, or any surface that holds data. +- Content surfaces stay flat and opaque. A blurred card is a bug. +- Keep text on glass at full-strength primary or secondary colour and verify AA against the lightest content that can pass beneath it. If a label cannot hold contrast, thicken the glass rather than dimming the type. +- Glass carries a hairline border and a soft shadow so its edge is legible over both dark and light content. + +### Reach + +Assume one hand, in a gym, mid-session. + +- The primary action lives in a floating dock at the bottom of the screen, inside thumb reach — never at the top, never only in a header. +- The dock may carry one primary action plus at most two square icon companions. +- Headers are for orientation — what session this is, what it contains. They do not hold the action. +- Frequent, low-risk operations get a horizontal quick-action strip directly under the header: swap, edit, repeat, trim, change equipment. +- Row-level operations belong on swipe. Swiping a list row reveals actions on both edges: destructive and replace on the trailing edge, reordering and grouping on the leading edge. Every swipe action must also be reachable without swiping, for VoiceOver and Switch Control. +- Anything a person does often — starting a suggested session, restarting a frequent one — should be one tap from the screen they land on. + +## Components + +### Actions + +Use one full-width yellow button with black text for the primary action, docked at the bottom of the screen in thumb reach. Secondary actions use a raised neutral surface with a hairline border. Tertiary actions use yellow text without a container. Destructive confirmation inverts the control and must be explicit. + +At most one yellow fill is visible at a time. On screens that lead with a full yellow field, the field **is** the yellow — the docked action is neutral glass. On screens with a neutral header, the docked action carries the yellow. Never both. + +Primary buttons are 54 points high. All controls need at least a 44 by 44 point hit target. On Apple Watch, controls should be larger and full width whenever practical. + +### Cards + +Cards group related decisions or information. They use a solid surface, a subtle border, 16-point corners, and 16-point internal padding. A card should not exist only to decorate content that could sit directly on the page. + +### Data + +Make the conclusion more prominent than the chart. Show the current value, a short label, and the next useful interpretation. Charts should use restrained axes and semantic color, with yellow reserved for the series the person is actually being asked to act on. Timers and changing values use monospaced digits so they do not jitter. + +### Empty And Loading States + +Empty states should explain what will appear and give a useful next step. Loading should preserve layout where possible. Errors should say what happened in plain language and offer recovery rather than a dead end. + +## Motion And Feedback + +Motion should communicate state and momentum. + +- Use short, responsive transitions and native spring or snappy timing. +- Give pressed controls a subtle opacity or scale response. +- Animate progress changes, completed sets, and transitions to the next exercise. +- Use haptics for consequential workout actions when supported. +- Avoid ambient looping animation, bouncing icons, confetti for routine actions, and effects that compete with workout data. +- Respect Reduce Motion and other system accessibility settings. + +## Imagery + +Prefer real, purposeful training imagery with diverse bodies, ages, and experience levels. Show believable effort, correct equipment use, and environments where people actually train. Crop closely enough to feel active, but leave clear space for type. + +Avoid hyper-muscular stereotypes, transformation imagery, body comparison, fake holographic data, generic stock smiles, and imagery that suggests pain is the goal. + +Illustration, when needed, should use heavy geometric forms, simplified anatomy, and the same high-contrast confidence as the mark, drawn in yellow, black, and white only. + +## Accessibility + +- Meet WCAG AA contrast at minimum. +- Always pair yellow with black ink, never with white. White on `#FFD60A` fails AA. +- Support Dynamic Type, VoiceOver, Reduce Motion, increased contrast, and light and dark appearances. +- Give controls descriptive labels and meaningful values. +- Do not encode recovery, completion, or errors through color alone. +- Keep workout-critical information visible at a glance and operable with one hand. +- Reaching the primary action must never require moving your hand up the screen. +- Provide a non-swipe route to every swipe action. +- Respect Reduce Transparency: fall back to an opaque chrome fill with the same border and shadow. +- Test the largest accessibility text sizes and the smallest supported Apple Watch display. + +## Product Design Test + +Before shipping a screen, ask: + +1. Is the next action obvious within a glance? +2. Does every metric help the person decide or understand something? +3. Can this be used between sets with one hand? +4. Does it feel strong without becoming aggressive? +5. Does it encourage progress without judgement? +6. Does it still work with large text, VoiceOver, and without color? +7. Is there exactly one thing on this screen wearing yellow? + +## Reusable Design Prompt + +```text +Design a [screen, flow, campaign, or asset] for BetterFit, a personal strength training coach for iPhone and Apple Watch. BetterFit helps people decide what to train, complete workouts with less friction, understand recovery, and make steady progress. + +The brand is strong, direct, useful, adaptive, encouraging, and human. It should feel like a knowledgeable training partner, not a drill sergeant, influencer, clinical dashboard, neon fitness game, or soft wellness app. Make the next action obvious and keep information practical enough to use while moving or between sets. + +BetterFit is a two-colour brand: electric yellow #FFD60A and black, with white as reversed ink. Do not introduce any additional accent hue. Use heavy geometric typography and compressed forms inspired by weight plates and repetition. For product UI, use a calm dark-first system with #0B0B0D backgrounds, #17171B surfaces, subtle hairline borders, white primary text, and muted gray secondary text, with yellow #FFD60A carrying the single primary action and always paired with black ink. Use the yellow ladder for status and recovery — bright #FFE860, base #FFD60A, deep #C9A800, dim #7A6600 — where brightness ramps up as readiness improves, always paired with a word or label; express danger by inverting the control rather than adding a red hue. Amber stays a small indicator only — a dot, a bar segment, an icon — and never a fill. + +Use bold headings, native system body text, and monospaced digits for weights, reps, timers, percentages, and stats. Build on a 4-point spacing rhythm with 20-point page margins, flat solid cards, continuous 16-point corners, 54-point primary buttons, and 44-point minimum tap targets. Float the navigation and the primary action as translucent blurred chrome over the scrolling page, and keep the primary action in thumb reach at the bottom; keep content surfaces flat and opaque. Put frequent operations in a quick-action strip and row operations on swipe. Prefer native navigation and controls. Use restrained SF Symbols and short, responsive motion. Invent BetterFit's own screen hierarchy rather than restating another fitness app's layout. Avoid glossy gradients, excessive shadows, dashboard clutter, generic AI visuals, motivational cliches, shame, and macho gym imagery. + +Write short, active copy in plain training language. Explain recommendations when it builds trust. Celebrate effort and consistency without hype. The result must support light and dark appearance, Dynamic Type, VoiceOver, Reduce Motion, one-handed use, and WCAG AA contrast. + +Create [required deliverable and states]. Include [specific content, constraints, or platform details]. +``` + +## Implementation Reference + +The current source of truth for product tokens and primitives lives in: + +- `Apps/iOS/BetterFitApp/DesignSystem/BFColors.swift` +- `Apps/iOS/BetterFitApp/DesignSystem/BFTypography.swift` +- `Apps/iOS/BetterFitApp/DesignSystem/BFSpacing.swift` +- `Apps/iOS/BetterFitApp/DesignSystem/BFComponents.swift` +- `Apps/iOS/BetterFitApp/DesignSystem/BFButtonStyles.swift` +- `Apps/iOS/BetterFitApp/AppTheme.swift` +- `Apps/iOS/BetterFitApp/Assets.xcassets/` + +`BFColors.brandAccent` (#FF5A3C) is deprecated and must not be used. Point accent usage at the identity yellow token and its black ink pairing. When this document and shipped tokens disagree, resolve the difference intentionally. Do not create a third visual language at the call site. diff --git a/design-system/github.md b/design-system/github.md new file mode 100644 index 0000000..bc8076b --- /dev/null +++ b/design-system/github.md @@ -0,0 +1,48 @@ +repo: echohello-dev/betterfit +branch: main +path: Apps/iOS + +## Last sync + +date: 2026-07-30T11:17:04Z + +### Updated in this project + +- Read upstream `design.md` and `BFColors.swift`: the repo had moved product accent to ember orange `#FF5A3C`. +- Rejected that direction. Rewrote `design.md` in this project as the two-colour source of truth: yellow `#FFD60A` is the accent, black is its ink, no third hue. +- Added guidance the repo doc lacked: yellow-versus-amber separation, one yellow fill per screen, AA pairing rule, and a note not to restate another training app's layout. +- Marked `BFColors.brandAccent` deprecated in the implementation reference; tokens in this project were already orange-free. +- Reimagined the iPhone screen layouts on a new in-house language ("The Ledger" — ruled rows, a 32px mono gutter, one squared yellow slab per screen, bars never rings). Rebuilt Plan, Body, Targets, Log, Session and Summary; retired `HomeScreen` and `PlanScreen` (duplicated Plan and Body); retired `IdentityBand` in favour of `Slab`. + +### Divergence from upstream + +`design.md` here supersedes `Apps/iOS/.../design.md`. Ember orange is deprecated by design decision, not drift — port this doc back to the repo before changing tokens there. + +### Not yet recreated + +New upstream surfaces with no screen in this kit: `Components/AdjustSetsSheet`, `ExerciseDetailSheet`, `HealthKitComponents`, `UnifiedExerciseTimeline`, `Features/StreakSummary/StreakSummarySheetView`, `BetterFitWatchApp/NotificationsView`, `DesignSystem/BFBodyMap`, `BFIllustrations`. + +## Screen map + +| Project screen | Repo files | +| --- | --- | +| `ui_kits/ios_app/SignInScreen.jsx` | `Apps/iOS/BetterFitApp/Features/Auth/SignInView.swift` | +| `ui_kits/ios_app/MyPlanScreen.jsx` | `Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift`, `WorkoutHomeView+Sections.swift`, `WorkoutHomeComponents.swift`, `Features/Trends/PlanView.swift` | +| `ui_kits/ios_app/BodyScreen.jsx` | `Apps/iOS/BetterFitApp/Features/Recovery/RecoveryView.swift` | +| `ui_kits/ios_app/TargetsScreen.jsx` | — (no repo counterpart) | +| `ui_kits/ios_app/LogScreen.jsx` | — (no repo counterpart) | +| `ui_kits/ios_app/SummaryScreen.jsx` | — (no repo counterpart) | +| `ui_kits/ios_app/SessionScreen.jsx` | `Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift` | +| `ui_kits/ios_app/SearchScreen.jsx` | `Apps/iOS/BetterFitApp/Features/AppSearch/AppSearchView.swift` | +| `ui_kits/ios_app/ProfileScreen.jsx` | `Apps/iOS/BetterFitApp/Features/Profile/ProfileView.swift` | +| `ui_kits/ios_app/SettingsScreen.jsx` | `Apps/iOS/BetterFitApp/Features/Profile/SettingsView.swift`, `Features/Theme/ThemePickerView.swift` | +| `ui_kits/ios_app/AppShell.jsx` | `Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift` | +| `ui_kits/watch_app/WatchScreens.jsx` | `Apps/iOS/BetterFitWatchApp/WorkoutListView.swift`, `ActiveWorkoutView.swift`, `ContentView.swift` | +| `ui_kits/watch_app/WatchShell.jsx` | `Apps/iOS/BetterFitWatchApp/WatchTheme.swift` | +| `tokens/*.css` | `Apps/iOS/BetterFitApp/DesignSystem/BFColors.swift`, `BFTypography.swift`, `BFSpacing.swift`, `AppTheme.swift` | +| `design.md` | `design.md` (repo root) — diverged, see above | +| `components/**` | `Apps/iOS/BetterFitApp/DesignSystem/BFComponents.swift`, `BFButtonStyles.swift`, `BFRecovery.swift`, `UIComponents.swift`, `Features/**` | + +## Sync history + +- 2026-07-26 — built the token layer from `BFColors`/`BFTypography`/`BFSpacing`/`AppTheme`, authored 30 components mapped to the SwiftUI primitives, recreated the iPhone app as 7 screens and the Watch app as 3, copied the logo artwork and BBH Hegarty Regular into `assets/`. diff --git a/design-system/guidelines/brand-app-marks.html b/design-system/guidelines/brand-app-marks.html new file mode 100644 index 0000000..b278487 --- /dev/null +++ b/design-system/guidelines/brand-app-marks.html @@ -0,0 +1,2 @@ +<!-- @dsCard group="Brand" viewport="700x170" name="App marks" subtitle="Lettermark, combo mark, and shipped app icon" --> +<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>App marks
LettermarkCombo markApp icon
\ No newline at end of file diff --git a/design-system/guidelines/brand-iconography.html b/design-system/guidelines/brand-iconography.html new file mode 100644 index 0000000..1f5ef3b --- /dev/null +++ b/design-system/guidelines/brand-iconography.html @@ -0,0 +1,7 @@ + + +
+
figure.run
waveform
magnifyingglass
person.fill
flame.fill
heart.fill
sparkles
dumbbell.fill
timer
trophy
checkmark.circle
chevron.right
+
+
Labels show the SF Symbol each Phosphor glyph replaces. One weight per screen; never mix stroke and fill. No emoji in the interface.
+ diff --git a/design-system/guidelines/brand-identity-palette.html b/design-system/guidelines/brand-identity-palette.html new file mode 100644 index 0000000..774c4f3 --- /dev/null +++ b/design-system/guidelines/brand-identity-palette.html @@ -0,0 +1,6 @@ + +Identity palette
+
BetterFit yellow--bf-yellow #FFD60A
+
Black--bf-black #000000
+
White--bf-white #FFFFFF
+
Yellow is a brand signal, not a page background. Large yellow fields carry black content.
\ No newline at end of file diff --git a/design-system/guidelines/brand-primary-lockup.html b/design-system/guidelines/brand-primary-lockup.html new file mode 100644 index 0000000..e8eb716 --- /dev/null +++ b/design-system/guidelines/brand-primary-lockup.html @@ -0,0 +1,2 @@ + +Primary lockup
BetterFit
Three compressed ovals — plates on a bar, repetition, momentum — over a heavy geometric wordmark. Clear space of one cap height on all sides. Do not alter proportions, recolour, add effects, or place anything inside the mark.
\ No newline at end of file diff --git a/design-system/guidelines/brand-stacked.html b/design-system/guidelines/brand-stacked.html new file mode 100644 index 0000000..2122b83 --- /dev/null +++ b/design-system/guidelines/brand-stacked.html @@ -0,0 +1,2 @@ + +Stacked lockups
BetterFit stackedBetterFit stacked reversed
Both stacked files carry the wordmark only — the upper area where the three-oval mark should sit renders same-colour as the field, so the mark is effectively absent. Use the yellow primary lockup when the mark must appear. Asset fix needed upstream.
\ No newline at end of file diff --git a/design-system/guidelines/color-action.html b/design-system/guidelines/color-action.html new file mode 100644 index 0000000..7f6d05d --- /dev/null +++ b/design-system/guidelines/color-action.html @@ -0,0 +1,8 @@ + + +
+
Accent--accent #FFD60A
+
Pressed--accent-pressed #C9A800
+
Accent surface--accent-surface 15%
+
Start workoutLabel on accent--text-on-accent #000
+
diff --git a/design-system/guidelines/color-glass.html b/design-system/guidelines/color-glass.html new file mode 100644 index 0000000..cdb8232 --- /dev/null +++ b/design-system/guidelines/color-glass.html @@ -0,0 +1,14 @@ + + +
+
+

The work

+
Lat pulldown 3×8 · 50 kg
+
Cable row 4×8 · 64 kg
+
Barbell curl 4×8 · 30 kg
+
Hammer curls 3×12 · 15 kg
+
+
--glass-fill
--glass-border
--glass-blur
--glass-shadow
+
+ +
diff --git a/design-system/guidelines/color-heatmap-ramp.html b/design-system/guidelines/color-heatmap-ramp.html new file mode 100644 index 0000000..695707c --- /dev/null +++ b/design-system/guidelines/color-heatmap-ramp.html @@ -0,0 +1,5 @@ + + +
+
--heat-0
--heat-1
--heat-2
--heat-3
--heat-4
+ diff --git a/design-system/guidelines/color-recovery.html b/design-system/guidelines/color-recovery.html new file mode 100644 index 0000000..b77a539 --- /dev/null +++ b/design-system/guidelines/color-recovery.html @@ -0,0 +1,10 @@ + + +
+
Recovered--recovery-recovered
+
Fresh--recovery-fresh
+
Fatigued--recovery-fatigued
+
Sore--recovery-sore
+
+
Readiness reads as brightness, which survives greyscale and colour blindness. The written status always sits beside the swatch — "Chest · Sore".
+ diff --git a/design-system/guidelines/color-schemes.html b/design-system/guidelines/color-schemes.html new file mode 100644 index 0000000..85f16bd --- /dev/null +++ b/design-system/guidelines/color-schemes.html @@ -0,0 +1,6 @@ + + +
+
Dark — default
Pull B
5 exercises · 42 min
Start workout
Swap exercise
+
Light
Pull B
5 exercises · 42 min
Start workout
Swap exercise
+
diff --git a/design-system/guidelines/color-status.html b/design-system/guidelines/color-status.html new file mode 100644 index 0000000..6940861 --- /dev/null +++ b/design-system/guidelines/color-status.html @@ -0,0 +1,10 @@ + + +
+
Success--success #FFE860
+
Warning--warning #FFD60A
+
Information--info #C9A800
+
Danger--danger = inverted
+
+
Danger has no hue of its own: destructive controls invert (white fill, black label on dark). Every status is written out in words, so removing colour removes nothing.
+ diff --git a/design-system/guidelines/color-surfaces-dark.html b/design-system/guidelines/color-surfaces-dark.html new file mode 100644 index 0000000..0601aa7 --- /dev/null +++ b/design-system/guidelines/color-surfaces-dark.html @@ -0,0 +1,8 @@ + +Surfaces dark
+
Page--bg-page #0B0B0D
+
Elevated--bg-elevated #131316
+
Surface--surface #17171B
+
Raised--surface-raised #1F1F25
+
Border--border rgba(255,255,255,.07)
+
\ No newline at end of file diff --git a/design-system/guidelines/color-surfaces-light.html b/design-system/guidelines/color-surfaces-light.html new file mode 100644 index 0000000..fc4003d --- /dev/null +++ b/design-system/guidelines/color-surfaces-light.html @@ -0,0 +1,8 @@ + +Surfaces light
+
Page--bg-page #F6F6F8
+
Elevated--bg-elevated #FFFFFF
+
Surface--surface #FFFFFF
+
Raised--surface-raised #EFEFF3
+
Border--border rgba(0,0,0,.08)
+
\ No newline at end of file diff --git a/design-system/guidelines/color-text.html b/design-system/guidelines/color-text.html new file mode 100644 index 0000000..557d446 --- /dev/null +++ b/design-system/guidelines/color-text.html @@ -0,0 +1,8 @@ + +Text colors
+
Primary
--text-primary #FFFFFF
+
Secondary
--text-secondary #9C9CA6
+
Tertiary
--text-tertiary #63636D
+
On accent
--text-on-accent #000
+
Accent text
--accent-text
+
\ No newline at end of file diff --git a/design-system/guidelines/motion-press.html b/design-system/guidelines/motion-press.html new file mode 100644 index 0000000..fdb473e --- /dev/null +++ b/design-system/guidelines/motion-press.html @@ -0,0 +1,7 @@ + +Press
+
Rest
+
Pressed
+
Secondary pressed
+
Ghost pressed
+
\ No newline at end of file diff --git a/design-system/guidelines/motion-tokens.html b/design-system/guidelines/motion-tokens.html new file mode 100644 index 0000000..84b0619 --- /dev/null +++ b/design-system/guidelines/motion-tokens.html @@ -0,0 +1,9 @@ + +Motion
+
Press feedback--dur-press 120ms
+
Standard ease--ease-out
+
State change--dur-fast 180ms
+
Snappy (progress)--ease-snappy
+
Transition--dur-standard 250ms
+
Card / sheet spring--ease-spring
+
All durations collapse to 0 under prefers-reduced-motion.
\ No newline at end of file diff --git a/design-system/guidelines/space-controls.html b/design-system/guidelines/space-controls.html new file mode 100644 index 0000000..f04f2c9 --- /dev/null +++ b/design-system/guidelines/space-controls.html @@ -0,0 +1,6 @@ + +Controls
+
Start workout
--control-lg 54
+
Swap
--control-md 44 · --tap-min 44
+
Filter
--control-sm 44 · min tap
+
\ No newline at end of file diff --git a/design-system/guidelines/space-page.html b/design-system/guidelines/space-page.html new file mode 100644 index 0000000..249b875 --- /dev/null +++ b/design-system/guidelines/space-page.html @@ -0,0 +1,7 @@ + +Page rhythm
+
+
+
Today's workout
+
Push A
6 exercises · 48 min
+
\ No newline at end of file diff --git a/design-system/guidelines/space-radii.html b/design-system/guidelines/space-radii.html new file mode 100644 index 0000000..1336870 --- /dev/null +++ b/design-system/guidelines/space-radii.html @@ -0,0 +1,2 @@ + +Radii
sm 8
md 12
button 14
card 16
sheet 24
pill
band 0
\ No newline at end of file diff --git a/design-system/guidelines/space-scale.html b/design-system/guidelines/space-scale.html new file mode 100644 index 0000000..b1c3a3d --- /dev/null +++ b/design-system/guidelines/space-scale.html @@ -0,0 +1,2 @@ + +Spacing
xs
4
sm
8
md
12
lg
16
xl
20
2xl
24
3xl
32
4xl
48
\ No newline at end of file diff --git a/design-system/guidelines/type-body.html b/design-system/guidelines/type-body.html new file mode 100644 index 0000000..ba7eec6 --- /dev/null +++ b/design-system/guidelines/type-body.html @@ -0,0 +1,8 @@ + +Body
+
Headline 17 semibold — Ready for your next set?
+
Body 17 regular — Chest is still recovering. Train back today.
+
Subheadline 15 — Two workouts this week. One more reaches your goal.
+
Footnote 13 — Based on your last four sessions.
+
Caption 12 — Updated 3 minutes ago
+
\ No newline at end of file diff --git a/design-system/guidelines/type-display.html b/design-system/guidelines/type-display.html new file mode 100644 index 0000000..4e43dbf --- /dev/null +++ b/design-system/guidelines/type-display.html @@ -0,0 +1,7 @@ + +Display
+
Train with direction
+
Large title 34
+
Title 1 · 28
+
Title 2 · 22
+
--text-hero 56 / --text-large-title 34 / --text-title-1 28 / --text-title-2 22
\ No newline at end of file diff --git a/design-system/guidelines/type-labels.html b/design-system/guidelines/type-labels.html new file mode 100644 index 0000000..fcb0af3 --- /dev/null +++ b/design-system/guidelines/type-labels.html @@ -0,0 +1,6 @@ + +Labels
+Today's workout +Up next +Now +
Write the string in sentence case; the label style uppercases it. Use sparingly — one or two per screen.
\ No newline at end of file diff --git a/design-system/guidelines/type-numeric.html b/design-system/guidelines/type-numeric.html new file mode 100644 index 0000000..5974365 --- /dev/null +++ b/design-system/guidelines/type-numeric.html @@ -0,0 +1,7 @@ + +Numeric
+
01:24
--text-timer 40
+
225 lb
--text-stat-lg 28
+
18.4k
--text-stat-md 20
+
72%
--text-stat-sm 15
+
\ No newline at end of file diff --git a/design-system/readme.md b/design-system/readme.md new file mode 100644 index 0000000..38b01cf --- /dev/null +++ b/design-system/readme.md @@ -0,0 +1,303 @@ +# BetterFit Design System + +BetterFit is a personal strength training coach for iPhone and Apple Watch. It turns workout history, performance and recovery into a clear next action: what to train today, how to get through the session with less friction, and when to back off. + +> **Train with direction. Get better with every session.** + +The name carries the promise — not perfect, extreme or finished. Better. + +This design system is the web-side expression of that product: the real tokens, the real brand artwork, reusable components, and click-through recreations of both shipped surfaces. + +--- + +## Sources + +Everything here was read from material the user supplied. You may not have access to these; they are recorded so you can go deeper if you do. + +| Source | What it gave us | +| --- | --- | +| `betterfit/` (mounted local codebase — Swift package + iOS/watchOS apps) | Ground truth for every token, component and screen | +| `betterfit/design.md` | The authored brand document: idea, character, voice, palettes, layout, motion, imagery | +| `betterfit/Apps/iOS/BetterFitApp/DesignSystem/` | `BFColors.swift`, `BFTypography.swift`, `BFSpacing.swift`, `BFComponents.swift`, `BFButtonStyles.swift`, `BFRecovery.swift` | +| `betterfit/Apps/iOS/BetterFitApp/AppTheme.swift` | Six user-selectable accent themes | +| `betterfit/Apps/iOS/BetterFitApp/Assets.xcassets/` | Logo artwork and app icons (copied into `assets/`) | +| `betterfit/Apps/iOS/BetterFitApp/Fonts/` | BBH Hegarty Regular (copied into `assets/fonts/`) | +| `betterfit/Apps/iOS/BetterFitWatchApp/` | Watch palette and the three watch screens | +| https://github.com/echohello-dev/betterfit | Public repository for the same project — **read it for anything this system doesn't cover**: services, models, the full SwiftUI view code, and the docs in `docs/` | + +Explore the repository above if you need more than this system carries. The Swift views are the best available reference for interaction detail, and `design.md` is the canonical brand statement. + +### Products represented + +1. **BetterFit for iPhone** — the main product. Tabs: Workout, Body, Targets, Log. Suggested sessions, live set logging, recovery readiness, streaks and personal records, theming. Search, Me and Settings are pushed surfaces rather than tabs. +2. **BetterFit for Apple Watch** — a stripped companion: pick a workout, log sets, see the summary. Bigger controls, glanceable numbers. + +There is no marketing website, docs site, or slide template in the supplied material, so this system contains no kits or slides for them. + +--- + +## The redesign + +The user asked to reimagine the product. Two things stayed fixed and one thing changed. + +**Fixed** — the type scale, the 4-point rhythm, the flat hairline-bordered surfaces, every screen and every piece of copy behaviour. Those come from shipped code and an authored brand doc; they are the product. + +**Changed** — the shipped app runs on ember orange `#FF5A3C` with a six-accent theme picker and a four-hue semantic palette, and keeps the identity yellow locked inside the app icon. This system collapses all of that to **two colours: electric yellow and black.** + +- **Yellow is the accent.** `--accent` is `#FFD60A` and every primary action is a yellow fill with a **black** label. Ember, the six theme accents, and the green/amber/red/blue semantic hues are gone. +- **Light and dark are peers.** Both schemes carry the same two colours over mirrored neutrals; `[data-scheme="light"]` flips the surfaces, text and the yellow-ladder steps. Nothing is dark-only. +- **Status without hue.** Meaning comes from brightness on a four-step yellow ladder (`#FFE860 → #FFD60A → #C9A800 → #7A6600`) plus the written word. Destructive actions **invert** — white fill, black label on dark — because there is no alarm colour to reach for. +- **A full-bleed yellow slab.** The session you are about to train sits in a squared-off, edge-to-edge yellow field with black display type at 44px (`Slab` in the iPhone kit, `WorkoutCard tone="identity"` as the DS component), and its CTA is a **black pill inside the slab**. +- **One yellow area per screen.** Because the accent *is* the brand colour, two yellow fills on one screen destroy the hierarchy. Either the slab or the primary button — never both. +- **Squared at page level, rounded inside.** Page-width bands use `--radius-band: 0`; everything nested keeps the shipped 16px card / 14px button / 12px control radii. +- **Numbers as the headline.** The conclusion leads in oversized tabular figures; the chart is demoted to context. + +**Tokens, type scale, spacing rhythm and copy behaviour are a faithful recreation** — where one of those differs from the Swift source, the Swift source wins. + +**iPhone screen layout is not.** The six training screens (Plan, Body, Targets, Log, Session, Summary) were reimagined on an in-house layout language — ruled rows on the page instead of cards, a 32px monospaced gutter down every list, one squared yellow slab carrying the headline and the single primary action, and bars on a shared left edge instead of rings. That is a deliberate departure from `WorkoutHomeView*.swift` and `ActiveSessionView.swift`, which remain the reference for *interaction* detail and copy, not for composition. `ui_kits/ios_app/README.md` documents the language and states which screens are on it. + +--- + +## CONTENT FUNDAMENTALS + +Write like a knowledgeable training partner standing nearby — not a coach shouting, not a dashboard reporting. + +**Sentence case everywhere.** Buttons, titles, list rows, settings. The only uppercase is the section label (12px, `0.1em` tracking, secondary colour) and it should appear once or twice per screen at most. + +**Lead with the action or the useful fact.** "Start workout", not "Ready to begin your workout?". "Chest is still recovering. Train back today.", not "Recovery status: sub-optimal". + +**Short active sentences.** One idea each. Two sentences is a long piece of copy in this product. + +**Second person, no first person.** "Your last four sessions", never "we recommend" or "I've planned". The product doesn't refer to itself. + +**Explain a recommendation when the reason builds trust,** and only then: "Back and biceps today. Push moves to Thursday." + +**Acknowledge progress without hype.** "Two workouts this week. One more reaches your goal." "Twelve weeks unbroken." "Good work." + +**Never shame.** Not missed sessions, not low numbers, not body shape, not needing rest. There is no "You failed to…" state in this product. + +**Never advertise the automation.** Adaptation is described by what it does ("Regenerate this week"), not by what powers it. No "AI-powered", no "magic". + +**No emoji.** None appear in the product UI. (The repository README uses them in its feature bullets; that is developer documentation, not product copy, and it is not a licence to use them in interfaces.) + +**Empty states name the gap and offer the step:** "No exercises planned — Add a lift, or generate a session from your recovery." + +**Errors say what happened in plain language and offer a way out.** Never a dead end, never a code. + +| Prefer | Avoid | +| --- | --- | +| Start workout | Crush your limits! | +| Swap exercise | No excuses | +| Chest is still recovering. Train back today. | Recovery index: 34% (suboptimal) | +| Two workouts this week. One more reaches your goal. | You failed to meet your goal | +| Ready for your next set? | AI-powered gains, unlocked | +| Good work | Summer body loading | + +--- + +## VISUAL FOUNDATIONS + +### Colour + +Two palettes with different jobs. + +**Two colours, no exceptions.** `--bf-yellow #FFD60A` and `--bf-black #000000`, plus white and a neutral grey ladder. There is no third hue anywhere in the system — no orange, no red, no green, no blue, and no theme picker. + +**Yellow is the accent as well as the identity.** `--accent` is the yellow; `--text-on-accent` is black. Primary buttons, the active tab, selected chips, progress fills and the identity band all use it. Because it is the brand colour, **a screen gets exactly one yellow area.** + +**Yellow ladder** — the only chromatic range: `--yellow-bright #FFE860`, `--bf-yellow #FFD60A`, `--yellow-deep #C9A800`, `--yellow-dim #7A6600`. Everything that used to need a second hue now moves along this ladder. + +**Yellow as text** needs care: `--accent` on a light field fails contrast, so text and icons use `--accent-text` — the yellow on dark, `#8A7100` on light. Never set body or link text to `--accent` directly. + +**Neutrals, dark (default)** — page `#0B0B0D`, elevated `#131316`, surface `#17171B`, raised `#1F1F25`; white primary text, `#9C9CA6` secondary, `#63636D` tertiary. + +**Neutrals, light** — `[data-scheme="light"]` mirrors it: `#F6F6F8` page, white surfaces, `#EFEFF3` raised, `#131317` text, and darker ladder steps so yellow stays legible on white. Light and dark are equal citizens; nothing is dark-only. + +**Status carries no hue of its own.** `--success` is the bright ladder step, `--warning` the core yellow, `--info` the deep step, and `--danger` resolves to `--text-primary` — destructive controls **invert** (white fill, black label on dark) rather than turning red. Every status is also written out, so stripping colour strips nothing. + +**Recovery** runs by brightness: recovered (brightest) → fresh → fatigued → sore (dimmest), always beside the written status. + +Tinted surfaces are the accent or a ladder step at **15%** opacity (recovery badges use 22% with a 45% border). Those are the only tint ratios in the system. + +### Type + +Display is **BBH Hegarty** at `+0.005em` tracking and `1.05` line-height — the Swift source specifies `-0.02em`, but that assumes the Bold/ExtraBold cut; with only the Regular cut licensed here, negative tracking collides at title sizes. **Restore `-0.02em` once the heavier cuts arrive.** It carries the hero (56px), large title (34), title 1 (28) and title 2 (22). Interface copy is the **native system face**: headline/body 17, callout 16, supporting 15, footnote 13, caption 12. Timers, weights, reps, percentages and any changing statistic use **tabular figures** (`.bf-num`) so they don't jitter; the timer itself (40px) is the system face with monospaced digits, not a separate mono stack. + +Section labels are 11px bold uppercase at `0.1em` tracking in secondary text. Nothing else is uppercase. + +The scale is a point scale carried to px 1:1 from `BFTypography.swift`. Do not invent intermediate sizes. + +### Spacing and layout + +A **4-point rhythm** — 4 / 8 / 12 / 16 / 20 / 24 / 32 / 48 — with 8, 12, 16, 20, 24 and 32 doing nearly all the work. Page margins are **20px**. Card padding is **16px**. Layouts are vertically rhythmic, **left-aligned**, and scannable in one pass. Data gets room; not every gap needs a metric. + +Fixed elements: the tab bar, and the primary CTA that floats above it (`safeAreaInset` in the shipped app, a gradient-masked footer here). Nothing else is pinned. + +### Shape + +Continuous rounded corners: **16px** cards, **14px** buttons, **12px** controls, **24px** sheets, full capsules for chips and only chips. The redesign adds **0px** for page-width identity bands. Controls are **54px** (primary CTA), **44px** (standard, and the minimum tap target), **34px** (compact). + +### Surfaces, borders and shadows + +Flat, solid fills with a **1px hairline border** — `--border` is `rgba(255,255,255,.07)` on dark, `rgba(0,0,0,.08)` on light. Always write `1px solid var(--border)`; there is deliberately no composite `--hairline` token, because a shorthand containing `var(--border)` resolves at `:root` and would freeze the dark value for every scheme. **No shadows on dark**; the light scheme gets a barely-there `0 1px 2px rgba(0,0,0,.05)` on cards and a real shadow only on sheets and popovers. No blur-heavy glass, no glossy gradients, no floating cards without hierarchy. + +The single gradient in the system is functional: a page-colour scrim behind the floating CTA so scrolling content passes under it legibly. Protection is a scrim, never a capsule. + +### Backgrounds and imagery + +Product backgrounds are **solid colour**. No patterns, textures, grain or illustration wallpaper. Imagery, where the brand uses it, is real training photography — diverse bodies, ages and experience levels, believable effort, correct equipment, environments where people actually train. Cropped close enough to feel active, with clear space left for type. Colour is **neutral to warm, natural light**, never teal-orange graded and never fake holographic data overlays. Avoid hyper-muscular stereotypes, transformation and before/after imagery, body comparison, stock smiles, and anything implying pain is the goal. **No photography is supplied with this system** — ask for real assets rather than substituting stock. + +Illustration, when needed, uses heavy geometric forms, simplified anatomy, and the same high-contrast confidence as the mark. The shipped app contains hand-built SwiftUI illustrations (`BFIllustrations.swift`: an ember mascot, a body-map companion, athlete poses) which have no exported artwork; they are **not** reproduced here. + +### Transparency and blur + +Transparency is used for exactly three things: hairline borders, 15% accent tints, and disabled state (40%). **Blur is not used at all.** If a surface needs to separate from what's behind it, it gets a solid fill and a border. + +### Motion + +Short and responsive. `120ms` press feedback, `180ms` state changes, `250ms` transitions, `350ms` for progress arcs. Easing is `cubic-bezier(.2,.8,.3,1)` by default, a snappier `cubic-bezier(.32,.72,0,1)` for progress, and a light spring for sheets and card stacks. Progress changes, completed sets and advancing to the next exercise animate. Nothing loops, nothing bounces for its own sake, no confetti for routine work, and every duration collapses to zero under `prefers-reduced-motion`. + +### Interaction states + +- **Hover** — not a primary concern (this is a touch product). Where it applies, it's a small opacity lift, never a colour change. +- **Press** — primary: opacity `0.85` **and** scale `0.985`. Secondary: opacity `0.7`. Ghost: opacity `0.6`. Icon buttons: opacity `0.7`. Never a darker fill; never a bounce. +- **Selected** — yellow fill with a **black** label (chips, tabs, segments, toggles). Toggle knobs go black when on. +- **Disabled** — opacity `0.4`, cursor `not-allowed`, no colour change. +- **Focus** — a `--focus-ring` in `--text-primary`, because yellow is already carrying the selected state. + +### Accessibility + +WCAG AA minimum. Light and dark, Dynamic Type, VoiceOver, Reduce Motion. Recovery, completion and errors are never encoded by colour alone — completion is a check *and* a strikethrough; recovery is a dot *and* a word. Workout-critical values stay legible one-handed and in motion. + +--- + +## ICONOGRAPHY + +**The shipped apps use SF Symbols exclusively** — no icon font, no SVG sprite, no PNG icon set, nothing exportable. Common glyphs across the codebase: `figure.run`, `waveform`, `magnifyingglass`, `person.fill`, `flame.fill`, `heart.fill`, `sparkles`, `calendar`, `chart.bar.fill`, `chevron.right`, `checkmark.circle.fill`, `plus.circle.fill`, `arrow.left.arrow.right`, `timer`, `ellipsis`, `bell`, `gearshape`, `trophy`. Weights are semibold and visually consistent; sizes sit between 12 and 36pt with 44pt circular tinted containers for list-row icons. + +**Substitution (please confirm):** SF Symbols cannot be licensed onto the web, so this system uses **[Phosphor Icons](https://phosphoricons.com) at `fill` weight** from CDN, which is the closest match to SF Symbols semibold in stroke mass and terminal shape. `bold` weight is also loaded for chevrons and checks. Every icon goes through the `Icon` component so the whole set can be swapped in one place. + +```html + + +``` + +Rules: one weight per screen, never mix stroke and fill. Icons are decorative next to a label and carry an `aria-label` only when they stand alone. **Emoji are never used in the interface.** Unicode characters are not used as icons; the middle dot `·` is used as a separator in metadata strings ("6 exercises · 48 min") and that is its only decorative role. + +The brand marks in `assets/` are real supplied artwork and are placed via the `Logo` component — never redrawn, retyped, recoloured or reconstructed. + +**Asset defect worth knowing:** only `logo.png` (the yellow primary lockup) actually contains the three-oval mark. In `logo-stacked.png` and `logo-stacked-dark.png` the mark area renders the same colour as its field, so those two files are wordmark-only. On dark screens use the yellow lockup or the square lettermark. **Please re-export the stacked lockups.** + +--- + +## Font substitutions — please confirm + +- **BBH Hegarty** ships as **Regular (400) only** (`assets/fonts/BBHHegarty-Regular.ttf`, OFL). The identity wordmark is far heavier than 400 and the shipped app asks for `BBHHegarty-ExtraBold` / `-Bold` first, falling back to a heavy rounded system face. **If you have licensed Bold / ExtraBold cuts, please add them** — display headings are currently rendering at 400 with tight tracking, which reads deliberate but is not the intended weight. +- **SF Pro / the system UI face** is not a webfont. Body copy uses `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui` so Apple platforms get the real face and others get their native equivalent. +- **SF Symbols → Phosphor Icons (fill)**, as above. + +--- + +## Components + +Thirty-two components, grouped by concern. The inventory maps 1:1 onto what the codebase defines — nothing was invented to round out a "standard set". + +**`components/core/`** — `Button`, `IconButton`, `Card`, `SectionHeader`, `Divider`, `Chip`, `DropdownChip`, `MetricPill`, `Icon`, `Logo`, `PhotoSlot` + +**`components/data/`** — `ProgressRing`, `Gauge`, `StatTile`, `OverviewStat`, `LegendDot`, `MuscleChip`, `ContributionHeatmap` + +`ProgressRing` and `Gauge` are for the Apple Watch kit and the specimen cards only. No iPhone screen uses them — on iPhone, progress and readiness are ruled bars on a shared left edge, never rings. + +**`components/lists/`** — `ListRow`, `ChevronRow`, `ExerciseRow`, `SettingsRow` + +**`components/feedback/`** — `RecoveryDot`, `RecoveryBadge`, `EmptyState`, `RestTimerBar`, `Banner` + +**`components/workout/`** — `WorkoutCard`, `SetLogField`, `WeightUnitToggle`, `SupersetIndicator` + +Every component has a sibling `.d.ts` (props contract) and `.prompt.md` (what & when, a usage example, notable variants). Each group directory has one gallery card. + +
+Where each one comes from + +| Component | Swift source | +| --- | --- | +| `Button` | `BFButtonStyles.swift` — `BFPrimary/Secondary/Ghost/Destructive ButtonStyle` (destructive re-expressed as inversion) | +| `IconButton` | `BFButtonStyles.swift` `BFIconButton`, `UIComponents.swift` `BFChromeIconButton` | +| `Card` | `BFComponents.swift` `BFDSCard`, `UIComponents` `BFCard` | +| `SectionHeader` | `BFComponents.swift` `BFSectionHeader` | +| `Divider` | `BFComponents.swift` `BFDivider` | +| `Chip` | `BFComponents.swift` `BFChip` | +| `MetricPill` | `UIComponents.swift` `MetricPill` | +| `ProgressRing` | `BFComponents.swift` `BFProgressRing`, `UIComponents` `ProgressRing` | +| `Gauge` | `WorkoutHomeComponents.swift` `SemiCircularGauge` | +| `StatTile` | `BFComponents.swift` `BFStatTile` | +| `OverviewStat` | `WorkoutHomeComponents.swift` `OverviewStat`, `GoalStat` | +| `LegendDot` | `WorkoutHomeComponents.swift` `CategoryLegendDot` | +| `MuscleChip` | `WorkoutHomeComponents.swift` `CompactMuscleChip`, `TargetMuscleView` | +| `ContributionHeatmap` | `WorkoutHomeComponents.swift` `ContributionHeatmap` | +| `ListRow` | `BFComponents.swift` `BFListRow` | +| `ChevronRow` | `BFComponents.swift` `BFChevronRow` | +| `ExerciseRow` | `WorkoutHomeView+Sections.swift` `StaticTimelineRow`, `CompactExerciseRow`, `ExercisePreviewRow` | +| `SettingsRow` | `SettingsView.swift` `SettingsToggleRow` / `RadioRow` / `StaticRow` / `LinkRow` | +| `RecoveryDot`, `RecoveryBadge` | `BFRecovery.swift` | +| `EmptyState` | `BFComponents.swift` `BFEmptyState` | +| `RestTimerBar` | `ActiveSessionView.swift` `RestTimerBar` | +| `Banner` | `HealthKitComponents.swift` `AppleHealthReminderBanner` | +| `WorkoutCard` | `WorkoutHomeComponents.swift` `WorkoutSwipeCard`, `WorkoutSuggestionCard`, `PlayingCardWorkoutCard` | +| `SetLogField` | `ActiveSessionView.swift` `ActiveSessionField` | +| `WeightUnitToggle` | `UnifiedExerciseTimeline.swift` `WeightUnitToggle` | +| `SupersetIndicator` | `UnifiedExerciseTimeline.swift` `SupersetIndicator` | + +
+ +### Intentional additions + +- **`Icon`** — a wrapper over the substituted Phosphor set. The Swift code calls `Image(systemName:)` inline; the web needs one seam so the whole icon set can be replaced at once. +- **`Logo`** — places the supplied artwork from `assets/`. Exists so nobody is ever tempted to retype the wordmark. +- **`DropdownChip`** — a capsule showing a current selection that opens a menu. The Swift source builds this inline as a `Menu` label in several places (heatmap range, equipment); the web needs it as one component. +- **`PhotoSlot`** — a labelled placeholder for photography. BetterFit ships no image assets, so this keeps every missing photo visible instead of silently filling it with stock. + +### Deliberately not built + +`BFIllustrations.swift` (`EmberMascot`, `BodyMapCompanion`, `AthleteIllustration`) is drawn in SwiftUI shapes with no exported artwork. Reproducing it would mean hand-drawing brand illustration, so it is omitted. **Ask for exported illustration assets** if a screen needs them. + +--- + +## Index + +``` +readme.md this file — the design guide +SKILL.md Agent Skills entry point +github.md upstream repository association +styles.css the one file consumers link (@import list only) +thumbnail.html homepage tile + +tokens/ fonts · colors · typography · spacing · radii · elevation · motion · base + colors.css is the whole palette: yellow, black, neutrals, one ladder +assets/ logo.png · logo-stacked.png · logo-stacked-dark.png + icon-lettermark.png · icon-combo.png · app-icon-1024.png + fonts/BBHHegarty-Regular.ttf · fonts/OFL.txt +guidelines/ 23 foundation specimen cards (Brand · Colors · Type · Spacing · Motion) +components// 30 components — .jsx + .d.ts + .prompt.md, one gallery card per group +ui_kits/ios_app/ 7-screen click-through iPhone recreation (README.md · index.html) +ui_kits/watch_app/ 3-screen Apple Watch recreation (README.md · index.html) +templates/ starting folders consuming projects can copy +``` + +### UI kits + +| Kit | Entry | Screens | +| --- | --- | --- | +| iPhone app | `ui_kits/ios_app/index.html` | Plan · Live session · Body (recovery + strength) · Targets · Log & calendar · Workout summary · Sign in · Home · Search · Me · Settings | +| Apple Watch app | `ui_kits/watch_app/index.html` | Workout list · Active workout · Summary | + +### Product design test + +Before shipping a screen, ask: + +1. Is the next action obvious within a glance? +2. Does every metric help the person decide or understand something? +3. Can this be used between sets with one hand? +4. Does it feel strong without becoming aggressive? +5. Does it encourage progress without judgement? +6. Does it still work with large text, VoiceOver, and without colour? diff --git a/design-system/styles.css b/design-system/styles.css new file mode 100644 index 0000000..7dae8c6 --- /dev/null +++ b/design-system/styles.css @@ -0,0 +1,8 @@ +@import url("tokens/fonts.css"); +@import url("tokens/colors.css"); +@import url("tokens/typography.css"); +@import url("tokens/spacing.css"); +@import url("tokens/radii.css"); +@import url("tokens/elevation.css"); +@import url("tokens/motion.css"); +@import url("tokens/base.css"); diff --git a/design-system/templates/app-screen/.thumbnail b/design-system/templates/app-screen/.thumbnail new file mode 100644 index 0000000..d6d1331 Binary files /dev/null and b/design-system/templates/app-screen/.thumbnail differ diff --git a/design-system/templates/app-screen/AppScreen.dc.html b/design-system/templates/app-screen/AppScreen.dc.html new file mode 100644 index 0000000..1abd4f9 --- /dev/null +++ b/design-system/templates/app-screen/AppScreen.dc.html @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + +
+
+ +
+9:41 + +
+ +
+BetterFit +
+
Hello, Johnny
+
Two workouts this week. One more reaches your goal.
+
+
+ +
+ +
+
Today · Tue 26 Jul
+
Pull B
+
+5 exercises42 min14.2k lb +
+
+Lats +Biceps +Rear delts +
+ +
+ +
+ + + + +
+ +12 +day streak +
+
+ + + +
+
+ +
+ + + + + + + +
+ +
+
+ +
+
Workout
+
Plan
+
Search
+
Me
+
+ +
+
+ +
+ + diff --git a/design-system/templates/app-screen/ds-base.js b/design-system/templates/app-screen/ds-base.js new file mode 100644 index 0000000..a7cc09a --- /dev/null +++ b/design-system/templates/app-screen/ds-base.js @@ -0,0 +1,15 @@ +// Loads this design system into the template. In a consuming project, point +// base at the bound DS folder relative to this file (e.g. '_ds/' at +// the project root, '../_ds/' one level down) — one line to edit. +(() => { + const base = '../..'; + for (const p of ["styles.css"]) { + const l = document.createElement('link'); + l.rel = 'stylesheet'; l.href = base + '/' + p; + document.head.appendChild(l); + } + const s = document.createElement('script'); + s.src = base + '/_ds_bundle.js'; + s.onerror = () => console.error('ds-base.js: failed to load ' + s.src + ' — if this is a consuming project, point the base line in ds-base.js at the bound _ds/ tree relative to this page (e.g. _ds/ at the project root, ../_ds/ one level down); in a fresh design system this can just mean the bundle is not compiled yet'); + document.head.appendChild(s); +})(); diff --git a/design-system/templates/app-screen/support.js b/design-system/templates/app-screen/support.js new file mode 100644 index 0000000..cb009b6 --- /dev/null +++ b/design-system/templates/app-screen/support.js @@ -0,0 +1,1911 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/; + var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/; + var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/; + function isDeckMountTag(el) { + if (el.localName === "deck-stage") return true; + return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage"; + } + function walkDeckChildren(el, host) { + const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null); + const kids = pairs.map((p) => p.b); + const seen = /* @__PURE__ */ new Set(); + const wsSeen = /* @__PURE__ */ new Map(); + const keys = []; + const nextSlideId = new Array(pairs.length); + { + let upcoming = null; + for (let j = pairs.length - 1; j >= 0; j--) { + const n = pairs[j].c; + if (n.nodeType === Node.ELEMENT_NODE) { + const t = n.localName; + upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null; + } + nextSlideId[j] = upcoming; + } + } + for (let j = 0; j < pairs.length; j++) { + const { c } = pairs[j]; + if (c.nodeType === Node.TEXT_NODE) { + if ((c.nodeValue ?? "").trim() === "") { + const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux"; + const n = wsSeen.get(base) ?? 0; + wsSeen.set(base, n + 1); + keys.push(n === 0 ? base : base + ":" + n); + continue; + } + return { kids, keys: null }; + } + if (c.nodeType !== Node.ELEMENT_NODE) { + keys.push(j); + continue; + } + const child = c; + const tag = child.localName; + if (DECK_AUX_RE.test(tag)) { + keys.push(j); + continue; + } + if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null }; + const v = child.getAttribute("data-om-slide-id"); + if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) { + return { kids, keys: null }; + } + seen.add(v); + keys.push("omid:" + v); + } + return { kids, keys }; + } + function renderDeckKids(kids, kidKeys, vals, ctx) { + return kids.map((b, j) => { + const k = kidKeys ? kidKeys[j] : j; + const out = b(vals, ctx, k); + return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out; + }); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : []; + const kidKeys = deckKeyed?.keys ?? null; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) { + props.children = renderDeckKids(kids, kidKeys, vals, ctx); + } + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host); + const kidKeys = deckKeyed?.keys ?? null; + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx)); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function scanUnquotedUrl(css, i) { + if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) { + return -1; + } + let j = i + 4; + while (j < css.length && /\s/.test(css[j])) j++; + if (css[j] === '"' || css[j] === "'") return -1; + while (j < css.length && css[j] !== ")") { + if (css[j] === "\\") j++; + j++; + } + return j < css.length ? j + 1 : css.length; + } + function stripComments(css) { + let out = ""; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") { + out += c + (css[i + 1] ?? ""); + i++; + continue; + } + if (c === quote) quote = ""; + out += c; + } else if (c === "'" || c === '"') { + quote = c; + out += c; + } else if (c === "/" && css[i + 1] === "*") { + const end = css.indexOf("*/", i + 2); + i = end === -1 ? css.length : end + 1; + out += " "; + } else { + const end = scanUnquotedUrl(css, i); + if (end === -1) out += c; + else { + out += css.slice(i, end); + i = end - 1; + } + } + } + return out; + } + function importantify(css) { + css = stripComments(css); + const decls = []; + let start = 0; + let depth = 0; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = ""; + } else if (c === "'" || c === '"') quote = c; + else if (c === "(") depth++; + else if (c === ")") depth = Math.max(0, depth - 1); + else if (c === ";" && depth === 0) { + decls.push(css.slice(start, i)); + start = i + 1; + } else { + const end = scanUnquotedUrl(css, i); + if (end !== -1) i = end - 1; + } + } + decls.push(css.slice(start)); + return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";"); + } + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const isPseudoElement = pseudo === "before" || pseudo === "after"; + const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule( + sel + "{" + (isPseudoElement ? css : importantify(css)) + "}", + el.sheet.cssRules.length + ); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: + + + + + + +
+ + + + + + + + + + + + + + + diff --git a/design-system/ui_kits/ios_app/tweaks-panel.jsx b/design-system/ui_kits/ios_app/tweaks-panel.jsx new file mode 100644 index 0000000..e983473 --- /dev/null +++ b/design-system/ui_kits/ios_app/tweaks-panel.jsx @@ -0,0 +1,553 @@ +// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) +// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected). + +/* BEGIN USAGE */ +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider, +// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "palette": ["#D97757", "#29261b", "#f6f4ef"], +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('palette', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// TweakRadio is the segmented control for 2–3 short options (auto-falls-back to +// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when +// options are many or long. For color tweaks always curate 3-4 options rather than +// a free picker; an option can also be a whole 2–5 color palette (the stored value +// is the array). The Tweak* controls are a floor, not a ceiling — build custom +// controls inside the panel if a tweak calls for UI they don't cover. +/* END USAGE */ +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} + + .twk-chips{display:flex;gap:6px} + .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px; + padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default; + box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06); + transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s} + .twk-chip:hover{transform:translateY(-1px); + box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)} + .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85), + 0 2px 6px rgba(0,0,0,.15)} + .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%; + display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)} + .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)} + .twk-chip>span>i:first-child{box-shadow:none} + .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px; + filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null + ? keyOrEdits : { [keyOrEdits]: val }; + setValues((prev) => ({ ...prev, ...edits })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); + // Same-window signal so in-page listeners (deck-stage rail thumbnails) + // can react — the parent message only reaches the host, not peers. + window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits })); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + // data-om-starter: inert presence marker — Claude Design's starter-usage + // probe reads it. The closed panel renders nothing, so the marker rides + // the element as an attribute instead of a rendered node — zero + // elements added, so page CSS (even structural selectors like + // :nth-child) can never observe it. It records that the page WIRES a + // tweaks panel, whether or not the panel is open. Keep this effect. + React.useEffect(() => { + document.documentElement.setAttribute('data-om-starter', 'tweaks-panel'); + return () => document.documentElement.removeAttribute('data-om-starter'); + }, []); + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
+ {children} +
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +function TweakRadio({ label, value, options, onChange }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag — ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + // Segments wrap mid-word once per-segment width runs out. The track is + // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px + // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2 + // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall + // back to a dropdown rather than wrap. + const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length; + const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0); + const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0); + if (!fitsAsSegments) { + // onChange(e.target.value)}> + {options.map((o) => { + const v = typeof o === 'object' ? o.value : o; + const l = typeof o === 'object' ? o.label : o; + return ; + })} + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +// Relative-luminance contrast pick — checkmarks drawn over a swatch need to +// read on both #111 and #fafafa without per-option configuration. Hex input +// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light". +function __twkIsLight(hex) { + const h = String(hex).replace('#', ''); + const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0'); + const n = parseInt(x.slice(0, 6), 16); + if (Number.isNaN(n)) return true; + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return r * 299 + g * 587 + b * 114 > 148000; +} + +const __TwkCheck = ({ light }) => ( + +); + +// TweakColor — curated color/palette picker. Each option is either a single +// hex string or an array of 1-5 hex strings; the card adapts — a lone color +// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the +// rest stacked in a sharp column on the right. onChange emits the +// option in the shape it was passed (string stays string, array stays array). +// Without options it falls back to the native color input for back-compat. +function TweakColor({ label, value, options, onChange }) { + if (!options || !options.length) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); + } + // Native emits lowercase hex per the HTML spec, so + // compare case-insensitively. String() guards JSON.stringify(undefined), + // which returns the primitive undefined (no .toLowerCase). + const key = (o) => String(JSON.stringify(o)).toLowerCase(); + const cur = key(value); + return ( + +
+ {options.map((o, i) => { + const colors = Array.isArray(o) ? o : [o]; + const [hero, ...rest] = colors; + const sup = rest.slice(0, 4); + const on = key(o) === cur; + return ( + + ); + })} +
+
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/design-system/ui_kits/watch_app/README.md b/design-system/ui_kits/watch_app/README.md new file mode 100644 index 0000000..c356cfb --- /dev/null +++ b/design-system/ui_kits/watch_app/README.md @@ -0,0 +1,17 @@ +# BetterFit Apple Watch — UI kit + +Three screens, recreated from `Apps/iOS/BetterFitWatchApp/`: + +| Screen | Source | +| --- | --- | +| `WatchList` — today's session + workout list | `WorkoutListView.swift`, `ContentView.swift` | +| `WatchActive` — elapsed timer, current set, log/pause/end | `ActiveWorkoutView.swift` | +| `WatchSummary` — completion stats and recovery change | `ActiveWorkoutView.swift` (CompleteWorkoutView) | + +Watch rules that differ from iPhone: + +- Controls are full width and at least 44px tall — `WatchButton`, not the phone `Button`. +- Screen is 198×242 CSS px here, standing in for the 45mm 396×484 point display at 2×. +- Palette is the same two-colour system as the phone, with the slightly stronger 10%-white border `WatchTheme.swift` uses. +- Timer and load values dominate; labels shrink to 9–11px because they are glanceable, not readable copy. +- One yellow identity block per session (today's card); everything else is dark surface. diff --git a/design-system/ui_kits/watch_app/WatchScreens.jsx b/design-system/ui_kits/watch_app/WatchScreens.jsx new file mode 100644 index 0000000..4b79304 --- /dev/null +++ b/design-system/ui_kits/watch_app/WatchScreens.jsx @@ -0,0 +1,112 @@ +const { Icon, ProgressRing, RecoveryDot } = window.BetterFitDesignSystem_6a70ea; + +const SESSION_ICON = (name) => ( + /pull/i.test(name) ? 'arrow-fat-line-down' + : /push/i.test(name) ? 'arrows-in-line-horizontal' + : /leg/i.test(name) ? 'person-simple-run' + : /cond|cardio|interval/i.test(name) ? 'heartbeat' + : 'barbell' +); + +function WatchList({ onStart }) { + const d = window.BFData; + return ( + <> + + +
+
+ +
Today
+
+
{d.today.name}
+
{d.today.exercises} exercises · {d.today.duration}
+ +
+ {['Push A', 'Legs A', 'Conditioning'].map((n) => ( +
+ + {n} + +
+ ))} +
+ + ); +} + +function WatchActive({ onFinish }) { + const d = window.BFData; + const [set, setSet] = React.useState(1); + const [paused, setPaused] = React.useState(false); + return ( + <> + + +
+
+ + {paused ? 'Paused' : 'Elapsed'} +
+
18:42
+
+
+
+ + {d.plan[0].name} +
+
245 lb
+
Set {set} of 4 · 5 reps
+
+ setSet((s) => Math.min(4, s + 1))}>Log set + setPaused((p) => !p)}>{paused ? 'Resume' : 'Pause'} + End workout +
+ + ); +} + +function WatchSummary({ onDone }) { + const d = window.BFData; + return ( + <> + + +
+ + + +
+
+
Good work
+
+ + {d.today.name} · 42 min +
+
+
+ {[['14.2k', 'lb lifted', 'scales'], ['18', 'sets', 'stack-simple'], ['13', 'day streak', 'flame'], ['412', 'kcal', 'fire-simple']].map(([v, l, ic]) => ( +
+
+ +
{v}
+
+
{l}
+
+ ))} +
+
+ Back is now fatigued +
+ Done +
+ + ); +} + +Object.assign(window, { WatchList, WatchActive, WatchSummary }); diff --git a/design-system/ui_kits/watch_app/WatchShell.jsx b/design-system/ui_kits/watch_app/WatchShell.jsx new file mode 100644 index 0000000..026a209 --- /dev/null +++ b/design-system/ui_kits/watch_app/WatchShell.jsx @@ -0,0 +1,47 @@ +const { Icon } = window.BetterFitDesignSystem_6a70ea; + +/** 45mm Apple Watch screen: 396×484 logical points, 44px corner radius. */ +function Watch({ children }) { + return ( +
+
{children}
+
+ ); +} + +function WatchBar({ title }) { + return ( +
+ {title} + 9:41 +
+ ); +} + +function WatchScroll({ children }) { + return
{children}
; +} + +/** Full-width watch control — larger and simpler than its phone equivalent. */ +function WatchButton({ children, icon, tone = 'accent', onClick }) { + const tones = { + accent: { background: 'var(--accent)', color: 'var(--text-on-accent)', border: '1px solid transparent' }, + surface: { background: 'var(--surface-raised)', color: 'var(--text-primary)', border: '1px solid var(--border)' }, + success: { background: 'var(--yellow-bright)', color: 'var(--bf-black)', border: '1px solid transparent' }, + danger: { background: 'transparent', color: 'var(--text-primary)', border: '1px solid var(--border-strong)' } + }; + return ( + + ); +} + +Object.assign(window, { Watch, WatchBar, WatchScroll, WatchButton }); diff --git a/design-system/ui_kits/watch_app/index.html b/design-system/ui_kits/watch_app/index.html new file mode 100644 index 0000000..5cd9881 --- /dev/null +++ b/design-system/ui_kits/watch_app/index.html @@ -0,0 +1,25 @@ + +BetterFit — Apple Watch app + + + + + + + + + +
+ + + diff --git a/design.md b/design.md new file mode 100644 index 0000000..2057751 --- /dev/null +++ b/design.md @@ -0,0 +1,256 @@ +# BetterFit Design + +BetterFit is a personal strength training coach for iPhone and Apple Watch. It helps people decide what to train, move through a workout with less friction, understand recovery, and keep building momentum. + +The brand should feel strong, direct, and energising without becoming aggressive. BetterFit is a capable training partner, not a drill sergeant, lifestyle influencer, or clinical dashboard. + +## Brand Idea + +**Train with direction. Get better with every session.** + +BetterFit turns workout history, performance, and recovery into a clear next action. The product should make training feel more understandable and achievable while still respecting the effort involved. + +The name carries the promise: not perfect, extreme, or finished. Better. + +## Brand Character + +BetterFit is: + +- **Strong**: bold type, high contrast, decisive actions. +- **Useful**: information appears when it helps someone train, not to decorate a screen. +- **Adaptive**: recommendations respond to performance, equipment, plans, and recovery. +- **Encouraging**: progress is acknowledged without guilt, hype, or empty praise. +- **Human**: language stays plain and practical. Data supports the person rather than defining them. +- **Focused**: one clear primary action should lead each state. + +BetterFit is not: + +- Militaristic, macho, or intimidating +- Sterile medical software +- A neon cyberpunk fitness game +- A dense analytics terminal +- A soft wellness or mindfulness brand +- A social feed built around comparison + +## Audience + +BetterFit is for people who want to become stronger but do not want to spend their training time programming workouts or interpreting raw data. It should work for a new lifter who needs confidence and an experienced lifter who values speed, control, and useful adaptation. + +Design for someone who may be tired, moving, wearing headphones, or glancing at a watch between sets. The interface must remain legible and operable under those conditions. + +## Voice + +Write like a knowledgeable training partner standing nearby. + +- Use short, active sentences. +- Lead with the action or useful fact. +- Prefer everyday training language over technical or AI language. +- Explain recommendations when the reason improves trust. +- Celebrate completed work, consistency, and progress without exaggeration. +- Never shame missed sessions, low performance, body shape, or recovery needs. +- Avoid slogans about pain, punishment, domination, or earning food. +- Avoid calling ordinary automation "magic" or repeatedly advertising that it is AI. + +Prefer: + +- "Start workout" +- "Swap exercise" +- "Chest is still recovering. Train back today." +- "Two workouts this week. One more reaches your goal." +- "Ready for your next set?" + +Avoid: + +- "Crush your limits!" +- "No excuses" +- "AI-powered gains, unlocked" +- "You failed to meet your goal" +- "Summer body loading" + +## Visual Identity + +The identity is built from bold black forms on electric yellow. The mark combines three compressed oval forms, which suggest plates on a barbell, repetition, and forward momentum, with a heavy geometric letterform. The wordmark is wide, compact, and unapologetically strong. + +Use the supplied logo artwork. Do not redraw it with text, alter its proportions, add effects, or place other elements inside its clear space. + +BetterFit is a two-colour brand: yellow and black, with white as the reversed ink. There is no secondary brand hue. Do not introduce an additional accent colour — orange, teal, purple, or otherwise — for actions, illustrations, charts, or marketing. Yellow is the accent everywhere. + +### Identity Palette + +| Role | Value | Use | +| --- | --- | --- | +| BetterFit yellow | `#FFD60A` | Primary identity field, primary action, high-impact highlights | +| Black | `#000000` | Mark, display type, ink on yellow, strong contrast | +| White | `#FFFFFF` | Reversed marks and type on dark fields | + +Large yellow fields always carry black content. Yellow is used with intent: one dominant yellow element per screen, not a yellow wash across every surface. + +### Product Palette + +The training interface is dark-first, calm, and functional. Yellow carries the primary action and the single most important value on a screen; everything else is neutral so that yellow always means "this is what matters". + +| Role | Dark | Light | +| --- | --- | --- | +| Page background | `#0B0B0D` | `#F6F6F8` | +| Elevated background | `#131316` | `#FFFFFF` | +| Surface | `#17171B` | `#FFFFFF` | +| Raised surface | `#1F1F25` | `#EFEFF3` | +| Primary text | `#FFFFFF` | `#131317` | +| Secondary text | `#9C9CA6` | approximately `#61616B` | +| Tertiary text | `#63636D` | approximately `#94949E` | +| Primary action | `#FFD60A` | `#FFD60A` | +| Action ink | `#000000` | `#000000` | +| Action tint (subtle) | `#FFD60A` at 15% | `#FFD60A` at 15% | +| Yellow ladder — bright (recovered, success) | `#FFE860` | `#FFE860` | +| Yellow ladder — base (fresh, warning) | `#FFD60A` | `#FFD60A` | +| Yellow ladder — deep (fatigued, information) | `#C9A800` | `#C9A800` | +| Yellow ladder — dim (sore) | `#7A6600` | `#7A6600` | + +Use semantic colors consistently. Status and recovery ride the yellow ladder plus a word or label, never a competing hue: brightness ramps up as readiness improves — dim for sore, deep for fatigued, base for fresh, bright for recovered. Success wears the bright step, information the deep step, and danger is expressed by inverting the control rather than adding red. On light backgrounds the ramp shifts to deeper steps so yellow stays legible. Never rely on color alone to communicate status. + +**Yellow versus amber.** Brand yellow and warning amber sit close together, so they must never compete. Yellow appears as a solid fill on interactive elements and on the hero value of a screen. Amber only ever appears as a small indicator — a dot, a bar segment, an icon, a text colour — and never as a filled button or a large field. If a warning needs emphasis, add an icon and a label rather than more colour. + +User-selectable themes may change the background atmosphere and the density of yellow, but they must preserve yellow as the accent, the same hierarchy, semantic colors, contrast, and component behaviour. + +## Typography + +Typography should feel sturdy and immediate. + +- Use BBH Hegarty for branded display headings when licensed assets are available. +- Fall back to a heavy rounded system face for headings. +- Use the native system typeface for interface copy and controls. +- Use bold, high-contrast headings and sentence case for most labels. +- Use monospaced digits for timers, weights, reps, percentages, and changing statistics. +- Use uppercase labels sparingly for compact section markers, with generous tracking. +- Support Dynamic Type. Do not fix layouts around one text size. + +The core scale is 34, 28, 22, and 20 points for titles; 17 points for body and primary controls; 15 points for supporting copy; and 12 to 13 points for captions. Workout-critical values may be larger, especially on Apple Watch. + +## Layout And Shape + +- Build on a 4-point spacing rhythm, using 8, 12, 16, 20, 24, and 32 points most often. +- Use 20 points of standard horizontal page padding on iPhone. +- Keep layouts vertically rhythmic, left aligned, and easy to scan. +- Use continuous rounded corners: 16 points for cards, 14 for buttons, 12 for controls, and full capsules only for compact chips. +- Prefer flat, solid surfaces with a subtle one-pixel border for content. +- Avoid glossy gradients, thick shadows, and floating cards without hierarchy. +- Give data room to breathe. Do not fill every space with a metric. +- Use SF Symbols for familiar interface and fitness concepts. Keep icon weights semibold and visually consistent. +- Lay out screens for BetterFit's own hierarchy. Do not reproduce another training app's screen structure, card order, or navigation model. + +### Floating Chrome And Translucency + +Chrome floats; content does not. Navigation and the primary action ride over the scrolling page in translucent, blurred containers so the page reads as one continuous surface underneath. + +- The tab bar is a floating capsule inset from the screen edges, not a bar welded to the bottom of the layout. +- Translucency is **only** for chrome that overlays scrolling content: the nav, the action dock, and revealed swipe actions. Never for cards, list rows, sheets' content, or any surface that holds data. +- Content surfaces stay flat and opaque. A blurred card is a bug. +- Keep text on glass at full-strength primary or secondary colour and verify AA against the lightest content that can pass beneath it. If a label cannot hold contrast, thicken the glass rather than dimming the type. +- Glass carries a hairline border and a soft shadow so its edge is legible over both dark and light content. + +### Reach + +Assume one hand, in a gym, mid-session. + +- The primary action lives in a floating dock at the bottom of the screen, inside thumb reach — never at the top, never only in a header. +- The dock may carry one primary action plus at most two square icon companions. +- Headers are for orientation — what session this is, what it contains. They do not hold the action. +- Frequent, low-risk operations get a horizontal quick-action strip directly under the header: swap, edit, repeat, trim, change equipment. +- Row-level operations belong on swipe. Swiping a list row reveals actions on both edges: destructive and replace on the trailing edge, reordering and grouping on the leading edge. Every swipe action must also be reachable without swiping, for VoiceOver and Switch Control. +- Anything a person does often — starting a suggested session, restarting a frequent one — should be one tap from the screen they land on. + +## Components + +### Actions + +Use one full-width yellow button with black text for the primary action, docked at the bottom of the screen in thumb reach. Secondary actions use a raised neutral surface with a hairline border. Tertiary actions use yellow text without a container. Destructive confirmation inverts the control and must be explicit. + +At most one yellow fill is visible at a time. On screens that lead with a full yellow field, the field **is** the yellow — the docked action is neutral glass. On screens with a neutral header, the docked action carries the yellow. Never both. + +Primary buttons are 54 points high. All controls need at least a 44 by 44 point hit target. On Apple Watch, controls should be larger and full width whenever practical. + +### Cards + +Cards group related decisions or information. They use a solid surface, a subtle border, 16-point corners, and 16-point internal padding. A card should not exist only to decorate content that could sit directly on the page. + +### Data + +Make the conclusion more prominent than the chart. Show the current value, a short label, and the next useful interpretation. Charts should use restrained axes and semantic color, with yellow reserved for the series the person is actually being asked to act on. Timers and changing values use monospaced digits so they do not jitter. + +### Empty And Loading States + +Empty states should explain what will appear and give a useful next step. Loading should preserve layout where possible. Errors should say what happened in plain language and offer recovery rather than a dead end. + +## Motion And Feedback + +Motion should communicate state and momentum. + +- Use short, responsive transitions and native spring or snappy timing. +- Give pressed controls a subtle opacity or scale response. +- Animate progress changes, completed sets, and transitions to the next exercise. +- Use haptics for consequential workout actions when supported. +- Avoid ambient looping animation, bouncing icons, confetti for routine actions, and effects that compete with workout data. +- Respect Reduce Motion and other system accessibility settings. + +## Imagery + +Prefer real, purposeful training imagery with diverse bodies, ages, and experience levels. Show believable effort, correct equipment use, and environments where people actually train. Crop closely enough to feel active, but leave clear space for type. + +Avoid hyper-muscular stereotypes, transformation imagery, body comparison, fake holographic data, generic stock smiles, and imagery that suggests pain is the goal. + +Illustration, when needed, should use heavy geometric forms, simplified anatomy, and the same high-contrast confidence as the mark, drawn in yellow, black, and white only. + +## Accessibility + +- Meet WCAG AA contrast at minimum. +- Always pair yellow with black ink, never with white. White on `#FFD60A` fails AA. +- Support Dynamic Type, VoiceOver, Reduce Motion, increased contrast, and light and dark appearances. +- Give controls descriptive labels and meaningful values. +- Do not encode recovery, completion, or errors through color alone. +- Keep workout-critical information visible at a glance and operable with one hand. +- Reaching the primary action must never require moving your hand up the screen. +- Provide a non-swipe route to every swipe action. +- Respect Reduce Transparency: fall back to an opaque chrome fill with the same border and shadow. +- Test the largest accessibility text sizes and the smallest supported Apple Watch display. + +## Product Design Test + +Before shipping a screen, ask: + +1. Is the next action obvious within a glance? +2. Does every metric help the person decide or understand something? +3. Can this be used between sets with one hand? +4. Does it feel strong without becoming aggressive? +5. Does it encourage progress without judgement? +6. Does it still work with large text, VoiceOver, and without color? +7. Is there exactly one thing on this screen wearing yellow? + +## Reusable Design Prompt + +```text +Design a [screen, flow, campaign, or asset] for BetterFit, a personal strength training coach for iPhone and Apple Watch. BetterFit helps people decide what to train, complete workouts with less friction, understand recovery, and make steady progress. + +The brand is strong, direct, useful, adaptive, encouraging, and human. It should feel like a knowledgeable training partner, not a drill sergeant, influencer, clinical dashboard, neon fitness game, or soft wellness app. Make the next action obvious and keep information practical enough to use while moving or between sets. + +BetterFit is a two-colour brand: electric yellow #FFD60A and black, with white as reversed ink. Do not introduce any additional accent hue. Use heavy geometric typography and compressed forms inspired by weight plates and repetition. For product UI, use a calm dark-first system with #0B0B0D backgrounds, #17171B surfaces, subtle hairline borders, white primary text, and muted gray secondary text, with yellow #FFD60A carrying the single primary action and always paired with black ink. Use the yellow ladder for status and recovery — bright #FFE860, base #FFD60A, deep #C9A800, dim #7A6600 — where brightness ramps up as readiness improves, always paired with a word or label; express danger by inverting the control rather than adding a red hue. Amber stays a small indicator only — a dot, a bar segment, an icon — and never a fill. + +Use bold headings, native system body text, and monospaced digits for weights, reps, timers, percentages, and stats. Build on a 4-point spacing rhythm with 20-point page margins, flat solid cards, continuous 16-point corners, 54-point primary buttons, and 44-point minimum tap targets. Float the navigation and the primary action as translucent blurred chrome over the scrolling page, and keep the primary action in thumb reach at the bottom; keep content surfaces flat and opaque. Put frequent operations in a quick-action strip and row operations on swipe. Prefer native navigation and controls. Use restrained SF Symbols and short, responsive motion. Invent BetterFit's own screen hierarchy rather than restating another fitness app's layout. Avoid glossy gradients, excessive shadows, dashboard clutter, generic AI visuals, motivational cliches, shame, and macho gym imagery. + +Write short, active copy in plain training language. Explain recommendations when it builds trust. Celebrate effort and consistency without hype. The result must support light and dark appearance, Dynamic Type, VoiceOver, Reduce Motion, one-handed use, and WCAG AA contrast. + +Create [required deliverable and states]. Include [specific content, constraints, or platform details]. +``` + +## Implementation Reference + +The current source of truth for product tokens and primitives lives in: + +- `Apps/iOS/BetterFitApp/DesignSystem/BFColors.swift` +- `Apps/iOS/BetterFitApp/DesignSystem/BFTypography.swift` +- `Apps/iOS/BetterFitApp/DesignSystem/BFSpacing.swift` +- `Apps/iOS/BetterFitApp/DesignSystem/BFComponents.swift` +- `Apps/iOS/BetterFitApp/DesignSystem/BFButtonStyles.swift` +- `Apps/iOS/BetterFitApp/AppTheme.swift` +- `Apps/iOS/BetterFitApp/Assets.xcassets/` + +`BFColors.brandAccent` (#FF5A3C) is deprecated and must not be used. Point accent usage at the identity yellow token and its black ink pairing. When this document and shipped tokens disagree, resolve the difference intentionally. Do not create a third visual language at the call site.