From ec0dc6e8281ba9ef31cfd2bac6dca97915e2b06b Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:14:01 +1000 Subject: [PATCH 1/6] feat(ios): sync design system and app screens to code Bring the iOS app in line with the BetterFit design system established in Figma, and ship the reference material alongside it. Design tokens - Replace the ember-orange / multi-accent palette with the two-colour identity: electric yellow (#FFD60A) as the only chromatic accent, with black ink and neutral structure. - Add a yellow ladder (bright/deep/dim) so status and recovery ride brightness plus words instead of competing status hues. - Add glass tokens for floating chrome only, plus borderStrong and accentText (deep yellow on light fields for AA). - Typography: BBH Hegarty display ramp, Inter UI ramp, JetBrains Mono tabular numerals. Ledger layout - Add BFLedger primitives (eyebrow, section rule, spec strip, slab, readout, bar, ledger row, quick actions) used across the rebuilt screens. Screens - WorkoutHome: Plan/ledger layout with plan exercise rows, inline weight/reps editing, superset pairing, and equipment swapping. - ActiveSession: live session with rest bar, quick actions, set logging, session ordering, and supersets. - Profile, Recovery, RootTab, SignIn, Settings, plus new Log and Targets tabs and a workout summary. Competitor-informed patterns (from Fitbod, Strong, Hevy, Freeletics) - Exercise thumbnails and completion state in the work list - Rest surfaced as an action bar rather than a separate screen - Single hero on home rather than a feed Reference material - design.md: the two-colour discipline and its rationale - design-system/: tokens, core/list/data/feedback/workout components, guidelines, and iOS/watchOS UI kits Code organisation - Split oversized views into extension files per AGENTS.md: WorkoutHomeView (+Sections/+Helpers), ActiveSessionView (+Sections/+Actions), ExerciseDetailSheet (+Sections). - Rename single-char identifiers and replace large tuples with named structs. All SwiftLint serious violations introduced by this work are cleared. Verified: swift build, 95/95 unit tests, ios:build:ci. Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> --- .../betterfit-code-organization/SKILL.md | 201 + .../skills/betterfit-mise-workflows/SKILL.md | 139 + .../skills/betterfit-supabase-setup/SKILL.md | 104 + .../betterfit-swift-file-splitting/SKILL.md | 188 + .../betterfit-swiftui-reactivity/SKILL.md | 171 + .claude/skills/betterfit-testing/SKILL.md | 231 + .claude/skills/betterfit-wide-events/SKILL.md | 213 + .claude/skills/remotion-app-showcase/SKILL.md | 387 + Apps/iOS/BetterFitApp/AppTheme.swift | 268 +- .../IconCombo.imageset/icon-combo.png | Bin 0 -> 6039 bytes .../icon-lettermark.png | Bin 0 -> 4450 bytes .../Assets.xcassets/Logo.imageset/Logo.png | Bin 8955 -> 31590 bytes .../Assets.xcassets/Logo.imageset/Logo@2x.png | Bin 19226 -> 31590 bytes .../LogoStacked.imageset/logo-stacked.png | Bin 0 -> 10354 bytes .../logo-stacked-dark.png | Bin 0 -> 10960 bytes .../Assets.xcassets/app-icon-1024.png | Bin 0 -> 19163 bytes .../Components/AdjustSetsSheet.swift | 190 +- .../ExerciseDetailSheet+Sections.swift | 103 + .../Components/ExerciseDetailSheet.swift | 568 +- .../DesignSystem/BFButtonStyles.swift | 32 +- .../BetterFitApp/DesignSystem/BFColors.swift | 140 +- .../DesignSystem/BFComponents.swift | 16 +- .../DesignSystem/BFIllustrations.swift | 2 +- .../BetterFitApp/DesignSystem/BFLedger.swift | 544 ++ .../DesignSystem/BFRecovery.swift | 31 +- .../DesignSystem/BFTypography.swift | 91 +- .../ActiveSessionView+Actions.swift | 612 ++ .../ActiveSessionView+Sections.swift | 658 ++ .../ActiveWorkout/ActiveSessionView.swift | 629 +- .../ActiveWorkout/WorkoutSummaryView.swift | 236 + .../Features/Auth/SignInView.swift | 22 +- .../BetterFitApp/Features/Log/LogView.swift | 316 + .../Features/Profile/ProfileView.swift | 1794 +--- .../Features/Profile/SettingsView.swift | 10 +- .../Features/Recovery/RecoveryView.swift | 234 +- .../Features/RootTab/RootTabView.swift | 322 +- .../Features/Targets/TargetsView.swift | 223 + .../Features/WorkoutHome/SupersetStore.swift | 44 + .../WorkoutHome/WorkoutHomeComponents.swift | 1191 +-- .../WorkoutHome/WorkoutHomeView+Helpers.swift | 1207 +-- .../WorkoutHomeView+Sections.swift | 1355 +-- .../WorkoutHome/WorkoutHomeView.swift | 394 +- .../WorkoutHome/WorkoutPreviewScreen.swift | 180 + .../BetterFitApp/Models/WorkoutModels.swift | 91 +- .../Models/WorkoutPlanModels.swift | 67 +- design-system/SKILL.md | 9 + design-system/_adherence.oxlintrc.json | 570 ++ design-system/_ds_bundle.js | 8513 +++++++++++++++++ design-system/_ds_manifest.json | 1 + design-system/assets/app-icon-1024.png | Bin 0 -> 19163 bytes .../assets/fonts/BBHHegarty-Regular.ttf | Bin 0 -> 28752 bytes design-system/assets/fonts/OFL.txt | 93 + design-system/assets/icon-combo.png | Bin 0 -> 6039 bytes design-system/assets/icon-lettermark.png | Bin 0 -> 4450 bytes design-system/assets/logo-stacked-dark.png | Bin 0 -> 10960 bytes design-system/assets/logo-stacked.png | Bin 0 -> 10354 bytes design-system/assets/logo.png | Bin 0 -> 31590 bytes design-system/components/core/Button.d.ts | 18 + design-system/components/core/Button.jsx | 55 + .../components/core/Button.prompt.md | 11 + design-system/components/core/Card.d.ts | 12 + design-system/components/core/Card.jsx | 16 + design-system/components/core/Card.prompt.md | 10 + design-system/components/core/Chip.d.ts | 10 + design-system/components/core/Chip.jsx | 24 + design-system/components/core/Chip.prompt.md | 8 + design-system/components/core/Divider.d.ts | 7 + design-system/components/core/Divider.jsx | 6 + .../components/core/Divider.prompt.md | 8 + .../components/core/DropdownChip.d.ts | 10 + .../components/core/DropdownChip.jsx | 25 + .../components/core/DropdownChip.prompt.md | 8 + design-system/components/core/Icon.d.ts | 18 + design-system/components/core/Icon.jsx | 32 + design-system/components/core/Icon.prompt.md | 14 + design-system/components/core/IconButton.d.ts | 13 + design-system/components/core/IconButton.jsx | 27 + .../components/core/IconButton.prompt.md | 8 + design-system/components/core/Logo.d.ts | 17 + design-system/components/core/Logo.jsx | 22 + design-system/components/core/Logo.prompt.md | 9 + design-system/components/core/MetricPill.d.ts | 11 + design-system/components/core/MetricPill.jsx | 21 + .../components/core/MetricPill.prompt.md | 8 + design-system/components/core/PhotoSlot.d.ts | 14 + design-system/components/core/PhotoSlot.jsx | 27 + .../components/core/PhotoSlot.prompt.md | 8 + .../components/core/SectionHeader.d.ts | 9 + .../components/core/SectionHeader.jsx | 14 + .../components/core/SectionHeader.prompt.md | 7 + design-system/components/core/core.card.html | 46 + .../components/data/ContributionHeatmap.d.ts | 12 + .../components/data/ContributionHeatmap.jsx | 25 + .../data/ContributionHeatmap.prompt.md | 7 + design-system/components/data/Gauge.d.ts | 15 + design-system/components/data/Gauge.jsx | 25 + design-system/components/data/Gauge.prompt.md | 7 + design-system/components/data/LegendDot.d.ts | 9 + design-system/components/data/LegendDot.jsx | 12 + .../components/data/LegendDot.prompt.md | 8 + design-system/components/data/MuscleChip.d.ts | 10 + design-system/components/data/MuscleChip.jsx | 17 + .../components/data/MuscleChip.prompt.md | 10 + .../components/data/OverviewStat.d.ts | 9 + .../components/data/OverviewStat.jsx | 15 + .../components/data/OverviewStat.prompt.md | 10 + .../components/data/ProgressRing.d.ts | 14 + .../components/data/ProgressRing.jsx | 20 + .../components/data/ProgressRing.prompt.md | 9 + design-system/components/data/StatTile.d.ts | 11 + design-system/components/data/StatTile.jsx | 14 + .../components/data/StatTile.prompt.md | 9 + design-system/components/data/data.card.html | 44 + design-system/components/feedback/Banner.d.ts | 14 + design-system/components/feedback/Banner.jsx | 33 + .../components/feedback/Banner.prompt.md | 9 + .../components/feedback/EmptyState.d.ts | 12 + .../components/feedback/EmptyState.jsx | 14 + .../components/feedback/EmptyState.prompt.md | 9 + .../components/feedback/RecoveryBadge.d.ts | 8 + .../components/feedback/RecoveryBadge.jsx | 20 + .../feedback/RecoveryBadge.prompt.md | 7 + .../components/feedback/RecoveryDot.d.ts | 11 + .../components/feedback/RecoveryDot.jsx | 13 + .../components/feedback/RecoveryDot.prompt.md | 7 + .../components/feedback/RestTimerBar.d.ts | 11 + .../components/feedback/RestTimerBar.jsx | 25 + .../feedback/RestTimerBar.prompt.md | 7 + .../components/feedback/feedback.card.html | 32 + .../components/lists/ChevronRow.d.ts | 12 + design-system/components/lists/ChevronRow.jsx | 19 + .../components/lists/ChevronRow.prompt.md | 5 + .../components/lists/ExerciseRow.d.ts | 16 + .../components/lists/ExerciseRow.jsx | 34 + .../components/lists/ExerciseRow.prompt.md | 9 + design-system/components/lists/ListRow.d.ts | 14 + design-system/components/lists/ListRow.jsx | 32 + .../components/lists/ListRow.prompt.md | 7 + .../components/lists/SettingsRow.d.ts | 16 + .../components/lists/SettingsRow.jsx | 44 + .../components/lists/SettingsRow.prompt.md | 10 + .../components/lists/lists.card.html | 43 + .../components/workout/SetLogField.d.ts | 11 + .../components/workout/SetLogField.jsx | 30 + .../components/workout/SetLogField.prompt.md | 10 + .../components/workout/SupersetIndicator.d.ts | 9 + .../components/workout/SupersetIndicator.jsx | 17 + .../workout/SupersetIndicator.prompt.md | 7 + .../components/workout/WeightUnitToggle.d.ts | 10 + .../components/workout/WeightUnitToggle.jsx | 24 + .../workout/WeightUnitToggle.prompt.md | 6 + .../components/workout/WorkoutCard.d.ts | 21 + .../components/workout/WorkoutCard.jsx | 59 + .../components/workout/WorkoutCard.prompt.md | 8 + .../components/workout/workout.card.html | 32 + design-system/design.md | 256 + design-system/github.md | 48 + design-system/guidelines/brand-app-marks.html | 2 + .../guidelines/brand-iconography.html | 7 + .../guidelines/brand-identity-palette.html | 6 + .../guidelines/brand-primary-lockup.html | 2 + design-system/guidelines/brand-stacked.html | 2 + design-system/guidelines/color-action.html | 8 + design-system/guidelines/color-glass.html | 14 + .../guidelines/color-heatmap-ramp.html | 5 + design-system/guidelines/color-recovery.html | 10 + design-system/guidelines/color-schemes.html | 6 + design-system/guidelines/color-status.html | 10 + .../guidelines/color-surfaces-dark.html | 8 + .../guidelines/color-surfaces-light.html | 8 + design-system/guidelines/color-text.html | 8 + design-system/guidelines/motion-press.html | 7 + design-system/guidelines/motion-tokens.html | 9 + design-system/guidelines/space-controls.html | 6 + design-system/guidelines/space-page.html | 7 + design-system/guidelines/space-radii.html | 2 + design-system/guidelines/space-scale.html | 2 + design-system/guidelines/type-body.html | 8 + design-system/guidelines/type-display.html | 7 + design-system/guidelines/type-labels.html | 6 + design-system/guidelines/type-numeric.html | 7 + design-system/readme.md | 303 + design-system/styles.css | 8 + design-system/templates/app-screen/.thumbnail | Bin 0 -> 8178 bytes .../templates/app-screen/AppScreen.dc.html | 91 + design-system/templates/app-screen/ds-base.js | 15 + design-system/templates/app-screen/support.js | 1911 ++++ design-system/thumbnail.html | 1 + design-system/tokens/base.css | 9 + design-system/tokens/colors.css | 116 + design-system/tokens/elevation.css | 15 + design-system/tokens/fonts.css | 8 + design-system/tokens/motion.css | 15 + design-system/tokens/radii.css | 10 + design-system/tokens/spacing.css | 20 + design-system/tokens/typography.css | 38 + .../ui_kits/ios_app/AddExerciseSheet.jsx | 175 + design-system/ui_kits/ios_app/AppShell.jsx | 487 + design-system/ui_kits/ios_app/BodyScreen.jsx | 73 + design-system/ui_kits/ios_app/LogScreen.jsx | 120 + .../ui_kits/ios_app/MyPlanScreen.jsx | 150 + .../ui_kits/ios_app/ProfileScreen.jsx | 87 + design-system/ui_kits/ios_app/README.md | 68 + .../ui_kits/ios_app/SearchScreen.jsx | 45 + .../ui_kits/ios_app/SessionScreen.jsx | 255 + .../ui_kits/ios_app/SettingsScreen.jsx | 66 + .../ui_kits/ios_app/SignInScreen.jsx | 28 + .../ui_kits/ios_app/SummaryScreen.jsx | 85 + .../ui_kits/ios_app/TargetsScreen.jsx | 81 + design-system/ui_kits/ios_app/data.js | 63 + design-system/ui_kits/ios_app/image-slot.js | 1225 +++ design-system/ui_kits/ios_app/index.html | 93 + .../ui_kits/ios_app/tweaks-panel.jsx | 553 ++ design-system/ui_kits/watch_app/README.md | 17 + .../ui_kits/watch_app/WatchScreens.jsx | 112 + .../ui_kits/watch_app/WatchShell.jsx | 47 + design-system/ui_kits/watch_app/index.html | 25 + design.md | 256 + 218 files changed, 24711 insertions(+), 6413 deletions(-) create mode 100644 .claude/skills/betterfit-code-organization/SKILL.md create mode 100644 .claude/skills/betterfit-mise-workflows/SKILL.md create mode 100644 .claude/skills/betterfit-supabase-setup/SKILL.md create mode 100644 .claude/skills/betterfit-swift-file-splitting/SKILL.md create mode 100644 .claude/skills/betterfit-swiftui-reactivity/SKILL.md create mode 100644 .claude/skills/betterfit-testing/SKILL.md create mode 100644 .claude/skills/betterfit-wide-events/SKILL.md create mode 100644 .claude/skills/remotion-app-showcase/SKILL.md create mode 100644 Apps/iOS/BetterFitApp/Assets.xcassets/IconCombo.imageset/icon-combo.png create mode 100644 Apps/iOS/BetterFitApp/Assets.xcassets/IconLettermark.imageset/icon-lettermark.png create mode 100644 Apps/iOS/BetterFitApp/Assets.xcassets/LogoStacked.imageset/logo-stacked.png create mode 100644 Apps/iOS/BetterFitApp/Assets.xcassets/LogoStackedDark.imageset/logo-stacked-dark.png create mode 100644 Apps/iOS/BetterFitApp/Assets.xcassets/app-icon-1024.png create mode 100644 Apps/iOS/BetterFitApp/Components/ExerciseDetailSheet+Sections.swift create mode 100644 Apps/iOS/BetterFitApp/DesignSystem/BFLedger.swift create mode 100644 Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Actions.swift create mode 100644 Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sections.swift create mode 100644 Apps/iOS/BetterFitApp/Features/ActiveWorkout/WorkoutSummaryView.swift create mode 100644 Apps/iOS/BetterFitApp/Features/Log/LogView.swift create mode 100644 Apps/iOS/BetterFitApp/Features/Targets/TargetsView.swift create mode 100644 Apps/iOS/BetterFitApp/Features/WorkoutHome/SupersetStore.swift create mode 100644 Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutPreviewScreen.swift create mode 100644 design-system/SKILL.md create mode 100644 design-system/_adherence.oxlintrc.json create mode 100644 design-system/_ds_bundle.js create mode 100644 design-system/_ds_manifest.json create mode 100644 design-system/assets/app-icon-1024.png create mode 100644 design-system/assets/fonts/BBHHegarty-Regular.ttf create mode 100644 design-system/assets/fonts/OFL.txt create mode 100644 design-system/assets/icon-combo.png create mode 100644 design-system/assets/icon-lettermark.png create mode 100644 design-system/assets/logo-stacked-dark.png create mode 100644 design-system/assets/logo-stacked.png create mode 100644 design-system/assets/logo.png create mode 100644 design-system/components/core/Button.d.ts create mode 100644 design-system/components/core/Button.jsx create mode 100644 design-system/components/core/Button.prompt.md create mode 100644 design-system/components/core/Card.d.ts create mode 100644 design-system/components/core/Card.jsx create mode 100644 design-system/components/core/Card.prompt.md create mode 100644 design-system/components/core/Chip.d.ts create mode 100644 design-system/components/core/Chip.jsx create mode 100644 design-system/components/core/Chip.prompt.md create mode 100644 design-system/components/core/Divider.d.ts create mode 100644 design-system/components/core/Divider.jsx create mode 100644 design-system/components/core/Divider.prompt.md create mode 100644 design-system/components/core/DropdownChip.d.ts create mode 100644 design-system/components/core/DropdownChip.jsx create mode 100644 design-system/components/core/DropdownChip.prompt.md create mode 100644 design-system/components/core/Icon.d.ts create mode 100644 design-system/components/core/Icon.jsx create mode 100644 design-system/components/core/Icon.prompt.md create mode 100644 design-system/components/core/IconButton.d.ts create mode 100644 design-system/components/core/IconButton.jsx create mode 100644 design-system/components/core/IconButton.prompt.md create mode 100644 design-system/components/core/Logo.d.ts create mode 100644 design-system/components/core/Logo.jsx create mode 100644 design-system/components/core/Logo.prompt.md create mode 100644 design-system/components/core/MetricPill.d.ts create mode 100644 design-system/components/core/MetricPill.jsx create mode 100644 design-system/components/core/MetricPill.prompt.md create mode 100644 design-system/components/core/PhotoSlot.d.ts create mode 100644 design-system/components/core/PhotoSlot.jsx create mode 100644 design-system/components/core/PhotoSlot.prompt.md create mode 100644 design-system/components/core/SectionHeader.d.ts create mode 100644 design-system/components/core/SectionHeader.jsx create mode 100644 design-system/components/core/SectionHeader.prompt.md create mode 100644 design-system/components/core/core.card.html create mode 100644 design-system/components/data/ContributionHeatmap.d.ts create mode 100644 design-system/components/data/ContributionHeatmap.jsx create mode 100644 design-system/components/data/ContributionHeatmap.prompt.md create mode 100644 design-system/components/data/Gauge.d.ts create mode 100644 design-system/components/data/Gauge.jsx create mode 100644 design-system/components/data/Gauge.prompt.md create mode 100644 design-system/components/data/LegendDot.d.ts create mode 100644 design-system/components/data/LegendDot.jsx create mode 100644 design-system/components/data/LegendDot.prompt.md create mode 100644 design-system/components/data/MuscleChip.d.ts create mode 100644 design-system/components/data/MuscleChip.jsx create mode 100644 design-system/components/data/MuscleChip.prompt.md create mode 100644 design-system/components/data/OverviewStat.d.ts create mode 100644 design-system/components/data/OverviewStat.jsx create mode 100644 design-system/components/data/OverviewStat.prompt.md create mode 100644 design-system/components/data/ProgressRing.d.ts create mode 100644 design-system/components/data/ProgressRing.jsx create mode 100644 design-system/components/data/ProgressRing.prompt.md create mode 100644 design-system/components/data/StatTile.d.ts create mode 100644 design-system/components/data/StatTile.jsx create mode 100644 design-system/components/data/StatTile.prompt.md create mode 100644 design-system/components/data/data.card.html create mode 100644 design-system/components/feedback/Banner.d.ts create mode 100644 design-system/components/feedback/Banner.jsx create mode 100644 design-system/components/feedback/Banner.prompt.md create mode 100644 design-system/components/feedback/EmptyState.d.ts create mode 100644 design-system/components/feedback/EmptyState.jsx create mode 100644 design-system/components/feedback/EmptyState.prompt.md create mode 100644 design-system/components/feedback/RecoveryBadge.d.ts create mode 100644 design-system/components/feedback/RecoveryBadge.jsx create mode 100644 design-system/components/feedback/RecoveryBadge.prompt.md create mode 100644 design-system/components/feedback/RecoveryDot.d.ts create mode 100644 design-system/components/feedback/RecoveryDot.jsx create mode 100644 design-system/components/feedback/RecoveryDot.prompt.md create mode 100644 design-system/components/feedback/RestTimerBar.d.ts create mode 100644 design-system/components/feedback/RestTimerBar.jsx create mode 100644 design-system/components/feedback/RestTimerBar.prompt.md create mode 100644 design-system/components/feedback/feedback.card.html create mode 100644 design-system/components/lists/ChevronRow.d.ts create mode 100644 design-system/components/lists/ChevronRow.jsx create mode 100644 design-system/components/lists/ChevronRow.prompt.md create mode 100644 design-system/components/lists/ExerciseRow.d.ts create mode 100644 design-system/components/lists/ExerciseRow.jsx create mode 100644 design-system/components/lists/ExerciseRow.prompt.md create mode 100644 design-system/components/lists/ListRow.d.ts create mode 100644 design-system/components/lists/ListRow.jsx create mode 100644 design-system/components/lists/ListRow.prompt.md create mode 100644 design-system/components/lists/SettingsRow.d.ts create mode 100644 design-system/components/lists/SettingsRow.jsx create mode 100644 design-system/components/lists/SettingsRow.prompt.md create mode 100644 design-system/components/lists/lists.card.html create mode 100644 design-system/components/workout/SetLogField.d.ts create mode 100644 design-system/components/workout/SetLogField.jsx create mode 100644 design-system/components/workout/SetLogField.prompt.md create mode 100644 design-system/components/workout/SupersetIndicator.d.ts create mode 100644 design-system/components/workout/SupersetIndicator.jsx create mode 100644 design-system/components/workout/SupersetIndicator.prompt.md create mode 100644 design-system/components/workout/WeightUnitToggle.d.ts create mode 100644 design-system/components/workout/WeightUnitToggle.jsx create mode 100644 design-system/components/workout/WeightUnitToggle.prompt.md create mode 100644 design-system/components/workout/WorkoutCard.d.ts create mode 100644 design-system/components/workout/WorkoutCard.jsx create mode 100644 design-system/components/workout/WorkoutCard.prompt.md create mode 100644 design-system/components/workout/workout.card.html create mode 100644 design-system/design.md create mode 100644 design-system/github.md create mode 100644 design-system/guidelines/brand-app-marks.html create mode 100644 design-system/guidelines/brand-iconography.html create mode 100644 design-system/guidelines/brand-identity-palette.html create mode 100644 design-system/guidelines/brand-primary-lockup.html create mode 100644 design-system/guidelines/brand-stacked.html create mode 100644 design-system/guidelines/color-action.html create mode 100644 design-system/guidelines/color-glass.html create mode 100644 design-system/guidelines/color-heatmap-ramp.html create mode 100644 design-system/guidelines/color-recovery.html create mode 100644 design-system/guidelines/color-schemes.html create mode 100644 design-system/guidelines/color-status.html create mode 100644 design-system/guidelines/color-surfaces-dark.html create mode 100644 design-system/guidelines/color-surfaces-light.html create mode 100644 design-system/guidelines/color-text.html create mode 100644 design-system/guidelines/motion-press.html create mode 100644 design-system/guidelines/motion-tokens.html create mode 100644 design-system/guidelines/space-controls.html create mode 100644 design-system/guidelines/space-page.html create mode 100644 design-system/guidelines/space-radii.html create mode 100644 design-system/guidelines/space-scale.html create mode 100644 design-system/guidelines/type-body.html create mode 100644 design-system/guidelines/type-display.html create mode 100644 design-system/guidelines/type-labels.html create mode 100644 design-system/guidelines/type-numeric.html create mode 100644 design-system/readme.md create mode 100644 design-system/styles.css create mode 100644 design-system/templates/app-screen/.thumbnail create mode 100644 design-system/templates/app-screen/AppScreen.dc.html create mode 100644 design-system/templates/app-screen/ds-base.js create mode 100644 design-system/templates/app-screen/support.js create mode 100644 design-system/thumbnail.html create mode 100644 design-system/tokens/base.css create mode 100644 design-system/tokens/colors.css create mode 100644 design-system/tokens/elevation.css create mode 100644 design-system/tokens/fonts.css create mode 100644 design-system/tokens/motion.css create mode 100644 design-system/tokens/radii.css create mode 100644 design-system/tokens/spacing.css create mode 100644 design-system/tokens/typography.css create mode 100644 design-system/ui_kits/ios_app/AddExerciseSheet.jsx create mode 100644 design-system/ui_kits/ios_app/AppShell.jsx create mode 100644 design-system/ui_kits/ios_app/BodyScreen.jsx create mode 100644 design-system/ui_kits/ios_app/LogScreen.jsx create mode 100644 design-system/ui_kits/ios_app/MyPlanScreen.jsx create mode 100644 design-system/ui_kits/ios_app/ProfileScreen.jsx create mode 100644 design-system/ui_kits/ios_app/README.md create mode 100644 design-system/ui_kits/ios_app/SearchScreen.jsx create mode 100644 design-system/ui_kits/ios_app/SessionScreen.jsx create mode 100644 design-system/ui_kits/ios_app/SettingsScreen.jsx create mode 100644 design-system/ui_kits/ios_app/SignInScreen.jsx create mode 100644 design-system/ui_kits/ios_app/SummaryScreen.jsx create mode 100644 design-system/ui_kits/ios_app/TargetsScreen.jsx create mode 100644 design-system/ui_kits/ios_app/data.js create mode 100644 design-system/ui_kits/ios_app/image-slot.js create mode 100644 design-system/ui_kits/ios_app/index.html create mode 100644 design-system/ui_kits/ios_app/tweaks-panel.jsx create mode 100644 design-system/ui_kits/watch_app/README.md create mode 100644 design-system/ui_kits/watch_app/WatchScreens.jsx create mode 100644 design-system/ui_kits/watch_app/WatchShell.jsx create mode 100644 design-system/ui_kits/watch_app/index.html create mode 100644 design.md diff --git a/.claude/skills/betterfit-code-organization/SKILL.md b/.claude/skills/betterfit-code-organization/SKILL.md new file mode 100644 index 0000000..effdc4b --- /dev/null +++ b/.claude/skills/betterfit-code-organization/SKILL.md @@ -0,0 +1,201 @@ +--- +name: betterfit-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 `betterfit-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/betterfit-mise-workflows/SKILL.md b/.claude/skills/betterfit-mise-workflows/SKILL.md new file mode 100644 index 0000000..52fce83 --- /dev/null +++ b/.claude/skills/betterfit-mise-workflows/SKILL.md @@ -0,0 +1,139 @@ +--- +name: betterfit-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 betterfit-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 betterfit-supabase-setup skill for reconfiguration diff --git a/.claude/skills/betterfit-supabase-setup/SKILL.md b/.claude/skills/betterfit-supabase-setup/SKILL.md new file mode 100644 index 0000000..836223e --- /dev/null +++ b/.claude/skills/betterfit-supabase-setup/SKILL.md @@ -0,0 +1,104 @@ +--- +name: betterfit-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/betterfit-swift-file-splitting/SKILL.md b/.claude/skills/betterfit-swift-file-splitting/SKILL.md new file mode 100644 index 0000000..14b2082 --- /dev/null +++ b/.claude/skills/betterfit-swift-file-splitting/SKILL.md @@ -0,0 +1,188 @@ +--- +name: betterfit-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/betterfit-swiftui-reactivity/SKILL.md b/.claude/skills/betterfit-swiftui-reactivity/SKILL.md new file mode 100644 index 0000000..21e0558 --- /dev/null +++ b/.claude/skills/betterfit-swiftui-reactivity/SKILL.md @@ -0,0 +1,171 @@ +--- +name: betterfit-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/betterfit-testing/SKILL.md b/.claude/skills/betterfit-testing/SKILL.md new file mode 100644 index 0000000..dea8c0c --- /dev/null +++ b/.claude/skills/betterfit-testing/SKILL.md @@ -0,0 +1,231 @@ +--- +name: betterfit-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/betterfit-wide-events/SKILL.md b/.claude/skills/betterfit-wide-events/SKILL.md new file mode 100644 index 0000000..489ba61 --- /dev/null +++ b/.claude/skills/betterfit-wide-events/SKILL.md @@ -0,0 +1,213 @@ +--- +name: betterfit-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/.claude/skills/remotion-app-showcase/SKILL.md b/.claude/skills/remotion-app-showcase/SKILL.md new file mode 100644 index 0000000..2ad94df --- /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 + +- **betterfit-mise-workflows**: Build/render commands via mise (includes `video:*` tasks) +- **betterfit-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/Apps/iOS/BetterFitApp/AppTheme.swift b/Apps/iOS/BetterFitApp/AppTheme.swift index c4c67d0..c2837d4 100644 --- a/Apps/iOS/BetterFitApp/AppTheme.swift +++ b/Apps/iOS/BetterFitApp/AppTheme.swift @@ -34,11 +34,16 @@ 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 + /// Legacy storage keys — all resolve to yellow neutrals. case midnight case forest case sunset @@ -47,271 +52,81 @@ enum AppTheme: String, CaseIterable, Identifiable { var displayName: String { switch self { - case .fitbod: return "Fitbod" + case .fitbod: return "Balanced" case .bold: return "Bold" - case .classic: return "Classic" + case .classic: return "Restrained" case .midnight: return "Midnight" case .forest: return "Forest" - case .sunset: return "Sunset" + case .sunset: return "Warm" } } - // 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) + : BFColors.background(for: scheme) 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) + : BFColors.background(for: scheme) 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) + default: + return 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 +144,11 @@ extension AppTheme { static let storageKey = "betterfit.appTheme" static let defaultTheme: AppTheme = .fitbod + /// Themes shown in the picker (yellow density + atmosphere). + static var selectableThemes: [AppTheme] { + [.fitbod, .bold, .classic, .midnight, .forest, .sunset] + } + static func fromStorage(_ rawValue: String?) -> AppTheme { guard let rawValue, let theme = AppTheme(rawValue: rawValue) else { return defaultTheme @@ -394,22 +214,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 +266,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 0000000000000000000000000000000000000000..59c5085c0a3bd8400e0c510e6354280f263f9bea GIT binary patch literal 6039 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9af7c5a770be0dd00e330390bf97ac20e48ca0c5 GIT binary patch literal 4450 zcmeHL`BziPwys2IMU*Huf-*#8hw_ z1Z0R;RJ3IhgfIuB71In+17Qvd2|)q@84Xk3!TZ+x7vB5f{!pu`zB;GYK6~%_zS<|{ zl&ACdt$Viu0JdXYj{g$?7;U3%`2j*6w^nkXP9@C6ClY{zt{V+TqC|N>;GU=xXQNIN zFGi8hM+5^BiDY(}5E>bHJ}lUb7!i^qv)c>6j%n=iW4~R`of;zF%ZsMAe&t#E&|G>A zl!A6Xy0PQ1iRZ^0RyhJD6TG+;LcQFStj&2Q~yS~!7|cgDVcOyt*M`Y zm2p-1k`q`x`yqWWNi$;_%8&0BF?TLvye4w%_!Ar0uD2;L7{5 zBFoDa>*=e>14vpaNwxeysRwJg^V54ZP|><<$YGrzlhnRU_vb6{EAX}+d zGT1f{jP>N+iSMXt48M?rxe`sfLQfsxBJpNtvqg`JzIX}+)&zSHTFd8)cVTyTyhnqi z>0{XS{h_ti#HpOBxGm$evdS-W&Q4lCG(DRqpH83T4Gl7nAZgu0&zIK5I8#o?LHk8* zM(zWRwGh2;w-iWPv&wMlNExE3y4sTa5@BZ%rx>2;iLniyAt1fh!Dt}g=Eg0C;_EuB z+Lo9?S^;SV1@yt`$1`3C*mj4{u_xkcHSy<)+q}x{GR70`y2mTO7n^<+A<&(>gX22~ z=b6nWw8|m+)pv(?0$MoRK{YZgbHREW4mOy_hraCLP5*Lzp4Smw_(X&+-v*?8Or<}T z_5@vCJ~u$w4h;CHzJ&eM{9cL*Fz9~Lma+@0H?T1lNUBo|li5O8(>qg^OT<~J21pw$ zPce*rg@d3eo1IHb%Fix+Jx;$huzCc%?fAty4t4-a*gNWg_DBglJOBg#nf^cb#xr^G z=JuFfCUt^cZ-s<9<2hq`-frVMGuOS0-oqQ5G?ug1w)h7Ev`OYcP`FH zdKeuGfzpvS`e;C}coIQ53~~%WT4n6ChcE`m45=?^^I%uuHdUm8Q?G~D+-55Zqvg%f z#muHjeQ+;aOk(W9b=iN4{NWT6ocjB4kFi($38s{AwLz(W7x=qoIhkvT8weiqFJ?OW z!5y*8B2VFrZOPp6Yj8&&r2Oh^jX4_!o7w`NfOITDQ0?8qBD~!LDrq z;)bOHe?s;1g{@llu!r@w0wd%%cc})N0teXj$;dpZ@~`Q@kv_e?PtJ7nEv0=>2i3gc z)e4^SsZ`KJUH=+dtl4x)1q@33yp64g1r+79bO=8}a6eOetpR@08Q|%}06qn_0UL&L zB*)RwTY&pW)gQm@2WYOY)YqK$VJFBEE!z71dlV$B7pUT+lz?#`wda3F;ZkMv(K$Z^ ze$3RLer~a+rQwyhn2o|zo5?4v8FS{7vw72qh0iB14mbF#-#e;=-{#?N7%Mw6*xDP( z7Tw)MZ?!*4o^xBSA4SyFudKGq-Tr~BDO!MQ=_b>%s^oL@iiBcbws~kn(DE4_!FO}f zfx>7CD>1VQXAvDcm2Wps6PQ4DLBt<+Y<47M1NJ%^ANFnApEDd=%Zcg&i403Cv?Is} z$ov-T`}YVbABdT>jXUYPry@@BkV)+JC)BC_n2seaHd(UN6>>eDeFl#$!mrTUoP zdW_=Tk6HJnuW^xWSP@-L6|VEB_AZ?s=vW@kiy{a)yE0!S z^in#~ZNfMr`;eoHUj9O5t0Y2=DS5#*Io5r&bAemggVJ1E8jO?h5@WCqBpi#nM1WFm zC(R;CvMh4ae1)@!?Z{!q&68H_CT6xz#Mf^aajV0XKhaQTh{F}6~>(Q9#Yf`HPTIaPar+|4ca?%D^^ ziUZ=#MRe|IsEf0A97csShAhUhF><4Q(NuF3R?HHMrwXm|A_ZOuMdrebNk5!(P~X$qgL%lHqXQPW-z+ zp|)~C{gN{{AZ=4*udcS$u6`SXV!R#p@DRnx* zH2}Y=btI_!Z_!jKwq$j{!E2>Pkot5Cfys{h`eGy5w*bp4OK-ThB#Sn(ae>>@vF_>l z^A;03z{NpNRmKvlo5#(mJr{W`Le{#$PCG!-h=sG7*#|q#yp@+`-wA0O3!tr9^|~p| zU4xX^i1l00zB4Mo35s8_ru$H<17>qZK(m@UNE%Cz-asZQxz^?oS0+K8e$d9@Q<~>j zTR3bSXfXoY-b7m)E7k*wz1?{j|4H0FWA96C{^DrBCC{hql z49iXpj}(~rnd6V_fX*DhnEMk^uj8wfHJCGaz3o%VY`@fb_O5|A$s)}XrsX7^b~XEU zL8>u&-i&i!qv{P7-+O0)R0-K@0LnB{V=hA$K|E|yZ*VKzcmq+US#0#lI`1j4Z5g0x z_hjvXJ|YXEx9duv>eKnnA)dnSZBH45)u{%7;W>Zh1+z8BpWu$0u;EnnBKEUZn<3S?Whfj1no@xTr^d<{y<>zP#5+v7rIMK z$A+@J9ZI3ZhTaWUY({C;vq{AXw zDBWR6A8LEaxXRTWfG(VDaGotoWE3z_&gRo|;~HEGlhvn))B0dw<_4+yFkUqkY4tOK zvoaF&p)-+9lyMCE$c2Ko_=$S~lNBRn0+X0rRpCo)PicC{&$OFyYX7;@{vTcGpQRXT z`(^;VNS5ys;rzW2nc9UVTyB8V{-_Jy?=Ps@C`qWX`>7?DzGGMRb9;PC0sgPrRp*{k z?#Frgd3wo$9ys}k_x51kyPazp^tghBz2M~7NYP}ecHEAlwpQ&77(U7&|LU^g%+0t% z+~v$rpp_Dx&EmKmQO5fuIMEU8b4pEH=H;jKgo>ZBwT;u871w=8c;ko@^b^|)VQ4$j z#rRCg=QRX_tC{H6mw@q)XZIZhNmFcVdpU{`05jzF6AG`sGs2!Z0u_&CQ8iZ7Gr=bm z37VimA%4hQAcgGbVIx+av0-Q{(wNXLVV03lUb6zz5eD2Bc{8r+e1MA4kU%;!C^$jB ziBl%meqA(wJCcPMN%fzfK7c}1K|A0K8vAyzA} zpE`W_dATA3te#Z?A82LT((9w#IC5h76fool5ADo;2i3`kEe9ZOHDivUY>b>22I{*M zi$g!g2PcpPlB_6l;NUOn_#?hdQJ&A;K(>U7=@WVbhJU}k;aucJy0>9pa54&xW(bVr z{C1uV10$-mkFf(eMN^qUPM#hSzVzQuujm8!V=U2#A?w|0+!cN$HKwa;6S^;Ws4<&~ zddv&QW}}6 zAXPd_kc`pFv2tD14{l{aQDLVx(w|N~o>t*8Z z=;dSU;edR6eB_5wZDRpWs~7l6Xjc6mQ!B|c<*7!eP1CKbeWpr$2Ob?x zp3)jVK<>G*>+BoRn1`aV@tO*T5eA8NZHy6#V;h#$4$^<%ur>)us1+KY18ETDa#xc5 zQuXruVZCArzn%0Iy-B0QTh93^WVhy1Rq-k%PR>gg9_A@K3!xb=JI@O-3`U`vvLVUP zL!U~cidr7rhGEf_Q^}y`7nbfJyXn)m7bv~B^E9vESr&Br=)i{o*ETJXAVfEHv$*4 zdNE&#=}Ju;XVDvmwA4y1&D0z6-YKKrUa;dbiE14ly2y)>QdBL_Yic^xA1_=~617oD z_KFaCoiNqlwW>(&7nNGYg*zLUtfw6#xlozZ(r>BWaDFxbJDhj@9<4JSQ|CzIo9zfH~^a5Xo=aH9F!m>BXwOMwD{jNn-G+x%s z86YlvXg!G!$9)&;xVQRk$uQrE=#7d_u?GzD^ZkxFde|i`3t#{?fmJ#MrMq4y4v-T1 zHA9QVO_8h%sliJ-L1n;H9g9GOHcccnFq}N~%O82&USY0oK{AP{ji+)hv$(#p(N`~H zW;WZk|IPtJrs7g-pA0&qT5liFbey#*{XUMNMl30nF`QpmS>knDi#deyD*Vh60{7Dk z^pD~Cpah+b?W))%Rbsto2jj0YAHlROjSA`XBi~3&26$(9FmnXf_!#))a8)(zPOB*O z+Ki)*D(6Nv@+_xuk3r7F%rdH4KG zs39jSUiQM(6Q9DSYpn6ny<7qx( z|9Dv?2p6kHj&3+Q|NVROg!@X-&2{t;eAxHiZY7$6quEUO$;#&!)>4WZpLfMKOQ{7* zy&8 z+#eO)*mGm$UTx5KpC1hkII6@-sM+``aNY4;&mG2Tvu>+>*}|gz=#t&c4m0X@s;0|9 zh3|00n;gh7i~!GH$}fv3s5~%7%7!tF@UQwpg0o5E4clt{pMDTRGBd>z99!vJT+@g) zCRI@3H>PNHVY&pJYfp-MXpzXcZMz0%iL$OltxZi%!PVWWAxw61J;fkFGQR29AVUWy zDfIeoJpCBW(SM&p{xhEU+ZKun7pjg_V#^gmpMTACa_Fy$6F&Pzj&er1`L%0|q?*@0 z+&vIQ%U@PWO)|64Wh<*zu$tFKMLe9w!RION4YgMfscaFq>IA=8FwzV5z3a|ZK?D79 z!9Bm)UxF{K0D#KfdWiuusl_W(lN(15%Qu`vmYQ|h+jcaKM z`8J!P6T=(6X{T)RwriuyJev?u&fl{Am!{=Z7->(38Hu58tpOQs9E@bCW@gTI#Tr&h41 zIm>gXC2%PWf2FGsG*-KsgZ~>*`~$}-oRMjuE{Bta@AWT+$`o z`WhWAJ_a6_NF*_k&Xu(1CwwtOA`y-qYMlC19-FUNe<}nqGPj0vJxi$Bjj!e}{%hR9 zTF>982PPhgro_AMP5!EUYOGU`0P?uuQ;kS&ehktYbjZQmzvQJU(>ko5(-x!s~9T%au z!&whu$tQiq@$Q3(-bUGEF2f>^_%f-Y#_HS*rFcq!t9q2C{Y`D_cN~niFZ1H21)Hpl zOns@Z1e(7R-ojm{knVM^ZzJPfUn!sZ=f5CO9{*P_m>c}p889vU*BLM^{MQ+9E&SIR z{_70r-^*uA)JAl_koksDwuP>&D%k-orKy*U=Y|8TGzp`S(Hxy)Vs|uWMi_vQ+1npIu2{Vl8Q%{&bIQxgC)MEeQB4Z>o za!5;LW01{lZOmM>S4cOvZ(f&m)kq>_`YAC{Y)5l|nExeKSgyh9Qgeyg+_phKqoJeX zA`h@lTFnC`)M7BNb$j{4z=4G&BPsKeX!@m=t8{a#*yVnO61Aa!_HXMv@)a7Di=dI{ zVngk@QidwWopy^5Ln?UPw$Z46#k!|r#jt!*u<6UlS9xuG)*}DGb`5IDN-j~@(o&tC zQn_45AEu(D2E;1?}&#K8(LYT3U?4+tTz_DAl-T+Tb9 zjGIidy)DVtibdCP@qD!|o%Z(7H+hzZx9WPemc~jb_}PVEbmdB<6oO1>&DQ7C^*G-w zXX6Iwn(CYTX6gNn=K4}*6^-W9j%9rsA*jrYy4w1Uy4-y2L9X_|3No%b?D1&h)Th@KkO>VYGJi$Jf^6e9sK}!t#&X*$~Xy>7lKUoLw{E%g+Fr8MDe%oNWRrL(_p}$gEa%xN2ZSx&F*>J?TU8W#QQh8zw(6A~5!aW( zoOChAIX1IZV{h9Prx3M;_<`)Pt0^=4w33r%Gs(fDq54wHwLvYRhZfUt=lQq|Nq-!Z zp_(qc(&~$2NWZlo5+KGdA5LsD+LQc1HV7QY`%S1N?*3i z+m4!>+%?qc6h->&hW0o~oNe3kU>;-RY|_J4o;X?a63N`4P+GvX$*$WK)2a!sJyz=U zFU(pR^P09$0t0vWFehY_-M@OwFlX^7b93L!yX{o#)ob*C&WGCL@!EIX)|Gi))Hki6rC>WleJ=22ezbeOvz?I{QcJHa?JU;vYjaob$}a-IH=(5%)?c`Q z`;nqY^=dy+rXz~4MNqt*f0JZLK2x$s`fAaf+Odi7UU zSD1dLh<4Cfksur6CqbALH=31@Nc@#~$;{PLGyyUB zg3zNUxN$B#2bDoSuNZgHFk@ zfkQ7^_iQy1zj0VxA*>fYq@lWf~UNH9TTcnTHEK{nE2y-`V@ z?F=VFvLIFL@cY%{wQ#qn->*!Gr3}I#Mwu(}G}!g<^yIuosp>|8X43Lh^~5Tl&aAB_ zhK`$&Vf$tC=1h)W)o_ua_qc4b{eIbaQjShJp4jr878R{PDLGuFD43$W3)QPuQu8mJ zhflCHLi+oR7Dj7*A-RA%tXKT{o2+wl-6*V=OGE)9b8dl0VYfcZI<6rnvux zr}9va_;oA=wH8MCtel=-E$ImL0ER5baruJRqOIe-2GrbsV?a^HT4aR9#*(JVi^efv zZ5P~mUMZryo#e!dp3H!-fR3fa!X3WhCE8xs06>Ndunz#h4SMq)d9BYePf7B$Wo}+< z*@DS}hp=>}b8P)EUwmwQJmYQZ#e5G#ri27tuU<+usn5~BtYK;Fa0#SfeOz$q*IQ6S zk4}&_Y4XZHu;Q5w#|DH1jcf&xn7G*-wxPJ#V#8HN?c_wC@|4#7hRUkhyNMuFI=(DK zL10^H`&`Z+Z*Se>dvCkDUQ_w`<*CPnz6S)Mz3(v9rD^h^8J3hdi`k#!KQ$1^@wrdv zONY@n-$p&XnaXriH|gZxD@(__8L!6b=Ytnp$RN^XoXe55e8_VyB(SVx=Hmmcm9NGAzY1$(@-S2olXJABeE9l3rMd1{!+|^OGa|yDwN-i!Z{$dSDdS zM&HNvXm0FUYB()GE8oGz<7LCurSn}7XeiB z9GbwOgAFP2_cJlf=H1GIuhRDqK`E+;e| zO66iry!NhcAMs_z=it!AmJpEJb`F=DNZ@gdEOs@F2y&o>itS1Mqt3Vc1HDhUMD0sj zU1fwew)!5(<_i4@gdU*{*V8N4YqF2C;={Ib)EA9m(Q3)VeZVW0hoZIk*{S!Zdt1d9 z*&jn<7mW!ps`#%_DYF6 zAP9WbVe4&D^LxGK7a0cD@tpq!uoijGOml|Md|$L!&%f+8dlyRu*YWTf(JnzB%~jm> zQWkC!6HB5)gxQKFVdJ1^yKUqJG=sX&fp2Eh2X~XE7cxqMo+7E_VrDzdLrqWvU=^7twULv z6H7~M+x-xyL*00k=veURQW@DZgN*()ollh(-p0;xf)*9A^30PfwfI^@eKuPw5&!y$ z1>4pGruL4m17{gMk9PWG8&r85W^N9D{i#FJrDJMkVAeJtAm%!7r2(&RJVXXz9TmlW&kHSpc3s(lW>TVjpdyMK-b&o!`Bb{da;;Mke zn1aj9+gk4xOK~F9SR;SoDRPm%1#KGd4a0QRQ>?Y{!eL;wT>FFyxa1~0HrO;Co8N=l z<-~~n@5$P19>^v=2*N^_Q_A1+M|ZK7+R!IMv7l_PANBo5ZRtU=CC=tSy5gOEgFy%1 z?@6bD;7G9Offsu9jNN^(^q7iPf`wmDzFnqv8ex2LTDyY9Zq)-A%Ju!|M3#cpWc$W( zkNz!U>{lhAjHdE?bQvR`yE_4b9c_*bvxHZDY35Uq{^ZXt{%)E2rCs31AR8Y8>)WzR z*_=cGLqQHGPS>TKSYO=@ea-QKgqa@Asm|6tFhsc?w|56+T4VlW>pC=W2ctfuSR&*v0}Ia_vg6 z@F)Mr&~?+&XvpH8Z{9`h0Qi<;fZX%L>GVygMV0%;2;StcuG-g;(@o&zz%#Y3FE(5Z zfk5{kSUSn(&?5~}(nz#`H3=t+kJMRv5}cqi#v8I}Awp;qkWM6pil<3^GyLRtPs!cWEvuK|bA2y^pt1Y?oebh>*kh5?D)lWjQ?yhRwR= z^co|iBZBr8UorQneFP$BH5R?5?a%;3@XXaCap(hh&G>YU5@4Cu3ISlrSfZ0J9w8i22eB16Xnch+10oc?{>nog%z5lC3kK*e-S9nN@k-=L`>n(O;BK^g z=ui#3Xd$nq=$Fdzq5IyqWZ*YSYXiDvPKRNij)~OQJxhNn@D<%lWPEq~nP?L1fG;(G zS5qeUL)(ikf?n3`!!k$45*E$}nQ zUu**8aho4k#cLK9akXLuhFEynh~=!!=)}$Vc>9G>t{-XbL#Kuoqd{Ma&Y}SrkBI&bP_6wpj-G5wV?1c9P66h82j(UI|;qA^;i z_?~25ir&oo3`WWZ7>CEm9%cP$WW#HG(+*cwBv0DUq$*_m>Sho3h#=)3m>cbq(E0<4 zGnR4l6oMlMN)E91ccuc5blvF-fTTqWG+Ic;3O$emb3af6VEWOeLyDmD8yJr^3VAa? z1^fbrgn;iCa8@VXcp8Ijk%QUUP)ixd?fVISmw^fYdgr5@z8fiiaPySK2$F!{$Cw^m z)IR@XTv2!O=}IPf@uVHF!wNC4FrGBto?oHJR1L5)a?^YYd4Kg)R=S32FX9k#Y zSPHPTMOhRhbKaIM5m=UmI*+nG+q>EUslUK*pgvnPQcCWjaV0L#woD!G#C#Z`+akz+ zD&h*)mNguzTOY`Y0k(R zKHcVpC`n}LS+72bE8;3o+AtjAc-?Zsc+ph_l1+MBQQ6Cb*pWJh_UVKMH9!@z7b-U| z*X~j?!Fj_5G}<6#m zX05RYq)`HhujNVv=3>S%0I6y510}~Z8$+27D6ix$arjy;&-;F_2oRec(8$9jF=^2C z9QhhoZztDMQo6s`V3r@J*_IKUM_s-$sYFiHbOlsW`01i?1W%e1RJh(vSVr(H2s-wy z4p58-(lh@I!GdEGtm3{0k4Yj8z_DtG7sZL!%VQ{OQHv7k9=7&d!cXHH0Fd-6Pnvsg z73Ng1hh>NJz#4pc9JBX+z&R+f9t90{Vm{~qDtM{487;niiV2Sc)1(pmmYp#CjDpyW z$J%$$PumB-k?uBdgayucUqGDo=^%yG`6h<`ci$i9T_f><^z3*IW%KuI{`PqD`^``1 z1F~<%{(LdAq_}&%z@ua2C&QVuSz7P9&ygSM$Xt-A_OBUh8~x!)`_0+l!y#G5x^C{{}j`GJcoQ3XpLw1QGO z+|)Ppc#Pu>6LF5ZTyYvdf1?rA@om!oWMF!;Br2L-#T*eQiRoy&0uT5+Wk1XX!HlxNxHMmC~UA$lMZ7wdGg|z^86azwOp@1`fP-(5M z!%MEzf4I|Slrk|_B_8v`Qmn(5n2P->C!sp(Jj;!j6U8){!Jqii#X&a4W|XZ)>3$IV zUwrk@{|B4!^D_VUs0f&Y=T%;^#WoNi>sAr&ub<`oxHinh6p|)1Ks9|yT#t%Q;!d%x zN-{S*APNi?i!dj_EZUH0jyuWk3s6x&1*^E{|KSu{8?dE^$h~NXY2yFtG~tGuL6${z zK!&M2{<5!hXgO$C>pf5CR@HgllzwgYr|4esl=30QE} zfu8M^_$lYc^B;iBu+qx&>t2W8c>4k${{-Uv0PL;{4|c> zP5ULs1giLUS@?6n5P-A;aDhD`{E2`L$C{&NCEO4?Q{UMw+V z^a^|A!AWZVmNh?z?=^&CVTz157M{eH|KbYgI=l19zq$7AQ;XL)qt1_9dn!u%aV<-N z%m>R+QluRMvD3&sZ?)Q~FKhm0W~@ zzk-BzaiJAU8$H~x%?IP3D=w7V!GntF|DN(t|35Jl4YTkm+1vtvH|;kW(*tG z;=*urzs^{Xb~yd}cTkD_!+I!ob@wd zfMRQcPPyjxVYy1qa|N?@riiEI^uK@3%kZ_F&UuI}#4Ip(K&KdHkeG=AC*08~ylLkjpCNh%uH_kt zE48Ed>tDnWHO*bF8)`O~14{fzv$H=SJQiLfT{8%aCst-)OU<|<%!kSj@ferB3&7GY z!I6w9!wrI?i71FH3z}0f-eA^d^sayom5g*ISTb!pXv_Rl3e>kRyYbQ#FGaah}3^0_N@ed%4x_uRJY+0^=sY zRk+5b)3{tUMthKdjU%vOYlv|*X^hx4JWGX8KkEMI@*~jk`inPBK$E;q*Yqq6ex_+` zMp`xn{FI!7|0G~+CErCOSNZ1jUcLka*^In=NyWs(TyZ%HW^QdTn(MCwAH3l<`^tW` zg3C$jZ;1Ve3k7x~=Wx;Pp^?9?or#4VU&|htiPFr0%H^6`Ec1vwTX}K>-nHymPf0mj zfE9T{eRtb`hF2q-`E`v%y>+oy$winB%Z~80Yzml48LxPN(Y(kc(Nn(eQjdb9_Km?{ zR>e~(tFXFICQl;vpN+yu|0#CYSa)6;2f&OW6s3$~JI2$pJEdw(+GZE-MvXV^*v7tU zh`$^9c(C=|MA>CP=orCVLt1uMF2=~qHV!6&A&8*HdJjP& zl28dpkODlU}JNrdHL)n058Q2)Dxbm$P-6)fn%xv0J^ka1&j8>`+Q{8dNr_t zG#Y5C!1tubdu^brxY=dSXsbN8v(Wh?zb>KlCJ&|SdjzFFx_-)&b3hENT&rx4G z8rfxJ$E#?bjSaV0uBV8pFScOJ-P}#Uwm=AV{seQLbSigw`az7c`)cQo$H;NF0@#qz z4o_cDKD+Pfv9ElF@xuOK$547WYRI9Epte{Ej0j95v|De}~ zH7%?g<7uLm+&pE28NTQ>l~HpV#Az`NolG4sw8P8tnqsie_?;X_WOL)J9foBIV26&> zpoYyZSR(aG;lZ`@2A8^6&eQVA9;hX8#0jfIv7W?kh? z8zyP0J12lokA$gtQU@crmjmOzC*siy@Q| zri}d_y^BOBngV@3dpFoAnp3%}$a|6=>w5&#R|+9fD{; z^3T&U^#efPb1%+0WJ^Shtde141FF&-KB|S(L%U~S@!WWUn8H6 zotVg#FIVZ0qPcNuqFe$U-GgHf7>@_!p%;t&YxUO9)yqRyPdX)^!k6Pbtt2FprPRl4 zFiN_7-bL(-5e!apupCmR#-nH~B~FtyOX|I1#FU6CY>S|;J0?kX5mO{+b{Y+1c_J%z z6XH`2v0`-&8f2yw7LfFZ8kGs*N`&x5rY6ScEa<4SG_$nZcBDyb)=9|*CWrr&3Q8?QALUk!slqQ&Q1bou7*_fRWI z&iT$JOo@h!r%xF}k;P`k?B|?nKM^9Jc+!Ze+E`o)t#zx*p-;QO5nFL`g}ixhuM(lw zUQDE@$PWO}=zUakn!s21q`A~jL~N!;I@J^2ju7~fUn>)f`sc3kN>Y9=oXt6YGm5gn zK0z60-*PjuPt~GrirAa{dzS~R%s#iTWOZi9_G|;+eX)xepO~W`r&?UfkP*M}@%I9; zMNhKWjap5`i$Bj`l;7TWb`PJLqdzH;3P^6XALL7?DbiqxuHxKLDWAxnsBY2}y5?u| z_E)Z9LN`H&DMro^3zGGY-P@!be@P4&vK0YxM0K1at<7jTQ_ymyC4g4?s!o%g>Sq)6 zI0Y!B!MVoYA!SZM`NMR>$*K~sA01zS=MuF09jEX8wK6+&cL2kpfn(W`gx>4OSw(5C zzDYMxG-=Y3qjiJIRvg7dl)JbpOGEDurLYbP{IWQDuRw}Zs!UjMfYE4tw5)lHuI4{O zjr6KWvPT<1U&>Z}qB4N>giy3?dtz@cU;^bUzxxWi4vE<}B{4cepc9X_q zDOh}_g#eas<67QFji+X-+_^XlUvK7S*Ia8(^oiHt zxg!GLcFY9abIUg)*LRY_zFkfeDSrLz9iR6RW+8?Wnz&-?@zk8z;Z2DRY9D70(rO7w zRRhhMZySM~GThiBM1Iuc#Wc~}b?))7)xniijTvJFz#bomV-KiU6G!eY+@tn6n>er< z%#=9s@=-LsBbThCqEcC%>z73Xw>g0aK#LX$sYQ;2KHml(ls{PDwVpV- zC-QZ;8v!wKg~M7ycQ=Ci3cjkZGvD@?_VL)H z*(^AbmmW?^5cRHU2oaqz#uV7aDMuc zrlJyK;r&;CmP}5dRTmiIe~cF+6OVp7+WmG&I=eR-G^xQkz)Z^)z+f4V+g+3&nY13D zZ;ADPRZ^(q8>~7xr$Xqn5fgdG4ZT+L0Qgu}0F#ADtQwn_);2 zaU??ETAyAG`@JfOl1Uw?)Crg|j_|WNC5$r}7qlJ~zrHVaCusqUb7T0jlvJd4hSyDN zd$CuL=SR6QS&$2vtA0(Fy!GlC55}eh*T6I{s%@N(!orKCZfpUg$|(^FAYVTk!{f6- zUJ7-(g^t6y8r%h`onE#!$W)zgs2vAYjxOTHDtNsPWzXO?U!CtAa; z9u0*($@V<}4D=s4;CZ!ca1ClZ4+Zn5*b%B@cXV^EooBOV!$xqQbGUKi!0Q#sDrEhL zU=Lcz(nYuhS2%Z@apQ4!_^oKt{(VEXj^HQnRIr3sGk%Ge;;f_+Q}JmFc_{9Yv#Jw` z^)j}jsMJZ!5!1{qt_&N^_nmlbeJ)XRjGk`7ZuV3-K|1)z>n2@^pagm3Q9B;ljwv#f zIB%c+WYJ&}ZE6K>dj)qoH8Tcs+zRaBa*~OiSVk7pT^&>0tnlcN~Ci6r695baJTvUJ4(m*O+Y{+&AgC*mti~q0&DLFC$kqd5X;B+jGuwlgvaRIOAy!UDcV5DuyOzy1e3-giCiJ2C z;&&mDZFQ+w3tiD{J#wtG6_Cgcx>HLed^3)Tc--SP@z6Z@v(~b?WEw#w8)SQxTUBTO!ipm>5+}I zR;PpanLCQXUBsBXO;~ozTiJ{n%EVWXd5kX@x-A}pyM=kpDfG-6fKp5FFi!@ zr3OWng`8)FjqcVKpfWHuo>M3fVehgoQd^~~u#{e}W1>!PC>y7$+1R3419$CFW@A&hr9T`o+fD3%a{x>km}Ml*eVsWR8J0G_@h;C6fB85 zI3hZdm?%%uDLJb5(&@xn0$i0F-PonNFznOSn~1F}c+lIN-wfAvpX+q(d^~+IJgkZ2 z_N7N9bceCJUI_RPQ_~z7z{qSflr2-ipjK zP})z{95)Yov|-MYJyXKmyS0qbn0ukXu^R5;KK~}e^3VL}onNyKG1h$XM$Wgp0C3Sn z4lXL}cY_=i!pVhul~%0>(%Xb0p(4YL{bd_JI$idum_g|8W;FLFLYtmgd+$13so5ZS zs*4O8R~lZ{a|HuEJ?ir?XJu5AR#U4lvIff?hwUbx9cHnmhzddeiP19f8?xG0MTo%u zt@5+DkA!=GJ4>}gWnnqiX~<5rh*~BhSB}v%f;}YvkvE{$g;6W#k3;WA?DB;~yNyN>+6T4j+?x|oZ2X9en3=%wRN%VBJL3D3wbY%2;6`!@7|u*d&6R=>fc^?>_-ir8=5 zf9Mkai{`(&;rhaV{pP>H@c)}c(I~fr5=(2oNz{v( zj0$hmJ2M_5Yn_m7aVNV|+3Z=`rOpU{y0~)bSW4?2n>nce8;>{{jpwdZsvanvor`be zQ!Zoqkz=OHKBX>w2$6_g`74*_!|;<%xzdlV-ul&Zgn^X6K_bUupQykSx0x52q~@tB z&5Mj9xwFlL0=vmxd-}#fPSCIR5af@-``=sQ<@WXm9>JekURxWlf6XY zWUZ$g;;L6|SWl)JFYU}~F-S>yvK9ICGF@~#seGoArWWc&)=Tr^F08+rN$z4J`6l9B zjO#|p=WOR@RC=8(??sWPPBx^fR_Qz)W=#tS6Z&-2yg1WAHs%4(n0crgnD`og!9gh|wx5VVd8k`4e4ljCyQ`76{&z zd+9h}Oq+*YNYz~Ow0MY*C9Ps#*X2;g$;jg*E7huFm)Qx^_fQ!ufL`s5B@(8Vi56ET ze0NYoR@|Tc$1nkw5P}<4f%K{`12$` zjoj+dy;a4&#bBu_o6US79)l0V-i5>j&JVRdg!z=a-~|lwlwp%UiB@SuW_{Fg^%(3h z$o$&Nxep-9FR-Uq9OJCNj&XBbroudk>=YFZ5PRMfx43dCHSnjZ@JitcVtR z7h4^jG;eN5s_xX_g%f4OZWqv259aiLa@SYUHde<@(-cp2gzQxt)iVr+zw0mw*MT-1 zz6a$2dF2M%2(hkC4?skGFn? z{Y%vbQrN^Qo%#`tgP?4-$GAIi(*wd)-ieiAIFmqFlQ3*+wag`NZnk zQ6hF-;czO*amcxc@v1~ZxUz8C3L}}8rMi5Y^~bPksS|v(#Eu4f4<6_2u(s)d%axd} zMe?gTXFXmPB=q|E0Kgpp$R-49s*R~8;@w&RskyzX}b@}l=r{d4_{NX|X{u)$_To83&Gs_4IV(^ZEb)%+P- zl|vs0$^JTMH6Ov)VH!(1Gy#ZrtGJWoS67H7y=T>6ZNFyZn;-vt!qp}v`5xyUR8PHa#S+m?IK%3ppJ!iNv3EH;cJZ#;Q1>R-DJNZne zv^&fj;3*q@hXs01)%*>Jh$9L)#m_LyeOl?`kg(?L`ip6hiPFZjqkGxczG|V|Dad1X zHr>RS4F(T4vrD`d#Ps+euD&25AxKsgA5rq&nxhQHT3ey{69Tx@S|&r&xM1fbr^w{dr)UWX*!>-sn+M~DxQ0@ z{+aB%-8(nP$Vj9`_TRRcrxV31cR5$y_dW`}ayX`hXVAMR-HW&E(eD$6Hs;=E4_0kk zF>s*`1UX_2jKDc9GS(l=Uro@6$?kuQ`zQsD&+UhIfK?tRA&S_!fa9zx)xrgukCcKx z8NRZOBE3~u8ds}&*17@Cos+Ko#@u}3<+RI=lf0B&*t&GD0M1DEZ#*|Krkq8eESEHd zd!LpLf`b0ztzpz?nQqO6YL%Qd2}h5K!dcUOY-jk|VXY`id+gm)*WrScoChimq|#o) z4Jxhqzw0HcIil9CMw$29zsz6)mDxhx=y?z4-!-pOlb7EKP~C%bVat8ihkFT1sImJn z)xp3Z)As_{%|F0nYzLj-ZYb7yO8q3Kv^b1>62bXtVq(R(8CgE~y5NW6j^3xRdgBpo za{(Hvi3LXT$>%*L%cM$IP3X$vZmh|WKYcbg-NufHDe|8D-wXZi7diW}o0&KH**+5# z1#D3!&GQdJmxa-6-8B@}QRue}2dbl)*pWAO8f>%EyL%rj`mK9L=ix{U^F7L<=(D}0 zi?_(Z^YQ64_G>t3K9hre8v)m1DVn`^@+Q0|ohRQxoKPNKNMJ-OT$o`w#gbaZrNkkm zEHe>>$IR;a>)*>?;n

(tO85k6Xrd#;3hY_p3YC2b4pLk(6;CN)TH{maMc5P zM>X^eD>qt1S1c9P#>Mi_0Qc`R;nqWmMf=ca&jc)9455poHu!hzo)HK_p?@N$@)xSL zf>)+%zYK|#F+TGdNX2PkH(Szz2|CjF$@(Wbi`$wX^IG>fHs8CRzUeJF^m=WAFe)3o z9Ym{7PO9MSh8EA*523^W^5FCDkApK@14{zn*n=zd=S$oh!z-7k{lp4;#3FU-QpE{| zJcy5#Yb~FgX4M>U^9a@l%xJqGAy&Qd&xdN9@YZ#3ehnwaP35%hRcFoNP?pd0)aBA#$ULK_GyNHq!FmQz)?LN)soPwq)okmiCObe%$V-e;0QdzH| z%Oe=vIID9aY53gT`hIo?UC_GiL6Q84?j{2&XJKje{l(AkrX;GD{3Sc=lY;{jO~kRg z;fiZY2AE?-56=c1Pn>Cn(|gDMC-Citajb!YKJI?7ETx66&dSg~xnG3h6~1-tFjsIX z=$M34ydMS@tyl@rkvf@L7j_SrajZ-BS@I|E4W5`bY|?2vtj8WOt+G@&FsPH5ynR;SR-y#tPy!H6>(77uX(Je z!Q>WbsV4kl44AmGM))LtfLSl)?i)Y9IUkCp8XHS5Z`OyBk}h&A{^e8} z2Qs+k!ga%(vSKglqy}sx+^stt;eMFJTc{+2njklwu6{QJZC_{%GY`ZcCS0MN<;ui^Gf+ac5T7 zb_1zLwY!#x9hFZSC(ENrzgSBn%JVM@!xFii2^9mMy|^62;TN$7;!WwK{=RwQc5&(C zd)n27Dy2iWu)bpQAKDKY*4vRuiGMEE&zyNJ6-C1Q)5LSWGJTUG4cl6Dxb$G|{Np05 zlkD~g4GD$CTPg~{=djC^m?((Qet>?9Z zJbXD~;Imi_eES0gbvysKP@_xvF3xz%k6m|;d><*;Qn+(N>&>5kmX6?i z7OCHJ^QPAQrl&=KDl+9v%{(1cjm9_mfrb??vK=08X=pVc?!<- zJsPO2URp>pkurW8H060v;&jEpQkCk$i-97wkMCOzvf|;d(Ac%zqR}G6g5Ghwjb!jE zr#A04b_TH-oCqZYg}dIosAXm@Au;KaePS%fA-~Qsy#o_5yZh}e>U?VdPftDG+Aq)fK^v#Rh3Fe zO@ZV@S_CPDAuD_RixENEk@k>I>XFnS>-fJ2$(-nJNK9@>O?EBTV_v}sjJeNcGuVU2 zjF#OCZJEk!4IcQINI^?eZ8=S)$1T}aSuTE`R+q<2>thW1rMt~eztv#6bA5>#Ti5-w z!Tk47CSLwWn;fBYkB#+ODNXS+Y{s+0J)o9~QUyyrY#Ok3Y{m(y2{+cg!`P`7}(aV?iyY#e%+4SMH4?>atiw*Wumb9RuA~pL|M!aWlTWY@~ zJ`Dvq)o!5P-9h`1OfA*GqU+5jj!|Ded;f?iCV}{BAT4Aycz@8j{Xwto7YkL4`p&Zx zN7>HwZ1T|%toDn$U>f+av1*3;{CTT^&G~ikCDt=$@ta5;=hbnSlnS%_Hg9@UPh{}; z!Ggwdv-`2#1!F`hp~06# zGiko)QD>x|i^U|x1IqbR!&e31FMk|ws!XF-bkQG2T%37zpDL%SP>Ee78(Dieq|4qn z)4qS)-nU+*Tzhxd>^6E^$~pgSUNPYP6T_;K?u$0G0`2&u;6T=mP3LxDq!)Xy5hOd7 zUD>;xS7&C7x_*wI54WFeqALv-c7@E_U#NfJd?rA8R>#$PxDD? zbsWo#vci<_MGKg+UP!q{f2EkWkU`g;p|1G2c)k~-Uk&h`I^-XUeLs4HASJ%^8*9NJ zF)8S1V#khHAzxM4G=Rr!mDjHdxWejteC%2c#9F0~VM?VF>jhu;_>42g z(;Ycw@?C>*#3?l?KYKH#GECCVEuFf`kQq205n@iu+|kR3Q$4Vd=}t6=iLqMCi*&`z zVk+ioh8qL@Xgwk3cyzZs-m0J{zv{+nEm4V|P9kQeykkfS>gV1|1&4?%n_ILWsCM&s zcO@OaOIta~HPoCxeMIh0g_REbx|^+!M^t%HjotEU6=Ok4we(9l7q;lxM=AOnrvux9 zPtsE$~8_N6_%`*_wMit^c~KdUXcS7Nm-Qrs6KXVgFckkOTU!DD6z;Mop%y+bv^G9BfjaJEt8G9DMq<6H8 z`7g4GdEO>#X|*ZI%b&x@O&sGyLsj|8tP;!BLB?*#zSz9ClMRt73pJ(brY&O-VL^;% zwbT#E-4*Ynh&}`6{)SWj^J<2WzZ0Ftjs4f@flpH1SMALG;d_P0Pis}^PX^{@eyTaU z+*qH`&|B8H?pTb~FS#E4h#l`&KojItHLf+fYKthrp(;<4p=#*_SjETxp;ytV@NwrZ`pcxaZ z#dUR_EkrO1grltvDPSnbg|_*hhSE*hRSJQIM=*5~>qB)0>=%wRx3Jd(l3p+OHJWaw zma)sDb6-hZ>anErbAI#Zulxm3wCDfHZx!^n?jc$)JDFk#5N)tc_#okV8Q^d33kYHI zU-_(!y|1w^UG8SYXc*+C34e!L>Q|pBnmx;6-&F7QcVdkVMB&zCUn`zH|7-g#_zHbI zpd-r4IChFN&?*SiDZ!V!=KI$ES+oiG4jNlOAg#`1xvVTX71GHezn^g2i(2 zZL&}Ksq8kaeF8g$m}0}KAct>50Zq>N_Rh>LS4w9l8C3#YJ1y3o?kL^P<3dEb;_6c_ z^kI4V%6lZ$fs_6iiWY^OWoIV8SNWcF0r;c9kaesAV$?NHx|PXwIax8ctT=o)Os}@K z_U9k7TP(C++Lmq#TKIIwA=qk6Igw0lNIC7#pC|A3Owx>fDWCZgT;T~Ad6S^9q*Ks6 z`mrXN$_H+$18*dp4Vp(}Os!+bgOak|8LYlGphc!K=hq>2*ucv3W|zvL(k0@+N_J{T znsD~nJF;Jjgm>rr^P*GPWLhd`Z|N5ex=vszU0gWE>Qr@4usP1Wvj2cHc`n)7|H%IQ z^Xwxx2nu?W%aM$n0o8@BTla^>#R9X~W&fwWCyQz-+4cmqV>be~FIO=Nh#J751w;{< zqjakQWN4M41)-Y=GDRWGpu`ED3ZjVu5+HzrC^WMmLnJ6eN0~vm2n2-4AOwVj*^pP~ zoOnO)$NTE_mbF-mlN$EkwQE=H;hd^k#X|*PrOmCM;PyTqZ&Q5Q7ar-~B6>6ctzL8) z*99HSK*k`uja-Ru>CG43I*nuf2Bc*6kvJhjG^_4X@l*N4)T`pQWWnR0K5)N=2aF@>h4X^d4!T8kLe*%pdiQ`+n4^d+b_V<{zS1rYdCj&C zA-K|VYifA;sZ6I}TZTLJoiJ21dxyg4Nce+Ac13G61R1G#% z4OU~h+@}p{D~)?t874AddJ)QiUt@5-x07yTB+1 z8^1uyHo<~*RPb(AhNcvhvjZk?et*?1Sn|3?PHWAJTT;OAfY0;^SYx+nls7BNeiy;S z!Z`v7?KcOOgDN8HUF7BtI^P3Kr8y8KHaI`Zw?NQy3%dK-WzsGK7}1T1sj#I9uLiAK zMw-A`Q`&7nbIA2jUI^ipZA{tF=VTbB(;7V99i1G008hSxywd(>LaA&fXtXVQ5|o2X z;hnt)wOG(6plkxz8yRW>j|5~6v)n#KWhxRxFw~G{6V9M3ip}s~Uz!94=9tnXI4%MJ zHc84t^R%tNqIc)g??_$fa|qD1hm=KMNW~}6IZAB+U8*0yu2_!jzk&LXfk1mS-vbYm z1CKQHMIWd)C2q(O1}2gQcct}4a;em&N4O4px6&E3PHqy=i$|vqWj1xjO5mP`;kq*Z zq}gwEmQ+dwmA>;}adX6QKgY;`mM{Zg?&9-Tu^=N2eBcI6{5~8@qqKN+96*&jI|X;y#^=}nD)7|nSdI=$8}GNpsqh2$c-rZpqA1 z&H$^hDcGdLJ;xJuz6hg0&Rc_5hX#4c)t<$on05;opX<;%urVWXpE2y;D+#WC&HWxC z%w<_yaD{IUK2bIeo7jpFhn0eC_M&$sjCp|P6xN7(ewZIaAAVjJ$l}j0KNQOf9*2ED&V5 zkzov2kYNdPmm<_%E>c|*-pI5kj7}Pwy8Qs{v0G$mpS4KP)t|`@vWznpZ{hd`2ZsFP z)Sz$v9at{e*ZpNw;fHU-^IwPO+cf^q($;#vT{?}CB2GV8T)LNa^+8awV;IKGkJIq} zC&%By>UfKbV)E?Ax90g{z6qBynogGPWc}d}yg0;=@d8HZ=0+xcJ>Ma0t+3Ric^Aud z7I;v=T^X1%RH0>GBSy}jsjcY z-zzV&gxB)~vDVM^g>1w@gQ(BJ37trlZ>r&MIjH{!#hngZPlDdcE9`q8(?`NvQ#z4W zh<5|AD?q44cK*FC?tLK=B!H-M-~?l2_`lf<|Nrf%^emQ%?>J(vLIjU;iTU`=Nc=k) z&a!yiuCF$K_Ps0kIEn8D1$2TFBark7xP@>c+=!RM1J#E3vrwyT+60)wfDyc!3Uj?$TG5<%?z*hX31a{(hP&iGp0VFp}YWmJ8qg#{=HP1I`d(HJ_3im_@{%Y{7aT^?>p0rhl^D?t!peLh({A}%@>P4DTs2a zrNH)L6m8lT-KEY?R;wD`l~RNC?!_@YJz}a6rUNOZ7CI)%7WIFc5;Jr8KT!4g*Zfcc z1ZN?tGo3m(ViLa$GLIjfrvEh2sgAU9b&5ZSAI;_8?oxq9%7WF)(-dx_!2G-@mxFvG zi%Yf62*ROU;am40JJMlVjhrRQ$a;7DwTBFSeysP(tLHA5yZFiyJ+-AbA!wW#8lJ5S z)L&xePHX^|OQ!Ycb7YBGF_#n(*xEd>5Z@>%DcUxxJOfxX)w=11@@N)x+Lnv{|v zE<0PVYD`ep5@>y$K8%m$`#9=ui3l&Dn5K*a4^z_TgC~}$fz?u~ME9~_wWY!3JPG4I zPR_xIrvYMrkNvtb6f>G~*Yp(xG<#EDQtbX@M?P&Xv~}4TP`$sm&U1Et?y?e6Ua zMJCN?xmN4;84OZJcw%(Frpn()LrnL>Qt_|(O+>E2)G&e$BGUKkJYo6uMdJqcQdQgz zQ#@%q3@O@3;h04N>4kBH0(P?)T>a>K_av?FT_H*hA+mf2hm&I9Fe=4v=5M8aIZew{ zHbezM7@g@!v5e|nF$iQguchK~@ zVMK-q)0Jv(DFN=!E!2kw6%-&EDJb09j#q$>Oe`2h@KU(}7ryg%7%8h}W@{wAfPj>h zR$I~CXqLy3xHb6tuH+EMwkMP2xt&S+Lz+Na|J?MiTS^Z}f*g-{9_jKp}A8sU}~9xJ6X$BIgMXo$W9$+xapLM{KmIgcPf`b zVHxT3#~#x9=}y0eq1rFEUJdt`N`J_#>3G*3NM#~w=j}5a0?eHOK}@|$lD3Dsz9xrm-U18RqL|-b?M1g$ik=z8+a&G&ao5mR zrJvT7c2(3`ZBUw&-8nQgVv}4Yqj!OTb^hgNtYWmmPJ^JQU3T|M)2^!eov;1QLO@w} zZ~N6Xo2?sHA_n`JOA#*3_waL>Th}b?q+FW`NicLTlO2ItXVjIY@-W?3L=V03L>lrZ zO2_nN)}64bi|*UU()qVEp<(6Jx!XBwDD0o7lq7Y2-S`$ywjGkw$hc>i)qM-xRa`>z zDoK>igTnnskE3vCVb8udQukwgpL)bkuI{!T6p)*B=|K59r|+7F&tN2V4(u$Yh7OvT z8Sz4Vcye#jUr4j9_ITbOl&Vo1RMJj1l;ERvJ~%OmyNO*BcL?V~!G0WpK3B#)J#r1_ z8t=L>`=gZv8eY`4n{O%_-6eRuZecn2HD>Tae8=yq7zF+hVmF@oH8ImY9=l{6%fbLkUY{Z?&-hS+mEyDywe$iTLcg zg}&d#3Rr4?(cPoj7h`HS)oi+WeGs~7yEu26y4xm8H1?SrQ6nKqXdUrLv~&7k!Qqv> xRRbH1Z2IDn&o}=$vBvw95B literal 8955 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 mP#ZNmtt%{W?FYpLf~}6 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 xRRbH1Z2IDn&o}=$vBvw95B literal 19226 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 z

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 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 0000000000000000000000000000000000000000..f2d8372808e6eeeb44e71819cc3e7ed0f463feaf GIT binary patch literal 10354 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 u+v++IB literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f6a12b17f03502ab3d78507e865b8a851dd31573 GIT binary patch literal 10960 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 EAG4K$%>V!Z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c31db1fe7b82bff18c0eb649be621352c99243ff GIT binary patch literal 19163 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 literal 0 HcmV?d00001 diff --git a/Apps/iOS/BetterFitApp/Components/AdjustSetsSheet.swift b/Apps/iOS/BetterFitApp/Components/AdjustSetsSheet.swift index 8c1b172..af31278 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, @@ -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..467b0ae 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) @@ -447,4 +373,4 @@ struct ExerciseDetailSheet: View { onSuperset: {}, onUpdate: { _ in } ) -} +} \ No newline at end of file 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/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.swift b/Apps/iOS/BetterFitApp/DesignSystem/BFLedger.swift new file mode 100644 index 0000000..537ec95 --- /dev/null +++ b/Apps/iOS/BetterFitApp/DesignSystem/BFLedger.swift @@ -0,0 +1,544 @@ +import SwiftUI + +// MARK: - Ledger layout 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: 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) + } +} + +// 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() + } +} + +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) + } +} + +// 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.glassFill(for: scheme)) + .overlay { + Capsule().stroke(BFColors.glassBorder(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 yellow action. Standalone = compact centered pill; otherwise fills available width. +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 + var action: () -> Void + + 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(BFColors.accentInk) + .padding(.horizontal, standalone ? 36 : 20) + .frame(maxWidth: standalone ? nil : .infinity) + .frame(height: BFControlSize.buttonLarge) + .background(Capsule(style: .continuous).fill(BFColors.accent)) + .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) + } +} + +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: 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() + } +} + +// 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/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..e8933fd --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Actions.swift @@ -0,0 +1,612 @@ +import BetterFit +import SwiftUI +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - Actions + +extension ActiveSessionView { + + 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: - Order + supersets + + func persistOrder() { + guard var updatedWorkout = workout else { return } + let all = updatedWorkout.exercises + updatedWorkout.exercises = orderedIDs.compactMap { id in all.first(where: { $0.id == id }) } + // Keep any stragglers + let missing = all.filter { !orderedIDs.contains($0.id) } + updatedWorkout.exercises.append(contentsOf: missing) + betterFit?.updateActiveWorkout(updatedWorkout) + } + + func moveExercises(from source: IndexSet, to destination: Int) { + ensureOrderSeeded() + let currentID = current?.id + orderedIDs.move(fromOffsets: source, toOffset: destination) + persistOrder() + if let currentID, let newIdx = orderedIDs.firstIndex(of: currentID) { + exerciseIndex = newIdx + } + } + + func moveExercise(from: Int, to: Int) { + ensureOrderSeeded() + guard exercises.indices.contains(from), + to >= 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() + } + + 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, (pair.load + loadDelta).rounded(toPlaces: 1)) + let newReps = max(1, pair.reps + repsDelta) + updateSet(we: we, index: setIndex, load: newLoad, reps: newReps) + } + + // MARK: - Draft text (keeps fields editable while typing) + + 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) + } + + func headerLoadDraft(for we: WorkoutExercise) -> Binding { + let key = draftKey(we: we, index: -1, kind: "load") + return Binding( + get: { draftLoad[key] ?? formatNum(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] ?? formatNum(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 } + ) + } + + 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(we: WorkoutExercise) { + 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.. 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) + } + + func regionLabel(_ we: WorkoutExercise) -> String { + we.exercise.muscleGroups.first?.rawValue.capitalized ?? "Lift" + } + + func formatNum(_ value: Double) -> String { + value.truncatingRemainder(dividingBy: 1) == 0 + ? String(Int(value)) + : String(format: "%.1f", value) + } +} 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..91dd067 --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sections.swift @@ -0,0 +1,658 @@ +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) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + commitFocusedField(we: we) + focusedField = nil + } + .fontWeight(.semibold) + } + } + } + .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) + } + + // 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: - Sets + + 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: - Session order (all exercises — jump / reorder / superset) + + 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) · \(formatNum(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") + } + } + } + + 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 \(formatNum(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.swift b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift index f248dca..c94b0f7 100644 --- a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift @@ -1,517 +1,184 @@ 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() } - - 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) - - 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 - } - } + /// 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 { + let _ = tick + let secs = Int(Date().timeIntervalSince(startedAt)) + return String(format: "%02d:%02d", secs / 60, secs % 60) } - @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))") - ) - } + var body: some View { + ZStack(alignment: .bottom) { + BFColors.background(for: colorScheme).ignoresSafeArea() - 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) + .onAppear(perform: bootstrap) + .onDisappear { + timer?.invalidate() + restTimer?.invalidate() } - - // Advance. - let nextSet = (currentSetByExercise[we.id] ?? 0) + 1 - currentSetByExercise[we.id] = nextSet - if nextSet >= we.sets.count { - selectNextExercise(after: we.id) + .confirmationDialog("Finish workout?", isPresented: $showFinish, titleVisibility: .visible) { + Button("Finish & save") { finish() } + Button("Keep training", role: .cancel) {} } - - // 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 - } - 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)) - } - - 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) + .sheet(isPresented: $showAddExercise) { + AddExerciseSheet(theme: theme) { planned in + addExercise(planned) } + .presentationDetents([.medium, .large]) } - .buttonStyle(.plain) } } -private struct ActiveSessionField: View { - let label: String - let unit: String - @Binding var binding: String +// MARK: - Summary data - @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)) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } +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] + var recoveryEffects: [(muscle: String, from: Int, to: Int)] = [ + ("Lats", 96, 41), + ("Biceps", 81, 46), + ("Rear delts", 78, 52), + ] } -// MARK: - Local types - -struct LoggedSet: Identifiable { +struct WorkoutSummaryExercise: Identifiable { let id = UUID() - let reps: Int - let weight: Double - let timestamp: Date + let name: String + let best: String + let setsDone: Int + let setsPlanned: Int + let volume: Double + let isPR: Bool } -// 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 +// MARK: - Double rounding - /// 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 +extension Double { + func rounded(toPlaces places: Int) -> Double { + let factor = pow(10.0, Double(places)) + return (self * factor).rounded() / factor } } -/// 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) - } - } - } - - 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..ee8133f --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/WorkoutSummaryView.swift @@ -0,0 +1,236 @@ +import SwiftUI + +// 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) + + BFDockButton(systemImage: "square.and.arrow.up", label: "Share summary") + } + .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() + Button {} label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("Share summary") + } + .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) + .padding(.top, 0) + } + .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") + } + } + + 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), + ] + ) + ) + .preferredColorScheme(.dark) +} 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/Log/LogView.swift b/Apps/iOS/BetterFitApp/Features/Log/LogView.swift new file mode 100644 index 0000000..09621ac --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/Log/LogView.swift @@ -0,0 +1,316 @@ +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) + } + if history.isEmpty { + for index in 0.. 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) : "—" + } +} + +// 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)) - } - } - } - - // 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) } - } + ToolbarItem(placement: .topBarLeading) { + Button("Close") { dismiss() } + .font(BFTypography.subheadlineEmphasis) } - .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) - - 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 - } + // MARK: - Identity - 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) - } - } + BFSectionHeader(title: "Weekly targets") { + Button("Edit") {} + .font(BFTypography.footnoteEmphasis) + .foregroundStyle(BFColors.accentText(for: colorScheme)) } - - 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) - } + 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("\(format(value))") + .font(BFTypography.subheadlineEmphasis) + .monospacedDigit() + Text("/ \(format(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) } - } + private func format(_ value: Double) -> String { + value.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(value)) : String(format: "%.0f", value) } } -// MARK: - Yearly Wrapped View +// MARK: - Compact year heatmap -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 - }) - } - - 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..bde5091 100644 --- a/Apps/iOS/BetterFitApp/Features/Recovery/RecoveryView.swift +++ b/Apps/iOS/BetterFitApp/Features/Recovery/RecoveryView.swift @@ -1,121 +1,173 @@ 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 } + } + + private var overall: Int { + Int(betterFit.bodyMapManager.getOverallRecoveryPercentage().rounded()) + } + + 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 +186,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..0c9369d 100644 --- a/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift +++ b/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift @@ -4,25 +4,25 @@ import SwiftUI 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 +35,22 @@ 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 init( betterFit: BetterFit, @@ -73,17 +72,67 @@ 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 + 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. + if selectedTab == .workout && !showActiveSession { + 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) + } + } + // 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 +142,69 @@ 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) { 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 + ) { 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 +239,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/Targets/TargetsView.swift b/Apps/iOS/BetterFitApp/Features/Targets/TargetsView.swift new file mode 100644 index 0000000..b60d198 --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/Targets/TargetsView.swift @@ -0,0 +1,223 @@ +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? + + private var workoutsDone: Int { + let cal = Calendar.current + let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date())) ?? Date() + return betterFit.getWorkoutHistory().filter { + $0.isCompleted && $0.date >= weekStart + }.count + } + + private var workoutsTarget: Int { 4 } + + 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] { + [ + WeeklyTarget(label: "Workouts", value: Double(workoutsDone), target: Double(workoutsTarget), unit: ""), + WeeklyTarget(label: "Volume", value: 48.2, target: 80, unit: "k"), + WeeklyTarget(label: "Time", value: 2.4, target: 4, unit: "h"), + ] + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + weekSlab + + VStack(alignment: .leading, spacing: 0) { + BFSectionRule(label: "Weekly targets", trailing: "\(targets.count) set") + 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) + } + ToolbarItem(placement: .topBarTrailing) { + Button("Edit") {} + .font(BFTypography.subheadlineEmphasis) + .foregroundStyle(BFColors.accentText(for: colorScheme)) + } + } + } + + // MARK: - Slab + + private var weekSlab: some View { + let left = max(0, workoutsTarget - workoutsDone) + return 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) + Text("/ \(workoutsTarget)") + .font(BFTypography.screenTitle) + .monospacedDigit() + .foregroundStyle(BFColors.identityInk.opacity(0.5)) + } + .padding(.top, 10) + + Text(left == 0 + ? "Weekly goal reached. Good work." + : left == 1 + ? "Workouts done. One more reaches your goal." + : "Workouts done. \(left) more reach your goal.") + .font(BFTypography.bodyEmphasis) + .foregroundStyle(BFColors.identityInk) + .padding(.top, 14) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 5) { + ForEach(0.. some View { + let pct = item.target > 0 ? item.value / item.target : 0 + return VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(item.label) + .font(BFTypography.bodyEmphasis) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Spacer() + Text(format(item.value) + item.unit) + .font(BFTypography.subheadlineEmphasis) + .monospacedDigit() + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Text("/ \(format(item.target))\(item.unit)") + .font(BFTypography.footnote) + .monospacedDigit() + .foregroundStyle(BFColors.textTertiary(for: colorScheme)) + } + 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() + } + + private func format(_ value: Double) -> String { + value.truncatingRemainder(dividingBy: 1) == 0 + ? String(Int(value)) + : String(format: "%.1f", value) + } +} + +#Preview { + NavigationStack { + TargetsView(betterFit: BetterFit(), theme: .defaultTheme) + } + .preferredColorScheme(.dark) +} 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..e40b36c 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,376 @@ 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) + func hasDraft(for focus: PlanField) -> Bool { + switch focus { + case .weight(let id): return draftWeight[id] != nil + case .reps(let id): return draftReps[id] != nil } - - if let duration = workout.duration, duration > 0 { - return formatElapsed(duration) - } - - // 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 - } - return groups.joined(separator: ", ") + func weightDraft(for exercise: PlannedExercise) -> Binding { + Binding( + get: { + if let cached = draftWeight[exercise.id] { return cached } + return exercise.weightValue > 0 ? formatPlanNum(exercise.weightValue) : "" + }, + set: { draftWeight[exercise.id] = $0 } + ) } - 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 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 prettifyMuscleGroup(_ raw: String) -> String { - switch raw { - case "abs": return "Core" - case "quads": return "Quadriceps" - default: - return raw.prefix(1).uppercased() + raw.dropFirst() + 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 = "\(formatPlanNum(value)) kg" + draftWeight[id] = formatPlanNum(value) + persistExercises() } - 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 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 } - .frame(width: 64, height: 52) - .accessibilityLabel(exercise.name) + // 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 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 formatPlanNum(_ value: Double) -> String { + value.truncatingRemainder(dividingBy: 1) == 0 + ? String(Int(value)) + : String(format: "%.1f", value) } - 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 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 groups.joined(separator: ", ") + return parts.joined(separator: " · ") } - func thumbIcon(for exercise: Exercise) -> 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 replaceWorkoutPreview(_ preview: WorkoutPreview) { + exercises = preview.exercises + sessionName = preview.name + if let type = workoutTypeMatching(preview.name) { + workoutType = type } + persistExercises() } - 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) + 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 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 - } - } - - return counts + func showPreview(named name: String) { + guard let preview = previewForName(name) else { return } + workoutPreview = preview } - 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) + func previewForName(_ name: String) -> WorkoutPreview? { + if name == sessionName { + return WorkoutPreview( + name: sessionName, + duration: plannedMinutes, + exercises: exercises ) - .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) - } - .buttonStyle(.plain) - .disabled(isFuture) - } - - var displayRegions: [BodyRegion] { - BodyRegion.allCases.filter { $0 != .other } - } - - 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 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 isDateInCurrentStreak(_ date: Date) -> Bool { - guard currentStreak > 0, let lastWorkoutDate else { - return false - } - - 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..2c2ff2b 100644 --- a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift @@ -1,1168 +1,343 @@ -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 { + Text("Plan") + .font(BFTypography.screenTitle) + .tracking(BFTypography.displayTracking) + .foregroundStyle(BFColors.textPrimary(for: colorScheme)) + Spacer(minLength: 0) } - .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() - } + // MARK: - Header - 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)) - - 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) - } - } + // 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") } - .scrollClipDisabled() - .frame(maxWidth: .infinity, alignment: .leading) - - BFChromeIconButton( - systemImage: "ellipsis", - accessibilityLabel: "Streak summary", - theme: theme - ) { - showStreakSummary = true + Button { + selectedExercise = exercise + } label: { + Label("Edit", systemImage: "pencil") } - } - - // Range selector - HStack { - Menu { - Button("1 Week") { - heatmapRange = .week - } - Button("1 Month") { - heatmapRange = .month + if BFSupersetStore.shared.isLinked(exercise.id) { + Button(role: .destructive) { + BFSupersetStore.shared.unlink(exercise.id) + } label: { + Label("Break superset", systemImage: "link.badge.plus") } - Button("1 Year") { - heatmapRange = .year - } - Divider() - Button("Custom…") { - heatmapRange = .custom - showCustomRangeSheet = true + } else if index + 1 < exercises.count { + Button { + BFSupersetStore.shared.link(exercise.id, exercises[index + 1].id) + } label: { + Label("Superset with next", systemImage: "link") } + } + Button(role: .destructive) { + remove(exercise) } label: { - HStack(spacing: 6) { - Text(heatmapRangeLabel()) - .font(BFTypography.captionEmphasis) - 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) } + Label("Remove", systemImage: "trash") } - - 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) + BFAddRow { showAddExercise = true } } } - 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) - } - } - } + 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) } - } - } - - // 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) - } - } + .overlay(alignment: .leading) { + if focus { + Rectangle() + .fill(BFColors.accent) + .frame(width: 3) + .offset(x: -BFSpacing.pageHorizontal) } } - } - } - - // 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 ?? [] - } - - 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 - } - .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 - ) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + commitWeight(exercise.id) + commitReps(exercise.id) + focusedPlanField = nil } + .fontWeight(.semibold) } } - } - } - - /// 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 - } - ) - } - } - - 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) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + removePlanExercise(exercise) + } label: { + Label("Remove", systemImage: "trash") } - } - } - } - - 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 - ) + Button { + selectedExercise = exercise + } label: { + Label("Replace", systemImage: "arrow.triangle.2.circlepath") } + .tint(BFColors.accent) } - } } - 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 - } - - 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) - } + 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) ) - }, - onDelete: { _ in }, - onReplace: nil, - onSuperset: nil, - onComplete: nil, - onMove: nil, - onAdd: { showAddExerciseSheet = true } - ) - .frame(height: listHeight) + .frame(width: 28, alignment: .leading) - 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) - } - } - } + .buttonStyle(.plain) - 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) - } - - 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 { + Text("SS") + .font(BFTypography.captionEmphasis) + .foregroundStyle(BFColors.accentInk) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(BFColors.accent)) } } + + 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) + 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) } + .padding(.leading, 40) } - 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") - } - .font(.caption) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - workoutPill(workoutType) - workoutPill("Intermediate") + .navigationTitle("Equipment") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showEquipment = false } } - - 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..9e029a7 100644 --- a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift @@ -1,70 +1,65 @@ 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 + +// Bridge BFSupersetStore from WorkoutHomeView's nested modules. +typealias SupersetStore = BFSupersetStore 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? + /// 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 +68,193 @@ 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), + ] + } - Spacer(minLength: 8) + 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) + + 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]) + .modifier(PlanScrollCollapseModifier(isScrolled: $isScrolled)) + .bfBackground(theme: theme) + .toolbar(.hidden, for: .navigationBar) + .safeAreaInset(edge: .top, spacing: 0) { + topBar } - .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]) + .onAppear(perform: loadPlan) + .sheet(isPresented: $showAddExercise) { + AddExerciseSheet(theme: theme) { planned in + exercises.append(planned) + persistExercises() + } + .presentationDetents([.medium, .large]) } - .sheet(isPresented: $showEquipmentSwapSheet) { - EquipmentSwapSheet( - theme: theme, - availableEquipment: $availableEquipment, - onApply: { applyEquipmentSwaps() } - ) - .presentationDetents([PresentationDetent.medium, PresentationDetent.large]) + .sheet(isPresented: $showEquipment) { + equipmentSheet + .presentationDetents([.medium]) } - .sheet(isPresented: $showAddExerciseSheet) { - AddExerciseSheet( - theme: theme, - onAdd: { exercise in - addExerciseToCurrentWorkout(exercise) - } - ) - .presentationDetents([PresentationDetent.medium, PresentationDetent.large]) - } - .sheet(item: $selectedExerciseForDetail) { exercise in + .sheet(item: $selectedExercise) { exercise in ExerciseDetailSheet( exercise: exercise, theme: theme, onDelete: { - deletePlannedExercise(exercise) - }, - onReplace: { - // Handle replace - }, - onSuperset: { - // Handle superset + exercises.removeAll { $0.id == exercise.id } + persistExercises() + selectedExercise = nil }, + onReplace: {}, + onSuperset: {}, onUpdate: { updated in - updatePlannedExercise(updated) + if let exerciseIndex = exercises.firstIndex(where: { $0.id == exercise.id }) { + exercises[exerciseIndex] = updated + persistExercises() + } } ) - .presentationDetents([PresentationDetent.large]) + .presentationDetents([.medium, .large]) } - .onAppear { - ensureDemoSeededIfNeeded() - refreshStatuses() - loadGameStats() - refreshVitals() - refreshActiveWorkout() - startWorkoutTimerIfNeeded() - } - .onDisappear { - stopWorkoutTimer() - } - .onReceive(NotificationCenter.default.publisher(for: .workoutStarted)) { _ in - refreshActiveWorkout() - startWorkoutTimerIfNeeded() - } - .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/design-system/SKILL.md b/design-system/SKILL.md new file mode 100644 index 0000000..86582b7 --- /dev/null +++ b/design-system/SKILL.md @@ -0,0 +1,9 @@ +--- +name: betterfit-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": " 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": " 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": "' + '

' + ''; + 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(' 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(' 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 , 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 /*#__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) { + // 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 0000000000000000000000000000000000000000..c31db1fe7b82bff18c0eb649be621352c99243ff GIT binary patch literal 19163 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 literal 0 HcmV?d00001 diff --git a/design-system/assets/fonts/BBHHegarty-Regular.ttf b/design-system/assets/fonts/BBHHegarty-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ac9a63fe0f85c45746d9e76e9614336fdc4711cb GIT binary patch literal 28752 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 z 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 B*t-A# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..59c5085c0a3bd8400e0c510e6354280f263f9bea GIT binary patch literal 6039 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 literal 0 HcmV?d00001 diff --git a/design-system/assets/icon-lettermark.png b/design-system/assets/icon-lettermark.png new file mode 100644 index 0000000000000000000000000000000000000000..9af7c5a770be0dd00e330390bf97ac20e48ca0c5 GIT binary patch literal 4450 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 EAG4K$%>V!Z literal 0 HcmV?d00001 diff --git a/design-system/assets/logo-stacked.png b/design-system/assets/logo-stacked.png new file mode 100644 index 0000000000000000000000000000000000000000..f2d8372808e6eeeb44e71819cc3e7ed0f463feaf GIT binary patch literal 10354 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 u+v++IB literal 0 HcmV?d00001 diff --git a/design-system/assets/logo.png b/design-system/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..72e7e6021aa789fafb61d868a30ef24c25761732 GIT binary patch literal 31590 zcmeFZcT`kY*EoCtktpaSv49alBMc~25s;2C7C;6JDk5E)2m=a8FJp|72!hVgyM>}6 zAXPd_kc`pFv2tD14{l{aQDLVx(w|N~o>t*8Z z=;dSU;edR6eB_5wZDRpWs~7l6Xjc6mQ!B|c<*7!eP1CKbeWpr$2Ob?x zp3)jVK<>G*>+BoRn1`aV@tO*T5eA8NZHy6#V;h#$4$^<%ur>)us1+KY18ETDa#xc5 zQuXruVZCArzn%0Iy-B0QTh93^WVhy1Rq-k%PR>gg9_A@K3!xb=JI@O-3`U`vvLVUP zL!U~cidr7rhGEf_Q^}y`7nbfJyXn)m7bv~B^E9vESr&Br=)i{o*ETJXAVfEHv$*4 zdNE&#=}Ju;XVDvmwA4y1&D0z6-YKKrUa;dbiE14ly2y)>QdBL_Yic^xA1_=~617oD z_KFaCoiNqlwW>(&7nNGYg*zLUtfw6#xlozZ(r>BWaDFxbJDhj@9<4JSQ|CzIo9zfH~^a5Xo=aH9F!m>BXwOMwD{jNn-G+x%s z86YlvXg!G!$9)&;xVQRk$uQrE=#7d_u?GzD^ZkxFde|i`3t#{?fmJ#MrMq4y4v-T1 zHA9QVO_8h%sliJ-L1n;H9g9GOHcccnFq}N~%O82&USY0oK{AP{ji+)hv$(#p(N`~H zW;WZk|IPtJrs7g-pA0&qT5liFbey#*{XUMNMl30nF`QpmS>knDi#deyD*Vh60{7Dk z^pD~Cpah+b?W))%Rbsto2jj0YAHlROjSA`XBi~3&26$(9FmnXf_!#))a8)(zPOB*O z+Ki)*D(6Nv@+_xuk3r7F%rdH4KG zs39jSUiQM(6Q9DSYpn6ny<7qx( z|9Dv?2p6kHj&3+Q|NVROg!@X-&2{t;eAxHiZY7$6quEUO$;#&!)>4WZpLfMKOQ{7* zy&8 z+#eO)*mGm$UTx5KpC1hkII6@-sM+``aNY4;&mG2Tvu>+>*}|gz=#t&c4m0X@s;0|9 zh3|00n;gh7i~!GH$}fv3s5~%7%7!tF@UQwpg0o5E4clt{pMDTRGBd>z99!vJT+@g) zCRI@3H>PNHVY&pJYfp-MXpzXcZMz0%iL$OltxZi%!PVWWAxw61J;fkFGQR29AVUWy zDfIeoJpCBW(SM&p{xhEU+ZKun7pjg_V#^gmpMTACa_Fy$6F&Pzj&er1`L%0|q?*@0 z+&vIQ%U@PWO)|64Wh<*zu$tFKMLe9w!RION4YgMfscaFq>IA=8FwzV5z3a|ZK?D79 z!9Bm)UxF{K0D#KfdWiuusl_W(lN(15%Qu`vmYQ|h+jcaKM z`8J!P6T=(6X{T)RwriuyJev?u&fl{Am!{=Z7->(38Hu58tpOQs9E@bCW@gTI#Tr&h41 zIm>gXC2%PWf2FGsG*-KsgZ~>*`~$}-oRMjuE{Bta@AWT+$`o z`WhWAJ_a6_NF*_k&Xu(1CwwtOA`y-qYMlC19-FUNe<}nqGPj0vJxi$Bjj!e}{%hR9 zTF>982PPhgro_AMP5!EUYOGU`0P?uuQ;kS&ehktYbjZQmzvQJU(>ko5(-x!s~9T%au z!&whu$tQiq@$Q3(-bUGEF2f>^_%f-Y#_HS*rFcq!t9q2C{Y`D_cN~niFZ1H21)Hpl zOns@Z1e(7R-ojm{knVM^ZzJPfUn!sZ=f5CO9{*P_m>c}p889vU*BLM^{MQ+9E&SIR z{_70r-^*uA)JAl_koksDwuP>&D%k-orKy*U=Y|8TGzp`S(Hxy)Vs|uWMi_vQ+1npIu2{Vl8Q%{&bIQxgC)MEeQB4Z>o za!5;LW01{lZOmM>S4cOvZ(f&m)kq>_`YAC{Y)5l|nExeKSgyh9Qgeyg+_phKqoJeX zA`h@lTFnC`)M7BNb$j{4z=4G&BPsKeX!@m=t8{a#*yVnO61Aa!_HXMv@)a7Di=dI{ zVngk@QidwWopy^5Ln?UPw$Z46#k!|r#jt!*u<6UlS9xuG)*}DGb`5IDN-j~@(o&tC zQn_45AEu(D2E;1?}&#K8(LYT3U?4+tTz_DAl-T+Tb9 zjGIidy)DVtibdCP@qD!|o%Z(7H+hzZx9WPemc~jb_}PVEbmdB<6oO1>&DQ7C^*G-w zXX6Iwn(CYTX6gNn=K4}*6^-W9j%9rsA*jrYy4w1Uy4-y2L9X_|3No%b?D1&h)Th@KkO>VYGJi$Jf^6e9sK}!t#&X*$~Xy>7lKUoLw{E%g+Fr8MDe%oNWRrL(_p}$gEa%xN2ZSx&F*>J?TU8W#QQh8zw(6A~5!aW( zoOChAIX1IZV{h9Prx3M;_<`)Pt0^=4w33r%Gs(fDq54wHwLvYRhZfUt=lQq|Nq-!Z zp_(qc(&~$2NWZlo5+KGdA5LsD+LQc1HV7QY`%S1N?*3i z+m4!>+%?qc6h->&hW0o~oNe3kU>;-RY|_J4o;X?a63N`4P+GvX$*$WK)2a!sJyz=U zFU(pR^P09$0t0vWFehY_-M@OwFlX^7b93L!yX{o#)ob*C&WGCL@!EIX)|Gi))Hki6rC>WleJ=22ezbeOvz?I{QcJHa?JU;vYjaob$}a-IH=(5%)?c`Q z`;nqY^=dy+rXz~4MNqt*f0JZLK2x$s`fAaf+Odi7UU zSD1dLh<4Cfksur6CqbALH=31@Nc@#~$;{PLGyyUB zg3zNUxN$B#2bDoSuNZgHFk@ zfkQ7^_iQy1zj0VxA*>fYq@lWf~UNH9TTcnTHEK{nE2y-`V@ z?F=VFvLIFL@cY%{wQ#qn->*!Gr3}I#Mwu(}G}!g<^yIuosp>|8X43Lh^~5Tl&aAB_ zhK`$&Vf$tC=1h)W)o_ua_qc4b{eIbaQjShJp4jr878R{PDLGuFD43$W3)QPuQu8mJ zhflCHLi+oR7Dj7*A-RA%tXKT{o2+wl-6*V=OGE)9b8dl0VYfcZI<6rnvux zr}9va_;oA=wH8MCtel=-E$ImL0ER5baruJRqOIe-2GrbsV?a^HT4aR9#*(JVi^efv zZ5P~mUMZryo#e!dp3H!-fR3fa!X3WhCE8xs06>Ndunz#h4SMq)d9BYePf7B$Wo}+< z*@DS}hp=>}b8P)EUwmwQJmYQZ#e5G#ri27tuU<+usn5~BtYK;Fa0#SfeOz$q*IQ6S zk4}&_Y4XZHu;Q5w#|DH1jcf&xn7G*-wxPJ#V#8HN?c_wC@|4#7hRUkhyNMuFI=(DK zL10^H`&`Z+Z*Se>dvCkDUQ_w`<*CPnz6S)Mz3(v9rD^h^8J3hdi`k#!KQ$1^@wrdv zONY@n-$p&XnaXriH|gZxD@(__8L!6b=Ytnp$RN^XoXe55e8_VyB(SVx=Hmmcm9NGAzY1$(@-S2olXJABeE9l3rMd1{!+|^OGa|yDwN-i!Z{$dSDdS zM&HNvXm0FUYB()GE8oGz<7LCurSn}7XeiB z9GbwOgAFP2_cJlf=H1GIuhRDqK`E+;e| zO66iry!NhcAMs_z=it!AmJpEJb`F=DNZ@gdEOs@F2y&o>itS1Mqt3Vc1HDhUMD0sj zU1fwew)!5(<_i4@gdU*{*V8N4YqF2C;={Ib)EA9m(Q3)VeZVW0hoZIk*{S!Zdt1d9 z*&jn<7mW!ps`#%_DYF6 zAP9WbVe4&D^LxGK7a0cD@tpq!uoijGOml|Md|$L!&%f+8dlyRu*YWTf(JnzB%~jm> zQWkC!6HB5)gxQKFVdJ1^yKUqJG=sX&fp2Eh2X~XE7cxqMo+7E_VrDzdLrqWvU=^7twULv z6H7~M+x-xyL*00k=veURQW@DZgN*()ollh(-p0;xf)*9A^30PfwfI^@eKuPw5&!y$ z1>4pGruL4m17{gMk9PWG8&r85W^N9D{i#FJrDJMkVAeJtAm%!7r2(&RJVXXz9TmlW&kHSpc3s(lW>TVjpdyMK-b&o!`Bb{da;;Mke zn1aj9+gk4xOK~F9SR;SoDRPm%1#KGd4a0QRQ>?Y{!eL;wT>FFyxa1~0HrO;Co8N=l z<-~~n@5$P19>^v=2*N^_Q_A1+M|ZK7+R!IMv7l_PANBo5ZRtU=CC=tSy5gOEgFy%1 z?@6bD;7G9Offsu9jNN^(^q7iPf`wmDzFnqv8ex2LTDyY9Zq)-A%Ju!|M3#cpWc$W( zkNz!U>{lhAjHdE?bQvR`yE_4b9c_*bvxHZDY35Uq{^ZXt{%)E2rCs31AR8Y8>)WzR z*_=cGLqQHGPS>TKSYO=@ea-QKgqa@Asm|6tFhsc?w|56+T4VlW>pC=W2ctfuSR&*v0}Ia_vg6 z@F)Mr&~?+&XvpH8Z{9`h0Qi<;fZX%L>GVygMV0%;2;StcuG-g;(@o&zz%#Y3FE(5Z zfk5{kSUSn(&?5~}(nz#`H3=t+kJMRv5}cqi#v8I}Awp;qkWM6pil<3^GyLRtPs!cWEvuK|bA2y^pt1Y?oebh>*kh5?D)lWjQ?yhRwR= z^co|iBZBr8UorQneFP$BH5R?5?a%;3@XXaCap(hh&G>YU5@4Cu3ISlrSfZ0J9w8i22eB16Xnch+10oc?{>nog%z5lC3kK*e-S9nN@k-=L`>n(O;BK^g z=ui#3Xd$nq=$Fdzq5IyqWZ*YSYXiDvPKRNij)~OQJxhNn@D<%lWPEq~nP?L1fG;(G zS5qeUL)(ikf?n3`!!k$45*E$}nQ zUu**8aho4k#cLK9akXLuhFEynh~=!!=)}$Vc>9G>t{-XbL#Kuoqd{Ma&Y}SrkBI&bP_6wpj-G5wV?1c9P66h82j(UI|;qA^;i z_?~25ir&oo3`WWZ7>CEm9%cP$WW#HG(+*cwBv0DUq$*_m>Sho3h#=)3m>cbq(E0<4 zGnR4l6oMlMN)E91ccuc5blvF-fTTqWG+Ic;3O$emb3af6VEWOeLyDmD8yJr^3VAa? z1^fbrgn;iCa8@VXcp8Ijk%QUUP)ixd?fVISmw^fYdgr5@z8fiiaPySK2$F!{$Cw^m z)IR@XTv2!O=}IPf@uVHF!wNC4FrGBto?oHJR1L5)a?^YYd4Kg)R=S32FX9k#Y zSPHPTMOhRhbKaIM5m=UmI*+nG+q>EUslUK*pgvnPQcCWjaV0L#woD!G#C#Z`+akz+ zD&h*)mNguzTOY`Y0k(R zKHcVpC`n}LS+72bE8;3o+AtjAc-?Zsc+ph_l1+MBQQ6Cb*pWJh_UVKMH9!@z7b-U| z*X~j?!Fj_5G}<6#m zX05RYq)`HhujNVv=3>S%0I6y510}~Z8$+27D6ix$arjy;&-;F_2oRec(8$9jF=^2C z9QhhoZztDMQo6s`V3r@J*_IKUM_s-$sYFiHbOlsW`01i?1W%e1RJh(vSVr(H2s-wy z4p58-(lh@I!GdEGtm3{0k4Yj8z_DtG7sZL!%VQ{OQHv7k9=7&d!cXHH0Fd-6Pnvsg z73Ng1hh>NJz#4pc9JBX+z&R+f9t90{Vm{~qDtM{487;niiV2Sc)1(pmmYp#CjDpyW z$J%$$PumB-k?uBdgayucUqGDo=^%yG`6h<`ci$i9T_f><^z3*IW%KuI{`PqD`^``1 z1F~<%{(LdAq_}&%z@ua2C&QVuSz7P9&ygSM$Xt-A_OBUh8~x!)`_0+l!y#G5x^C{{}j`GJcoQ3XpLw1QGO z+|)Ppc#Pu>6LF5ZTyYvdf1?rA@om!oWMF!;Br2L-#T*eQiRoy&0uT5+Wk1XX!HlxNxHMmC~UA$lMZ7wdGg|z^86azwOp@1`fP-(5M z!%MEzf4I|Slrk|_B_8v`Qmn(5n2P->C!sp(Jj;!j6U8){!Jqii#X&a4W|XZ)>3$IV zUwrk@{|B4!^D_VUs0f&Y=T%;^#WoNi>sAr&ub<`oxHinh6p|)1Ks9|yT#t%Q;!d%x zN-{S*APNi?i!dj_EZUH0jyuWk3s6x&1*^E{|KSu{8?dE^$h~NXY2yFtG~tGuL6${z zK!&M2{<5!hXgO$C>pf5CR@HgllzwgYr|4esl=30QE} zfu8M^_$lYc^B;iBu+qx&>t2W8c>4k${{-Uv0PL;{4|c> zP5ULs1giLUS@?6n5P-A;aDhD`{E2`L$C{&NCEO4?Q{UMw+V z^a^|A!AWZVmNh?z?=^&CVTz157M{eH|KbYgI=l19zq$7AQ;XL)qt1_9dn!u%aV<-N z%m>R+QluRMvD3&sZ?)Q~FKhm0W~@ zzk-BzaiJAU8$H~x%?IP3D=w7V!GntF|DN(t|35Jl4YTkm+1vtvH|;kW(*tG z;=*urzs^{Xb~yd}cTkD_!+I!ob@wd zfMRQcPPyjxVYy1qa|N?@riiEI^uK@3%kZ_F&UuI}#4Ip(K&KdHkeG=AC*08~ylLkjpCNh%uH_kt zE48Ed>tDnWHO*bF8)`O~14{fzv$H=SJQiLfT{8%aCst-)OU<|<%!kSj@ferB3&7GY z!I6w9!wrI?i71FH3z}0f-eA^d^sayom5g*ISTb!pXv_Rl3e>kRyYbQ#FGaah}3^0_N@ed%4x_uRJY+0^=sY zRk+5b)3{tUMthKdjU%vOYlv|*X^hx4JWGX8KkEMI@*~jk`inPBK$E;q*Yqq6ex_+` zMp`xn{FI!7|0G~+CErCOSNZ1jUcLka*^In=NyWs(TyZ%HW^QdTn(MCwAH3l<`^tW` zg3C$jZ;1Ve3k7x~=Wx;Pp^?9?or#4VU&|htiPFr0%H^6`Ec1vwTX}K>-nHymPf0mj zfE9T{eRtb`hF2q-`E`v%y>+oy$winB%Z~80Yzml48LxPN(Y(kc(Nn(eQjdb9_Km?{ zR>e~(tFXFICQl;vpN+yu|0#CYSa)6;2f&OW6s3$~JI2$pJEdw(+GZE-MvXV^*v7tU zh`$^9c(C=|MA>CP=orCVLt1uMF2=~qHV!6&A&8*HdJjP& zl28dpkODlU}JNrdHL)n058Q2)Dxbm$P-6)fn%xv0J^ka1&j8>`+Q{8dNr_t zG#Y5C!1tubdu^brxY=dSXsbN8v(Wh?zb>KlCJ&|SdjzFFx_-)&b3hENT&rx4G z8rfxJ$E#?bjSaV0uBV8pFScOJ-P}#Uwm=AV{seQLbSigw`az7c`)cQo$H;NF0@#qz z4o_cDKD+Pfv9ElF@xuOK$547WYRI9Epte{Ej0j95v|De}~ zH7%?g<7uLm+&pE28NTQ>l~HpV#Az`NolG4sw8P8tnqsie_?;X_WOL)J9foBIV26&> zpoYyZSR(aG;lZ`@2A8^6&eQVA9;hX8#0jfIv7W?kh? z8zyP0J12lokA$gtQU@crmjmOzC*siy@Q| zri}d_y^BOBngV@3dpFoAnp3%}$a|6=>w5&#R|+9fD{; z^3T&U^#efPb1%+0WJ^Shtde141FF&-KB|S(L%U~S@!WWUn8H6 zotVg#FIVZ0qPcNuqFe$U-GgHf7>@_!p%;t&YxUO9)yqRyPdX)^!k6Pbtt2FprPRl4 zFiN_7-bL(-5e!apupCmR#-nH~B~FtyOX|I1#FU6CY>S|;J0?kX5mO{+b{Y+1c_J%z z6XH`2v0`-&8f2yw7LfFZ8kGs*N`&x5rY6ScEa<4SG_$nZcBDyb)=9|*CWrr&3Q8?QALUk!slqQ&Q1bou7*_fRWI z&iT$JOo@h!r%xF}k;P`k?B|?nKM^9Jc+!Ze+E`o)t#zx*p-;QO5nFL`g}ixhuM(lw zUQDE@$PWO}=zUakn!s21q`A~jL~N!;I@J^2ju7~fUn>)f`sc3kN>Y9=oXt6YGm5gn zK0z60-*PjuPt~GrirAa{dzS~R%s#iTWOZi9_G|;+eX)xepO~W`r&?UfkP*M}@%I9; zMNhKWjap5`i$Bj`l;7TWb`PJLqdzH;3P^6XALL7?DbiqxuHxKLDWAxnsBY2}y5?u| z_E)Z9LN`H&DMro^3zGGY-P@!be@P4&vK0YxM0K1at<7jTQ_ymyC4g4?s!o%g>Sq)6 zI0Y!B!MVoYA!SZM`NMR>$*K~sA01zS=MuF09jEX8wK6+&cL2kpfn(W`gx>4OSw(5C zzDYMxG-=Y3qjiJIRvg7dl)JbpOGEDurLYbP{IWQDuRw}Zs!UjMfYE4tw5)lHuI4{O zjr6KWvPT<1U&>Z}qB4N>giy3?dtz@cU;^bUzxxWi4vE<}B{4cepc9X_q zDOh}_g#eas<67QFji+X-+_^XlUvK7S*Ia8(^oiHt zxg!GLcFY9abIUg)*LRY_zFkfeDSrLz9iR6RW+8?Wnz&-?@zk8z;Z2DRY9D70(rO7w zRRhhMZySM~GThiBM1Iuc#Wc~}b?))7)xniijTvJFz#bomV-KiU6G!eY+@tn6n>er< z%#=9s@=-LsBbThCqEcC%>z73Xw>g0aK#LX$sYQ;2KHml(ls{PDwVpV- zC-QZ;8v!wKg~M7ycQ=Ci3cjkZGvD@?_VL)H z*(^AbmmW?^5cRHU2oaqz#uV7aDMuc zrlJyK;r&;CmP}5dRTmiIe~cF+6OVp7+WmG&I=eR-G^xQkz)Z^)z+f4V+g+3&nY13D zZ;ADPRZ^(q8>~7xr$Xqn5fgdG4ZT+L0Qgu}0F#ADtQwn_);2 zaU??ETAyAG`@JfOl1Uw?)Crg|j_|WNC5$r}7qlJ~zrHVaCusqUb7T0jlvJd4hSyDN zd$CuL=SR6QS&$2vtA0(Fy!GlC55}eh*T6I{s%@N(!orKCZfpUg$|(^FAYVTk!{f6- zUJ7-(g^t6y8r%h`onE#!$W)zgs2vAYjxOTHDtNsPWzXO?U!CtAa; z9u0*($@V<}4D=s4;CZ!ca1ClZ4+Zn5*b%B@cXV^EooBOV!$xqQbGUKi!0Q#sDrEhL zU=Lcz(nYuhS2%Z@apQ4!_^oKt{(VEXj^HQnRIr3sGk%Ge;;f_+Q}JmFc_{9Yv#Jw` z^)j}jsMJZ!5!1{qt_&N^_nmlbeJ)XRjGk`7ZuV3-K|1)z>n2@^pagm3Q9B;ljwv#f zIB%c+WYJ&}ZE6K>dj)qoH8Tcs+zRaBa*~OiSVk7pT^&>0tnlcN~Ci6r695baJTvUJ4(m*O+Y{+&AgC*mti~q0&DLFC$kqd5X;B+jGuwlgvaRIOAy!UDcV5DuyOzy1e3-giCiJ2C z;&&mDZFQ+w3tiD{J#wtG6_Cgcx>HLed^3)Tc--SP@z6Z@v(~b?WEw#w8)SQxTUBTO!ipm>5+}I zR;PpanLCQXUBsBXO;~ozTiJ{n%EVWXd5kX@x-A}pyM=kpDfG-6fKp5FFi!@ zr3OWng`8)FjqcVKpfWHuo>M3fVehgoQd^~~u#{e}W1>!PC>y7$+1R3419$CFW@A&hr9T`o+fD3%a{x>km}Ml*eVsWR8J0G_@h;C6fB85 zI3hZdm?%%uDLJb5(&@xn0$i0F-PonNFznOSn~1F}c+lIN-wfAvpX+q(d^~+IJgkZ2 z_N7N9bceCJUI_RPQ_~z7z{qSflr2-ipjK zP})z{95)Yov|-MYJyXKmyS0qbn0ukXu^R5;KK~}e^3VL}onNyKG1h$XM$Wgp0C3Sn z4lXL}cY_=i!pVhul~%0>(%Xb0p(4YL{bd_JI$idum_g|8W;FLFLYtmgd+$13so5ZS zs*4O8R~lZ{a|HuEJ?ir?XJu5AR#U4lvIff?hwUbx9cHnmhzddeiP19f8?xG0MTo%u zt@5+DkA!=GJ4>}gWnnqiX~<5rh*~BhSB}v%f;}YvkvE{$g;6W#k3;WA?DB;~yNyN>+6T4j+?x|oZ2X9en3=%wRN%VBJL3D3wbY%2;6`!@7|u*d&6R=>fc^?>_-ir8=5 zf9Mkai{`(&;rhaV{pP>H@c)}c(I~fr5=(2oNz{v( zj0$hmJ2M_5Yn_m7aVNV|+3Z=`rOpU{y0~)bSW4?2n>nce8;>{{jpwdZsvanvor`be zQ!Zoqkz=OHKBX>w2$6_g`74*_!|;<%xzdlV-ul&Zgn^X6K_bUupQykSx0x52q~@tB z&5Mj9xwFlL0=vmxd-}#fPSCIR5af@-``=sQ<@WXm9>JekURxWlf6XY zWUZ$g;;L6|SWl)JFYU}~F-S>yvK9ICGF@~#seGoArWWc&)=Tr^F08+rN$z4J`6l9B zjO#|p=WOR@RC=8(??sWPPBx^fR_Qz)W=#tS6Z&-2yg1WAHs%4(n0crgnD`og!9gh|wx5VVd8k`4e4ljCyQ`76{&z zd+9h}Oq+*YNYz~Ow0MY*C9Ps#*X2;g$;jg*E7huFm)Qx^_fQ!ufL`s5B@(8Vi56ET ze0NYoR@|Tc$1nkw5P}<4f%K{`12$` zjoj+dy;a4&#bBu_o6US79)l0V-i5>j&JVRdg!z=a-~|lwlwp%UiB@SuW_{Fg^%(3h z$o$&Nxep-9FR-Uq9OJCNj&XBbroudk>=YFZ5PRMfx43dCHSnjZ@JitcVtR z7h4^jG;eN5s_xX_g%f4OZWqv259aiLa@SYUHde<@(-cp2gzQxt)iVr+zw0mw*MT-1 zz6a$2dF2M%2(hkC4?skGFn? z{Y%vbQrN^Qo%#`tgP?4-$GAIi(*wd)-ieiAIFmqFlQ3*+wag`NZnk zQ6hF-;czO*amcxc@v1~ZxUz8C3L}}8rMi5Y^~bPksS|v(#Eu4f4<6_2u(s)d%axd} zMe?gTXFXmPB=q|E0Kgpp$R-49s*R~8;@w&RskyzX}b@}l=r{d4_{NX|X{u)$_To83&Gs_4IV(^ZEb)%+P- zl|vs0$^JTMH6Ov)VH!(1Gy#ZrtGJWoS67H7y=T>6ZNFyZn;-vt!qp}v`5xyUR8PHa#S+m?IK%3ppJ!iNv3EH;cJZ#;Q1>R-DJNZne zv^&fj;3*q@hXs01)%*>Jh$9L)#m_LyeOl?`kg(?L`ip6hiPFZjqkGxczG|V|Dad1X zHr>RS4F(T4vrD`d#Ps+euD&25AxKsgA5rq&nxhQHT3ey{69Tx@S|&r&xM1fbr^w{dr)UWX*!>-sn+M~DxQ0@ z{+aB%-8(nP$Vj9`_TRRcrxV31cR5$y_dW`}ayX`hXVAMR-HW&E(eD$6Hs;=E4_0kk zF>s*`1UX_2jKDc9GS(l=Uro@6$?kuQ`zQsD&+UhIfK?tRA&S_!fa9zx)xrgukCcKx z8NRZOBE3~u8ds}&*17@Cos+Ko#@u}3<+RI=lf0B&*t&GD0M1DEZ#*|Krkq8eESEHd zd!LpLf`b0ztzpz?nQqO6YL%Qd2}h5K!dcUOY-jk|VXY`id+gm)*WrScoChimq|#o) z4Jxhqzw0HcIil9CMw$29zsz6)mDxhx=y?z4-!-pOlb7EKP~C%bVat8ihkFT1sImJn z)xp3Z)As_{%|F0nYzLj-ZYb7yO8q3Kv^b1>62bXtVq(R(8CgE~y5NW6j^3xRdgBpo za{(Hvi3LXT$>%*L%cM$IP3X$vZmh|WKYcbg-NufHDe|8D-wXZi7diW}o0&KH**+5# z1#D3!&GQdJmxa-6-8B@}QRue}2dbl)*pWAO8f>%EyL%rj`mK9L=ix{U^F7L<=(D}0 zi?_(Z^YQ64_G>t3K9hre8v)m1DVn`^@+Q0|ohRQxoKPNKNMJ-OT$o`w#gbaZrNkkm zEHe>>$IR;a>)*>?;n

(tO85k6Xrd#;3hY_p3YC2b4pLk(6;CN)TH{maMc5P zM>X^eD>qt1S1c9P#>Mi_0Qc`R;nqWmMf=ca&jc)9455poHu!hzo)HK_p?@N$@)xSL zf>)+%zYK|#F+TGdNX2PkH(Szz2|CjF$@(Wbi`$wX^IG>fHs8CRzUeJF^m=WAFe)3o z9Ym{7PO9MSh8EA*523^W^5FCDkApK@14{zn*n=zd=S$oh!z-7k{lp4;#3FU-QpE{| zJcy5#Yb~FgX4M>U^9a@l%xJqGAy&Qd&xdN9@YZ#3ehnwaP35%hRcFoNP?pd0)aBA#$ULK_GyNHq!FmQz)?LN)soPwq)okmiCObe%$V-e;0QdzH| z%Oe=vIID9aY53gT`hIo?UC_GiL6Q84?j{2&XJKje{l(AkrX;GD{3Sc=lY;{jO~kRg z;fiZY2AE?-56=c1Pn>Cn(|gDMC-Citajb!YKJI?7ETx66&dSg~xnG3h6~1-tFjsIX z=$M34ydMS@tyl@rkvf@L7j_SrajZ-BS@I|E4W5`bY|?2vtj8WOt+G@&FsPH5ynR;SR-y#tPy!H6>(77uX(Je z!Q>WbsV4kl44AmGM))LtfLSl)?i)Y9IUkCp8XHS5Z`OyBk}h&A{^e8} z2Qs+k!ga%(vSKglqy}sx+^stt;eMFJTc{+2njklwu6{QJZC_{%GY`ZcCS0MN<;ui^Gf+ac5T7 zb_1zLwY!#x9hFZSC(ENrzgSBn%JVM@!xFii2^9mMy|^62;TN$7;!WwK{=RwQc5&(C zd)n27Dy2iWu)bpQAKDKY*4vRuiGMEE&zyNJ6-C1Q)5LSWGJTUG4cl6Dxb$G|{Np05 zlkD~g4GD$CTPg~{=djC^m?((Qet>?9Z zJbXD~;Imi_eES0gbvysKP@_xvF3xz%k6m|;d><*;Qn+(N>&>5kmX6?i z7OCHJ^QPAQrl&=KDl+9v%{(1cjm9_mfrb??vK=08X=pVc?!<- zJsPO2URp>pkurW8H060v;&jEpQkCk$i-97wkMCOzvf|;d(Ac%zqR}G6g5Ghwjb!jE zr#A04b_TH-oCqZYg}dIosAXm@Au;KaePS%fA-~Qsy#o_5yZh}e>U?VdPftDG+Aq)fK^v#Rh3Fe zO@ZV@S_CPDAuD_RixENEk@k>I>XFnS>-fJ2$(-nJNK9@>O?EBTV_v}sjJeNcGuVU2 zjF#OCZJEk!4IcQINI^?eZ8=S)$1T}aSuTE`R+q<2>thW1rMt~eztv#6bA5>#Ti5-w z!Tk47CSLwWn;fBYkB#+ODNXS+Y{s+0J)o9~QUyyrY#Ok3Y{m(y2{+cg!`P`7}(aV?iyY#e%+4SMH4?>atiw*Wumb9RuA~pL|M!aWlTWY@~ zJ`Dvq)o!5P-9h`1OfA*GqU+5jj!|Ded;f?iCV}{BAT4Aycz@8j{Xwto7YkL4`p&Zx zN7>HwZ1T|%toDn$U>f+av1*3;{CTT^&G~ikCDt=$@ta5;=hbnSlnS%_Hg9@UPh{}; z!Ggwdv-`2#1!F`hp~06# zGiko)QD>x|i^U|x1IqbR!&e31FMk|ws!XF-bkQG2T%37zpDL%SP>Ee78(Dieq|4qn z)4qS)-nU+*Tzhxd>^6E^$~pgSUNPYP6T_;K?u$0G0`2&u;6T=mP3LxDq!)Xy5hOd7 zUD>;xS7&C7x_*wI54WFeqALv-c7@E_U#NfJd?rA8R>#$PxDD? zbsWo#vci<_MGKg+UP!q{f2EkWkU`g;p|1G2c)k~-Uk&h`I^-XUeLs4HASJ%^8*9NJ zF)8S1V#khHAzxM4G=Rr!mDjHdxWejteC%2c#9F0~VM?VF>jhu;_>42g z(;Ycw@?C>*#3?l?KYKH#GECCVEuFf`kQq205n@iu+|kR3Q$4Vd=}t6=iLqMCi*&`z zVk+ioh8qL@Xgwk3cyzZs-m0J{zv{+nEm4V|P9kQeykkfS>gV1|1&4?%n_ILWsCM&s zcO@OaOIta~HPoCxeMIh0g_REbx|^+!M^t%HjotEU6=Ok4we(9l7q;lxM=AOnrvux9 zPtsE$~8_N6_%`*_wMit^c~KdUXcS7Nm-Qrs6KXVgFckkOTU!DD6z;Mop%y+bv^G9BfjaJEt8G9DMq<6H8 z`7g4GdEO>#X|*ZI%b&x@O&sGyLsj|8tP;!BLB?*#zSz9ClMRt73pJ(brY&O-VL^;% zwbT#E-4*Ynh&}`6{)SWj^J<2WzZ0Ftjs4f@flpH1SMALG;d_P0Pis}^PX^{@eyTaU z+*qH`&|B8H?pTb~FS#E4h#l`&KojItHLf+fYKthrp(;<4p=#*_SjETxp;ytV@NwrZ`pcxaZ z#dUR_EkrO1grltvDPSnbg|_*hhSE*hRSJQIM=*5~>qB)0>=%wRx3Jd(l3p+OHJWaw zma)sDb6-hZ>anErbAI#Zulxm3wCDfHZx!^n?jc$)JDFk#5N)tc_#okV8Q^d33kYHI zU-_(!y|1w^UG8SYXc*+C34e!L>Q|pBnmx;6-&F7QcVdkVMB&zCUn`zH|7-g#_zHbI zpd-r4IChFN&?*SiDZ!V!=KI$ES+oiG4jNlOAg#`1xvVTX71GHezn^g2i(2 zZL&}Ksq8kaeF8g$m}0}KAct>50Zq>N_Rh>LS4w9l8C3#YJ1y3o?kL^P<3dEb;_6c_ z^kI4V%6lZ$fs_6iiWY^OWoIV8SNWcF0r;c9kaesAV$?NHx|PXwIax8ctT=o)Os}@K z_U9k7TP(C++Lmq#TKIIwA=qk6Igw0lNIC7#pC|A3Owx>fDWCZgT;T~Ad6S^9q*Ks6 z`mrXN$_H+$18*dp4Vp(}Os!+bgOak|8LYlGphc!K=hq>2*ucv3W|zvL(k0@+N_J{T znsD~nJF;Jjgm>rr^P*GPWLhd`Z|N5ex=vszU0gWE>Qr@4usP1Wvj2cHc`n)7|H%IQ z^Xwxx2nu?W%aM$n0o8@BTla^>#R9X~W&fwWCyQz-+4cmqV>be~FIO=Nh#J751w;{< zqjakQWN4M41)-Y=GDRWGpu`ED3ZjVu5+HzrC^WMmLnJ6eN0~vm2n2-4AOwVj*^pP~ zoOnO)$NTE_mbF-mlN$EkwQE=H;hd^k#X|*PrOmCM;PyTqZ&Q5Q7ar-~B6>6ctzL8) z*99HSK*k`uja-Ru>CG43I*nuf2Bc*6kvJhjG^_4X@l*N4)T`pQWWnR0K5)N=2aF@>h4X^d4!T8kLe*%pdiQ`+n4^d+b_V<{zS1rYdCj&C zA-K|VYifA;sZ6I}TZTLJoiJ21dxyg4Nce+Ac13G61R1G#% z4OU~h+@}p{D~)?t874AddJ)QiUt@5-x07yTB+1 z8^1uyHo<~*RPb(AhNcvhvjZk?et*?1Sn|3?PHWAJTT;OAfY0;^SYx+nls7BNeiy;S z!Z`v7?KcOOgDN8HUF7BtI^P3Kr8y8KHaI`Zw?NQy3%dK-WzsGK7}1T1sj#I9uLiAK zMw-A`Q`&7nbIA2jUI^ipZA{tF=VTbB(;7V99i1G008hSxywd(>LaA&fXtXVQ5|o2X z;hnt)wOG(6plkxz8yRW>j|5~6v)n#KWhxRxFw~G{6V9M3ip}s~Uz!94=9tnXI4%MJ zHc84t^R%tNqIc)g??_$fa|qD1hm=KMNW~}6IZAB+U8*0yu2_!jzk&LXfk1mS-vbYm z1CKQHMIWd)C2q(O1}2gQcct}4a;em&N4O4px6&E3PHqy=i$|vqWj1xjO5mP`;kq*Z zq}gwEmQ+dwmA>;}adX6QKgY;`mM{Zg?&9-Tu^=N2eBcI6{5~8@qqKN+96*&jI|X;y#^=}nD)7|nSdI=$8}GNpsqh2$c-rZpqA1 z&H$^hDcGdLJ;xJuz6hg0&Rc_5hX#4c)t<$on05;opX<;%urVWXpE2y;D+#WC&HWxC z%w<_yaD{IUK2bIeo7jpFhn0eC_M&$sjCp|P6xN7(ewZIaAAVjJ$l}j0KNQOf9*2ED&V5 zkzov2kYNdPmm<_%E>c|*-pI5kj7}Pwy8Qs{v0G$mpS4KP)t|`@vWznpZ{hd`2ZsFP z)Sz$v9at{e*ZpNw;fHU-^IwPO+cf^q($;#vT{?}CB2GV8T)LNa^+8awV;IKGkJIq} zC&%By>UfKbV)E?Ax90g{z6qBynogGPWc}d}yg0;=@d8HZ=0+xcJ>Ma0t+3Ric^Aud z7I;v=T^X1%RH0>GBSy}jsjcY z-zzV&gxB)~vDVM^g>1w@gQ(BJ37trlZ>r&MIjH{!#hngZPlDdcE9`q8(?`NvQ#z4W zh<5|AD?q44cK*FC?tLK=B!H-M-~?l2_`lf<|Nrf%^emQ%?>J(vLIjU;iTU`=Nc=k) z&a!yiuCF$K_Ps0kIEn8D1$2TFBark7xP@>c+=!RM1J#E3vrwyT+60)wfDyc!3Uj?$TG5<%?z*hX31a{(hP&iGp0VFp}YWmJ8qg#{=HP1I`d(HJ_3im_@{%Y{7aT^?>p0rhl^D?t!peLh({A}%@>P4DTs2a zrNH)L6m8lT-KEY?R;wD`l~RNC?!_@YJz}a6rUNOZ7CI)%7WIFc5;Jr8KT!4g*Zfcc z1ZN?tGo3m(ViLa$GLIjfrvEh2sgAU9b&5ZSAI;_8?oxq9%7WF)(-dx_!2G-@mxFvG zi%Yf62*ROU;am40JJMlVjhrRQ$a;7DwTBFSeysP(tLHA5yZFiyJ+-AbA!wW#8lJ5S z)L&xePHX^|OQ!Ycb7YBGF_#n(*xEd>5Z@>%DcUxxJOfxX)w=11@@N)x+Lnv{|v zE<0PVYD`ep5@>y$K8%m$`#9=ui3l&Dn5K*a4^z_TgC~}$fz?u~ME9~_wWY!3JPG4I zPR_xIrvYMrkNvtb6f>G~*Yp(xG<#EDQtbX@M?P&Xv~}4TP`$sm&U1Et?y?e6Ua zMJCN?xmN4;84OZJcw%(Frpn()LrnL>Qt_|(O+>E2)G&e$BGUKkJYo6uMdJqcQdQgz zQ#@%q3@O@3;h04N>4kBH0(P?)T>a>K_av?FT_H*hA+mf2hm&I9Fe=4v=5M8aIZew{ zHbezM7@g@!v5e|nF$iQguchK~@ zVMK-q)0Jv(DFN=!E!2kw6%-&EDJb09j#q$>Oe`2h@KU(}7ryg%7%8h}W@{wAfPj>h zR$I~CXqLy3xHb6tuH+EMwkMP2xt&S+Lz+Na|J?MiTS^Z}f*g-{9_jKp}A8sU}~9xJ6X$BIgMXo$W9$+xapLM{KmIgcPf`b zVHxT3#~#x9=}y0eq1rFEUJdt`N`J_#>3G*3NM#~w=j}5a0?eHOK}@|$lD3Dsz9xrm-U18RqL|-b?M1g$ik=z8+a&G&ao5mR zrJvT7c2(3`ZBUw&-8nQgVv}4Yqj!OTb^hgNtYWmmPJ^JQU3T|M)2^!eov;1QLO@w} zZ~N6Xo2?sHA_n`JOA#*3_waL>Th}b?q+FW`NicLTlO2ItXVjIY@-W?3L=V03L>lrZ zO2_nN)}64bi|*UU()qVEp<(6Jx!XBwDD0o7lq7Y2-S`$ywjGkw$hc>i)qM-xRa`>z zDoK>igTnnskE3vCVb8udQukwgpL)bkuI{!T6p)*B=|K59r|+7F&tN2V4(u$Yh7OvT z8Sz4Vcye#jUr4j9_ITbOl&Vo1RMJj1l;ERvJ~%OmyNO*BcL?V~!G0WpK3B#)J#r1_ z8t=L>`=gZv8eY`4n{O%_-6eRuZecn2HD>Tae8=yq7zF+hVmF@oH8ImY9=l{6%fbLkUY{Z?&-hS+mEyDywe$iTLcg zg}&d#3Rr4?(cPoj7h`HS)oi+WeGs~7yEu26y4xm8H1?SrQ6nKqXdUrLv~&7k!Qqv> xRRbH1Z2IDn&o}=$vBvw95B literal 0 HcmV?d00001 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 ( + + ); +} 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 + + + +``` + +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 ( +

+ {children} +
+ ); +} 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 + +
Today
+
Push A
+
+``` + +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 ( + + ); +} 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 + + +``` + +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
; +} 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 + + +``` + +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 ( + + ); +} 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 + + +``` + +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 ( +
+ {cols.map((col, i) => ( +
+ {col.map((v, j) => ( +
+ ))} +
+ ))} +
+ ); +} 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 + +``` + +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 ( +
+ + + + +
+ {value != null &&
{value}
} + {label &&
{label}
} +
+
+ ); +} 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 + +``` + +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 ( + + + {label} + {percent != null && {percent}%} + + ); +} 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 + + +``` + +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 ( +
+
{percent}%
+
{muscle}
+
+
+
+
+ ); +} 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 +
+ + +
+``` + +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 ( +
+
+ {icon && } + {value} +
+ {label} +
+ ); +} 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 +
+ + +
+``` + +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 ( +
+ + + + +
{children}
+
+ ); +} 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 + + 72% + +``` + +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 ( + + {icon && } +
{value}
+
{label}
+
+ ); +} 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 +
+ + + +
+``` 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 @@ + + + + + + + + + + +
+ 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 ( +
+ +
+
{title}
+ {message &&
{message}
} + {action &&
{action}
} +
+ {onDismiss && ( + + )} +
+ ); +} 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 +Connect} /> +``` + +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 ( +
+ +
{title}
+ {message &&
{message}
} + {action} +
+ ); +} 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 +Add exercise} /> +``` + +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 ( + + + {LABEL[status]} + + ); +} 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 + +``` + +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; 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 ; +} 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 + Chest +``` + +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 ( +
+
+ +
+
Rest
+
{mm}:{ss}
+
+ + +
+
+
+
+
+ ); +} 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 + +``` + +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 @@ + + + + + + + + + + +
+ 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 ( + + {value && {value}} + + + } + {...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 + +``` 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 ( +
+
+ + {done ? : index} + + {!isLast && } +
+
+
{name}
+ {prescription &&
{prescription}
} + {muscles &&
{muscles}
} +
+ {active && Now} +
+ ); +} 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 + + + +``` + +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 ( +
+ {icon && ( + + + + )} +
+
{title}
+ {subtitle &&
{subtitle}
} +
+ {trailing} +
+ ); +} 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 +} /> +``` + +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 ( +
+ {icon && } +
+
{title}
+ {subtitle &&
{subtitle}
} +
+ {kind === 'toggle' && ( + + + + )} + {kind === 'radio' && checked && } + {kind === 'static' && value && {value}} + {kind === 'link' && } +
+ ); +} 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 + + + + +``` + +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 @@ + + + + + + + + + + +
+ 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 ( + + ); +} 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 +
+ + +
+``` + +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 ( +
+ + + {label} · {rounds} rounds + +
+ ); +} 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 + + + +``` 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 ( +
+ {options.map((o) => { + const on = o === value; + return ( + + ); + })} +
+ ); +} 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 + + +``` 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 ( +
+
+ {focus} + {badge && ( + {badge} + )} +
+ +
{name}
+ +
+ {exercises != null && {exercises} exercises} + {duration && {duration}} + {volume && {volume}} +
+ + {muscles.length > 0 && ( +
+ {muscles.map((m) => ( + {m} + ))} +
+ )} +
+ ); +} 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 + +``` + +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 @@ + + + + + + + + + + +
+ diff --git a/design-system/design.md b/design-system/design.md new file mode 100644 index 0000000..2ff2f8d --- /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 16% | `#FFD60A` at 16% | +| Success | `#4ADE80` | `#4ADE80` | +| Warning | `#F5B83D` | `#F5B83D` | +| Danger | `#F04438` | `#F04438` | +| Information / recovered | `#2E90FA` | `#2E90FA` | + +Use semantic colors consistently. Recovery runs from blue for recovered, through green and amber, to red for sore. 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 uses red 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 green #4ADE80 for success, amber #F5B83D for warning as a small indicator only, red #F04438 for danger, and blue #2E90FA for information or recovered status. + +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 @@ + +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 @@ + + +
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..8e4d3d7 --- /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) uses the mono stack. + +Section labels are 12px semibold 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 0000000000000000000000000000000000000000..d6d1331493b9233070ebaade31980fdb0686d188 GIT binary patch literal 8178 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 literal 0 HcmV?d00001 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..2ff2f8d --- /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 16% | `#FFD60A` at 16% | +| Success | `#4ADE80` | `#4ADE80` | +| Warning | `#F5B83D` | `#F5B83D` | +| Danger | `#F04438` | `#F04438` | +| Information / recovered | `#2E90FA` | `#2E90FA` | + +Use semantic colors consistently. Recovery runs from blue for recovered, through green and amber, to red for sore. 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 uses red 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 green #4ADE80 for success, amber #F5B83D for warning as a small indicator only, red #F04438 for danger, and blue #2E90FA for information or recovered status. + +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. From a44a14c3c80ceeff604a3d17cbc0494ea3781b65 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:30:10 +1000 Subject: [PATCH 2/6] chore(skills): drop betterfit- prefix so skills blend together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The betterfit-* skills read as a separate family next to the unprefixed project skills (adr-writing, tdd, swift-ios-teaching, qa). The prefix is redundant inside the BetterFit repo — every skill here is about BetterFit — so drop it and let them sort as one set. - betterfit-code-organization -> code-organization - betterfit-mise-workflows -> mise-workflows - betterfit-supabase-setup -> supabase-setup - betterfit-swift-file-splitting -> swift-file-splitting - betterfit-swiftui-reactivity -> swiftui-reactivity - betterfit-testing -> testing - betterfit-wide-events -> wide-events - betterfit-design -> design Update the cross-references in code-organization, mise-workflows, and remotion-app-showcase that pointed at the old names. Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> --- .../SKILL.md | 4 ++-- .../{betterfit-mise-workflows => mise-workflows}/SKILL.md | 6 +++--- .claude/skills/remotion-app-showcase/SKILL.md | 4 ++-- .../{betterfit-supabase-setup => supabase-setup}/SKILL.md | 2 +- .../SKILL.md | 2 +- .../SKILL.md | 2 +- .claude/skills/{betterfit-testing => testing}/SKILL.md | 2 +- .../skills/{betterfit-wide-events => wide-events}/SKILL.md | 2 +- design-system/SKILL.md | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) rename .claude/skills/{betterfit-code-organization => code-organization}/SKILL.md (98%) rename .claude/skills/{betterfit-mise-workflows => mise-workflows}/SKILL.md (94%) rename .claude/skills/{betterfit-supabase-setup => supabase-setup}/SKILL.md (98%) rename .claude/skills/{betterfit-swift-file-splitting => swift-file-splitting}/SKILL.md (99%) rename .claude/skills/{betterfit-swiftui-reactivity => swiftui-reactivity}/SKILL.md (99%) rename .claude/skills/{betterfit-testing => testing}/SKILL.md (99%) rename .claude/skills/{betterfit-wide-events => wide-events}/SKILL.md (99%) diff --git a/.claude/skills/betterfit-code-organization/SKILL.md b/.claude/skills/code-organization/SKILL.md similarity index 98% rename from .claude/skills/betterfit-code-organization/SKILL.md rename to .claude/skills/code-organization/SKILL.md index effdc4b..77e7b7d 100644 --- a/.claude/skills/betterfit-code-organization/SKILL.md +++ b/.claude/skills/code-organization/SKILL.md @@ -1,5 +1,5 @@ --- -name: betterfit-code-organization +name: code-organization description: Organize Swift code with MARK comments and consistent file structure. Use when refactoring, creating new files, or improving code navigability. --- @@ -181,7 +181,7 @@ final class WorkoutManager: ObservableObject { ### Across files -See `betterfit-swift-file-splitting` skill for when/how to split large files. +See `swift-file-splitting` skill for when/how to split large files. ## Xcode benefits diff --git a/.claude/skills/betterfit-mise-workflows/SKILL.md b/.claude/skills/mise-workflows/SKILL.md similarity index 94% rename from .claude/skills/betterfit-mise-workflows/SKILL.md rename to .claude/skills/mise-workflows/SKILL.md index 52fce83..c676af7 100644 --- a/.claude/skills/betterfit-mise-workflows/SKILL.md +++ b/.claude/skills/mise-workflows/SKILL.md @@ -1,5 +1,5 @@ --- -name: betterfit-mise-workflows +name: mise-workflows description: Fast development workflows for the BetterFit iOS project using mise. Use when building, testing, opening Xcode, or running lint. --- @@ -115,7 +115,7 @@ 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 betterfit-supabase-setup skill) +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 @@ -136,4 +136,4 @@ mise run test - Run `mise run ios:open` to regenerate from `project.yml` ### Build fails after git clean -- `.env` may be deleted — check betterfit-supabase-setup skill for reconfiguration +- `.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 index 2ad94df..27978ed 100644 --- a/.claude/skills/remotion-app-showcase/SKILL.md +++ b/.claude/skills/remotion-app-showcase/SKILL.md @@ -363,8 +363,8 @@ Ensure `TransitionSeries` wraps all scenes. Use `@remotion/transitions` package ## Related skills -- **betterfit-mise-workflows**: Build/render commands via mise (includes `video:*` tasks) -- **betterfit-testing**: Mobile MCP testing for capturing simulator footage +- **mise-workflows**: Build/render commands via mise (includes `video:*` tasks) +- **testing**: Mobile MCP testing for capturing simulator footage ## External resources diff --git a/.claude/skills/betterfit-supabase-setup/SKILL.md b/.claude/skills/supabase-setup/SKILL.md similarity index 98% rename from .claude/skills/betterfit-supabase-setup/SKILL.md rename to .claude/skills/supabase-setup/SKILL.md index 836223e..c6da0a6 100644 --- a/.claude/skills/betterfit-supabase-setup/SKILL.md +++ b/.claude/skills/supabase-setup/SKILL.md @@ -1,5 +1,5 @@ --- -name: betterfit-supabase-setup +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. --- diff --git a/.claude/skills/betterfit-swift-file-splitting/SKILL.md b/.claude/skills/swift-file-splitting/SKILL.md similarity index 99% rename from .claude/skills/betterfit-swift-file-splitting/SKILL.md rename to .claude/skills/swift-file-splitting/SKILL.md index 14b2082..27b0724 100644 --- a/.claude/skills/betterfit-swift-file-splitting/SKILL.md +++ b/.claude/skills/swift-file-splitting/SKILL.md @@ -1,5 +1,5 @@ --- -name: betterfit-swift-file-splitting +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". --- diff --git a/.claude/skills/betterfit-swiftui-reactivity/SKILL.md b/.claude/skills/swiftui-reactivity/SKILL.md similarity index 99% rename from .claude/skills/betterfit-swiftui-reactivity/SKILL.md rename to .claude/skills/swiftui-reactivity/SKILL.md index 21e0558..28bf916 100644 --- a/.claude/skills/betterfit-swiftui-reactivity/SKILL.md +++ b/.claude/skills/swiftui-reactivity/SKILL.md @@ -1,5 +1,5 @@ --- -name: betterfit-swiftui-reactivity +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. --- diff --git a/.claude/skills/betterfit-testing/SKILL.md b/.claude/skills/testing/SKILL.md similarity index 99% rename from .claude/skills/betterfit-testing/SKILL.md rename to .claude/skills/testing/SKILL.md index dea8c0c..84a76c7 100644 --- a/.claude/skills/betterfit-testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -1,5 +1,5 @@ --- -name: betterfit-testing +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. --- diff --git a/.claude/skills/betterfit-wide-events/SKILL.md b/.claude/skills/wide-events/SKILL.md similarity index 99% rename from .claude/skills/betterfit-wide-events/SKILL.md rename to .claude/skills/wide-events/SKILL.md index 489ba61..40a81fd 100644 --- a/.claude/skills/betterfit-wide-events/SKILL.md +++ b/.claude/skills/wide-events/SKILL.md @@ -1,5 +1,5 @@ --- -name: betterfit-wide-events +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. --- diff --git a/design-system/SKILL.md b/design-system/SKILL.md index 86582b7..e5909db 100644 --- a/design-system/SKILL.md +++ b/design-system/SKILL.md @@ -1,5 +1,5 @@ --- -name: betterfit-design +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 --- From 6b93f6527aee3dc61f7f8ca9afb73795e64d8ca9 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:40:12 +1000 Subject: [PATCH 3/6] fix(ios): address design review findings Two-axis review of the design sync (Standards + Spec) surfaced real bugs and drift. This fixes them. Correctness - WorkoutSummaryView showed fabricated recovery numbers: the summary data carried hardcoded Lats/Biceps/Rear-delts defaults that nothing ever overwrote. Recovery effects are now derived from the regions actually trained and the body map's real status; when there is no data the section is hidden rather than invented. - TargetsView displayed hardcoded Volume 48.2/80k and Time 2.4/4h as the user's own stats. Both are now computed from workout history (load volume from weight x reps, time from durations). Targets are derived from the plan and the trailing 4-week average, and a metric with no history renders without a target instead of a fake one. - ProfileView and AppSearchView were unreachable: the Ledger redesign orphaned onProfile/onSearch, which were wired to sheets but never invoked. The top bar now carries a Search button and a Profile avatar, so both are reachable again. Design system conformance - At most one yellow fill per screen (design.md:168): the docked primary now honours AppTheme.quietPrimaryAction and renders as neutral glass when the screen leads with a yellow field. - Quick-action strip dropped glass; glass is reserved for nav, dock and swipe actions. The superset badge is neutral so it no longer competes with the primary action. - Removed the chromatic .midnight/.forest/.sunset themes, which introduced blue/green/red against "do not introduce any additional accent hue". Stored values fall back to the default theme. - Work list rows gained the missing exercise thumbnails. Documentation and tokens - design.md still specified the old multi-hue status palette (green/amber/red/blue, blue-to-red recovery ramp) while the code shipped a yellow ladder. Rewrote both the palette table and the summary prompt; synced the design-system mirror. - Aligned the 15% action tint and the 11px bold eyebrow across design.md, the CSS tokens and BFTypography; added the seven type sizes typography.css claimed but omitted. - Documented the real type stack: BBH Hegarty for display when licensed, native system for UI copy, monospaced digits for numbers. Dead code - Removed no-op controls that lied to the user (Edit, Jump to date, Share summary x2), LogView's synthetic heatmap filler and fake ledger rows, the unused SupersetStore typealias, and the module-wide Double.rounded extension. Code organisation - Split BFLedger and ActiveSessionView into focused extension files and extracted shared number formatting into BFFormat. Tests - Added UI coverage for Log, Targets, Workout Summary and Workout Preview, including a guard that the summary hides recovery data when there is none. - The design sync changed the tab set to Workout/Body/Targets/Log; the suite still asserted "Plan"/"Me"/"Your Training Plan". Retargeted every test to current UI and restored the Profile tests the missing affordance had made untestable. Verified: swift build, 95/95 unit tests, ios:build:ci, build-for-testing for the UI test target, and zero SwiftLint serious violations in any file this work touches. Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> --- .gitignore | 4 + Apps/iOS/BetterFitApp/AppTheme.swift | 28 +- .../Components/AddExerciseSheet.swift | 2 +- .../Components/AdjustSetsSheet.swift | 2 +- .../Components/ExerciseDetailSheet.swift | 2 +- .../Components/UnifiedExerciseTimeline.swift | 2 +- .../BetterFitApp/DesignSystem/BFFormat.swift | 22 + .../DesignSystem/BFLedger+Actions.swift | 197 +++++++ .../DesignSystem/BFLedger+Identity.swift | 153 +++++ .../DesignSystem/BFLedger+Rows.swift | 151 +++++ .../DesignSystem/BFLedger+Surfaces.swift | 76 +++ .../BetterFitApp/DesignSystem/BFLedger.swift | 544 ------------------ .../ActiveSessionView+Actions.swift | 464 ++------------- .../ActiveSessionView+Drafts.swift | 143 +++++ .../ActiveSessionView+Order.swift | 273 +++++++++ .../ActiveSessionView+Sections.swift | 356 +----------- .../ActiveSessionView+Sets.swift | 379 ++++++++++++ .../ActiveWorkout/ActiveSessionView.swift | 39 +- .../ActiveWorkout/WorkoutSummaryView.swift | 94 +-- .../Features/AppSearch/AppSearchView.swift | 2 +- .../AppSearch/SettingDetailView.swift | 2 +- .../Features/Calendar/CalendarSheetView.swift | 2 +- .../Features/Demo/ContentView.swift | 2 +- .../BetterFitApp/Features/Log/LogView.swift | 76 +-- .../Features/Profile/ProfileView.swift | 13 +- .../Features/RootTab/RootTabView.swift | 17 +- .../StreakSummarySheetView.swift | 2 +- .../Features/Targets/TargetsView.swift | 192 +++++-- .../Features/Theme/ThemePickerView.swift | 4 +- .../Features/Trends/ExercisePickerView.swift | 2 +- .../Features/Trends/PlanView.swift | 2 +- .../Features/Trends/TrendsView.swift | 2 +- .../WorkoutHome/WorkoutHomeView+Helpers.swift | 20 +- .../WorkoutHomeView+Sections.swift | 43 +- .../WorkoutHome/WorkoutHomeView.swift | 31 +- .../AdjustSetsUITests.swift | 179 +++--- .../BetterFitAppUITests/LogViewUITests.swift | 92 +++ .../ProfileJourneyUITests.swift | 344 ++++++----- Apps/iOS/BetterFitAppUITests/README.md | 121 ++-- .../TabNavigationUITests.swift | 106 ++-- .../TargetsViewUITests.swift | 106 ++++ .../WorkoutPreviewUITests.swift | 72 +++ .../WorkoutSummaryUITests.swift | 124 ++++ design-system/design.md | 16 +- design-system/readme.md | 4 +- design-system/tokens/base.css | 5 +- design-system/tokens/typography.css | 15 +- design.md | 16 +- 48 files changed, 2584 insertions(+), 1959 deletions(-) create mode 100644 Apps/iOS/BetterFitApp/DesignSystem/BFFormat.swift create mode 100644 Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Actions.swift create mode 100644 Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Identity.swift create mode 100644 Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Rows.swift create mode 100644 Apps/iOS/BetterFitApp/DesignSystem/BFLedger+Surfaces.swift delete mode 100644 Apps/iOS/BetterFitApp/DesignSystem/BFLedger.swift create mode 100644 Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Drafts.swift create mode 100644 Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Order.swift create mode 100644 Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sets.swift create mode 100644 Apps/iOS/BetterFitAppUITests/LogViewUITests.swift create mode 100644 Apps/iOS/BetterFitAppUITests/TargetsViewUITests.swift create mode 100644 Apps/iOS/BetterFitAppUITests/WorkoutPreviewUITests.swift create mode 100644 Apps/iOS/BetterFitAppUITests/WorkoutSummaryUITests.swift 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 c2837d4..a36e379 100644 --- a/Apps/iOS/BetterFitApp/AppTheme.swift +++ b/Apps/iOS/BetterFitApp/AppTheme.swift @@ -43,10 +43,6 @@ enum AppTheme: String, CaseIterable, Identifiable { case bold /// Quieter yellow: accent lives on the docked action only. case classic - /// Legacy storage keys — all resolve to yellow neutrals. - case midnight - case forest - case sunset var id: String { rawValue } @@ -55,9 +51,6 @@ enum AppTheme: String, CaseIterable, Identifiable { case .fitbod: return "Balanced" case .bold: return "Bold" case .classic: return "Restrained" - case .midnight: return "Midnight" - case .forest: return "Forest" - case .sunset: return "Warm" } } @@ -100,22 +93,7 @@ enum AppTheme: String, CaseIterable, Identifiable { } func backgroundBase(for scheme: ColorScheme) -> Color { - switch self { - case .midnight: - return scheme == .dark - ? Color(red: 0.05, green: 0.06, blue: 0.10) - : BFColors.background(for: scheme) - case .forest: - return scheme == .dark - ? Color(red: 0.04, green: 0.09, blue: 0.07) - : BFColors.background(for: scheme) - case .sunset: - return scheme == .dark - ? Color(red: 0.10, green: 0.06, blue: 0.07) - : BFColors.background(for: scheme) - default: - return BFColors.background(for: scheme) - } + BFColors.background(for: scheme) } func backgroundGradient(for scheme: ColorScheme) -> LinearGradient { @@ -144,9 +122,9 @@ extension AppTheme { static let storageKey = "betterfit.appTheme" static let defaultTheme: AppTheme = .fitbod - /// Themes shown in the picker (yellow density + atmosphere). + /// Themes shown in the picker (yellow density). static var selectableThemes: [AppTheme] { - [.fitbod, .bold, .classic, .midnight, .forest, .sunset] + [.fitbod, .bold, .classic] } static func fromStorage(_ rawValue: String?) -> AppTheme { 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 af31278..9d4f492 100644 --- a/Apps/iOS/BetterFitApp/Components/AdjustSetsSheet.swift +++ b/Apps/iOS/BetterFitApp/Components/AdjustSetsSheet.swift @@ -166,7 +166,7 @@ struct AdjustSetsSheet: View { #Preview { AdjustSetsSheet( - theme: .forest, + theme: .fitbod, exercise: PlannedExercise( name: "Bench Press", category: .push, diff --git a/Apps/iOS/BetterFitApp/Components/ExerciseDetailSheet.swift b/Apps/iOS/BetterFitApp/Components/ExerciseDetailSheet.swift index 467b0ae..6c1a437 100644 --- a/Apps/iOS/BetterFitApp/Components/ExerciseDetailSheet.swift +++ b/Apps/iOS/BetterFitApp/Components/ExerciseDetailSheet.swift @@ -367,7 +367,7 @@ struct ExerciseDetailSheet: View { targetWeight: "135 lbs", muscleGroups: ["Chest", "Triceps"] ), - theme: .forest, + theme: .fitbod, onDelete: {}, onReplace: {}, onSuperset: {}, 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/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/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/BFLedger.swift b/Apps/iOS/BetterFitApp/DesignSystem/BFLedger.swift deleted file mode 100644 index 537ec95..0000000 --- a/Apps/iOS/BetterFitApp/DesignSystem/BFLedger.swift +++ /dev/null @@ -1,544 +0,0 @@ -import SwiftUI - -// MARK: - Ledger layout 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: 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) - } -} - -// 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() - } -} - -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) - } -} - -// 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.glassFill(for: scheme)) - .overlay { - Capsule().stroke(BFColors.glassBorder(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 yellow action. Standalone = compact centered pill; otherwise fills available width. -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 - var action: () -> Void - - 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(BFColors.accentInk) - .padding(.horizontal, standalone ? 36 : 20) - .frame(maxWidth: standalone ? nil : .infinity) - .frame(height: BFControlSize.buttonLarge) - .background(Capsule(style: .continuous).fill(BFColors.accent)) - .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) - } -} - -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: 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() - } -} - -// 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/Features/ActiveWorkout/ActiveSessionView+Actions.swift b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Actions.swift index e8933fd..e242147 100644 --- a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Actions.swift +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Actions.swift @@ -8,6 +8,8 @@ import SwiftUI extension ActiveSessionView { + // MARK: - Session lifecycle + func bootstrap() { startedAt = workout?.date ?? Date() // New workout session → wipe kg/reps overrides so this session's @@ -41,344 +43,7 @@ extension ActiveSessionView { } } - // MARK: - Order + supersets - - func persistOrder() { - guard var updatedWorkout = workout else { return } - let all = updatedWorkout.exercises - updatedWorkout.exercises = orderedIDs.compactMap { id in all.first(where: { $0.id == id }) } - // Keep any stragglers - let missing = all.filter { !orderedIDs.contains($0.id) } - updatedWorkout.exercises.append(contentsOf: missing) - betterFit?.updateActiveWorkout(updatedWorkout) - } - - func moveExercises(from source: IndexSet, to destination: Int) { - ensureOrderSeeded() - let currentID = current?.id - orderedIDs.move(fromOffsets: source, toOffset: destination) - persistOrder() - if let currentID, let newIdx = orderedIDs.firstIndex(of: currentID) { - exerciseIndex = newIdx - } - } - - func moveExercise(from: Int, to: Int) { - ensureOrderSeeded() - guard exercises.indices.contains(from), - to >= 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() - } - - 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, (pair.load + loadDelta).rounded(toPlaces: 1)) - let newReps = max(1, pair.reps + repsDelta) - updateSet(we: we, index: setIndex, load: newLoad, reps: newReps) - } - - // MARK: - Draft text (keeps fields editable while typing) - - 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) - } - - func headerLoadDraft(for we: WorkoutExercise) -> Binding { - let key = draftKey(we: we, index: -1, kind: "load") - return Binding( - get: { draftLoad[key] ?? formatNum(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] ?? formatNum(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 } - ) - } - - 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(we: WorkoutExercise) { - 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.. [(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 } - func applyCurrentToAll(_ we: WorkoutExercise) { - // Only unlogged sets from the current set onward. - let start = (0.. Int { @@ -599,14 +237,4 @@ extension ActiveSessionView { if pairs.indices.contains(index) { return pairs[index] } return pairs.last ?? (60, 8) } - - func regionLabel(_ we: WorkoutExercise) -> String { - we.exercise.muscleGroups.first?.rawValue.capitalized ?? "Lift" - } - - func formatNum(_ value: Double) -> String { - value.truncatingRemainder(dividingBy: 1) == 0 - ? String(Int(value)) - : String(format: "%.1f", value) - } } 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..2dc7a50 --- /dev/null +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Drafts.swift @@ -0,0 +1,143 @@ +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(we: WorkoutExercise) { + 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 index 91dd067..da75cb4 100644 --- a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sections.swift +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sections.swift @@ -181,6 +181,10 @@ extension ActiveSessionView { .frame(maxWidth: .infinity) } + func regionLabel(_ we: WorkoutExercise) -> String { + we.exercise.muscleGroups.first?.rawValue.capitalized ?? "Lift" + } + // MARK: - Rest var restBar: some View { @@ -239,355 +243,7 @@ extension ActiveSessionView { .padding(.bottom, 4) } - // MARK: - Sets - - 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: - Session order (all exercises — jump / reorder / superset) - - 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) · \(formatNum(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") - } - } - } + // MARK: - Finish var finishButton: some View { Button { @@ -616,7 +272,7 @@ extension ActiveSessionView { BFDockPrimaryButton( title: complete ? "Next exercise" - : "Log \(formatNum(weight)) × \(reps)", + : "Log \(BFFormat.trimmed(weight)) × \(reps)", systemImage: complete ? "arrow.right" : "checkmark", standalone: false ) { 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 c94b0f7..8cd26ab 100644 --- a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift @@ -92,11 +92,13 @@ struct ActiveSessionView: View { } var elapsedLabel: String { - let _ = tick + _ = tick let secs = Int(Date().timeIntervalSince(startedAt)) return String(format: "%02d:%02d", secs / 60, secs % 60) } + // MARK: - View + var body: some View { ZStack(alignment: .bottom) { BFColors.background(for: colorScheme).ignoresSafeArea() @@ -144,41 +146,6 @@ struct ActiveSessionView: View { } } -// 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] - var recoveryEffects: [(muscle: String, from: Int, to: Int)] = [ - ("Lats", 96, 41), - ("Biceps", 81, 46), - ("Rear delts", 78, 52), - ] -} - -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: - Double rounding - -extension Double { - func rounded(toPlaces places: Int) -> Double { - let factor = pow(10.0, Double(places)) - return (self * factor).rounded() / factor - } -} - #Preview { ActiveSessionView(betterFit: BetterFit(), onEnd: {}) } diff --git a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/WorkoutSummaryView.swift b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/WorkoutSummaryView.swift index ee8133f..93275e2 100644 --- a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/WorkoutSummaryView.swift +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/WorkoutSummaryView.swift @@ -1,5 +1,30 @@ 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 { @@ -43,8 +68,6 @@ struct WorkoutSummaryView: View { } .buttonStyle(.plain) .layoutPriority(1) - - BFDockButton(systemImage: "square.and.arrow.up", label: "Share summary") } .padding(.bottom, 16) } @@ -65,14 +88,6 @@ struct WorkoutSummaryView: View { } .buttonStyle(.plain) Spacer() - Button {} label: { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(BFColors.textSecondary(for: colorScheme)) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .accessibilityLabel("Share summary") } .padding(.horizontal, 16) .padding(.bottom, 12) @@ -112,7 +127,6 @@ struct WorkoutSummaryView: View { Rectangle() .fill(BFColors.identityInk.opacity(0.2)) .frame(height: 1.5) - .padding(.top, 0) } .fixedSize(horizontal: false, vertical: true) } @@ -150,33 +164,35 @@ struct WorkoutSummaryView: View { } } - 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)) + 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) } - 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) } } @@ -229,6 +245,10 @@ struct WorkoutSummaryView: View { 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), ] ) ) 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/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 index 09621ac..c348aec 100644 --- a/Apps/iOS/BetterFitApp/Features/Log/LogView.swift +++ b/Apps/iOS/BetterFitApp/Features/Log/LogView.swift @@ -35,11 +35,6 @@ struct LogView: View { let idx = values.count - 1 - daysAgo values[idx] = min(4, values[idx] + 1) } - if history.isEmpty { - for index in 0.. 0 ? String(format: "%.0f", vol) : "41k" } - private func format(_ value: Double) -> String { - value.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(value)) : String(format: "%.0f", value) - } } // MARK: - Compact year heatmap diff --git a/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift b/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift index 0c9369d..1551928 100644 --- a/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift +++ b/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift @@ -80,6 +80,13 @@ struct RootTabView: View { /// 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)) @@ -151,7 +158,12 @@ struct RootTabView: View { if hasActiveWorkout { activeWorkoutControls } else { - BFDockPrimaryButton(title: "Start workout", systemImage: "play.fill", standalone: false) { + BFDockPrimaryButton( + title: "Start workout", + systemImage: "play.fill", + standalone: false, + quiet: dockIsQuiet + ) { startOrResumeWorkout() } .accessibilityLabel("Start workout") @@ -164,7 +176,8 @@ struct RootTabView: View { BFDockPrimaryButton( title: isWorkoutPaused ? "Resume" : "Pause", systemImage: isWorkoutPaused ? "play.fill" : "pause.fill", - standalone: false + standalone: false, + quiet: dockIsQuiet ) { if isWorkoutPaused { togglePause() 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 index b60d198..1c9dd33 100644 --- a/Apps/iOS/BetterFitApp/Features/Targets/TargetsView.swift +++ b/Apps/iOS/BetterFitApp/Features/Targets/TargetsView.swift @@ -9,15 +9,69 @@ struct TargetsView: View { let theme: AppTheme var planManager: WorkoutPlanManager? - private var workoutsDone: Int { + // MARK: - Data + + private var weekStart: Date { let cal = Calendar.current - let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date())) ?? Date() + 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 >= weekStart - }.count + $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 var workoutsTarget: Int { 4 } + 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() @@ -36,16 +90,49 @@ struct TargetsView: View { private struct WeeklyTarget { let label: String let value: Double - let target: Double + let target: Double? let unit: String } private var targets: [WeeklyTarget] { - [ - WeeklyTarget(label: "Workouts", value: Double(workoutsDone), target: Double(workoutsTarget), unit: ""), - WeeklyTarget(label: "Volume", value: 48.2, target: 80, unit: "k"), - WeeklyTarget(label: "Time", value: 2.4, target: 4, unit: "h"), + 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 { @@ -54,7 +141,7 @@ struct TargetsView: View { weekSlab VStack(alignment: .leading, spacing: 0) { - BFSectionRule(label: "Weekly targets", trailing: "\(targets.count) set") + BFSectionRule(label: "Weekly targets", trailing: targetsSectionMeta) ForEach(Array(targets.enumerated()), id: \.offset) { _, item in targetRow(item) } @@ -96,78 +183,90 @@ struct TargetsView: View { .font(BFTypography.screenTitle) .tracking(BFTypography.displayTracking) } - ToolbarItem(placement: .topBarTrailing) { - Button("Edit") {} - .font(BFTypography.subheadlineEmphasis) - .foregroundStyle(BFColors.accentText(for: colorScheme)) - } } } // MARK: - Slab private var weekSlab: some View { - let left = max(0, workoutsTarget - workoutsDone) - return BFSlab { + 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) - Text("/ \(workoutsTarget)") - .font(BFTypography.screenTitle) - .monospacedDigit() - .foregroundStyle(BFColors.identityInk.opacity(0.5)) + if let workoutsTarget { + Text("/ \(workoutsTarget)") + .font(BFTypography.screenTitle) + .monospacedDigit() + .foregroundStyle(BFColors.identityInk.opacity(0.5)) + } } .padding(.top, 10) - Text(left == 0 - ? "Weekly goal reached. Good work." - : left == 1 - ? "Workouts done. One more reaches your goal." - : "Workouts done. \(left) more reach your goal.") + Text(slabMessage) .font(BFTypography.bodyEmphasis) .foregroundStyle(BFColors.identityInk) .padding(.top, 14) .fixedSize(horizontal: false, vertical: true) - HStack(spacing: 5) { - ForEach(0.. some View { - let pct = item.target > 0 ? item.value / item.target : 0 - return VStack(alignment: .leading, spacing: 7) { + VStack(alignment: .leading, spacing: 7) { HStack(alignment: .firstTextBaseline, spacing: 12) { Text(item.label) .font(BFTypography.bodyEmphasis) .foregroundStyle(BFColors.textPrimary(for: colorScheme)) Spacer() - Text(format(item.value) + item.unit) + Text(BFFormat.trimmed(item.value) + item.unit) .font(BFTypography.subheadlineEmphasis) .monospacedDigit() .foregroundStyle(BFColors.textPrimary(for: colorScheme)) - Text("/ \(format(item.target))\(item.unit)") - .font(BFTypography.footnote) + 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.textTertiary(for: colorScheme)) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) } - 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) { @@ -208,11 +307,6 @@ struct TargetsView: View { return String(formatter.string(from: date).prefix(2)).uppercased() } - private func format(_ value: Double) -> String { - value.truncatingRemainder(dividingBy: 1) == 0 - ? String(Int(value)) - : String(format: "%.1f", value) - } } #Preview { 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/WorkoutHomeView+Helpers.swift b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Helpers.swift index e40b36c..c7b95a6 100644 --- a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Helpers.swift +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Helpers.swift @@ -81,7 +81,7 @@ extension WorkoutHomeView { Binding( get: { if let cached = draftWeight[exercise.id] { return cached } - return exercise.weightValue > 0 ? formatPlanNum(exercise.weightValue) : "" + return exercise.weightValue > 0 ? BFFormat.trimmed(exercise.weightValue) : "" }, set: { draftWeight[exercise.id] = $0 } ) @@ -109,8 +109,8 @@ extension WorkoutHomeView { persistExercises() return } - exercises[exerciseIndex].targetWeight = "\(formatPlanNum(value)) kg" - draftWeight[id] = formatPlanNum(value) + exercises[exerciseIndex].targetWeight = "\(BFFormat.trimmed(value)) kg" + draftWeight[id] = BFFormat.trimmed(value) persistExercises() } @@ -132,10 +132,16 @@ extension WorkoutHomeView { persistExercises() } - func formatPlanNum(_ value: Double) -> String { - value.truncatingRemainder(dividingBy: 1) == 0 - ? String(Int(value)) - : String(format: "%.1f", value) + /// 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" } + return exercise.category.icon } func meta(for exercise: PlannedExercise, index: Int, focus: Bool) -> String { diff --git a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift index 2c2ff2b..df4216b 100644 --- a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift @@ -8,12 +8,25 @@ extension WorkoutHomeView { // MARK: - Top bar (title only) var topBar: some View { - HStack { + 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(.horizontal, BFSpacing.pageHorizontal) .padding(.bottom, 14) @@ -166,6 +179,8 @@ extension WorkoutHomeView { ) .frame(width: 28, alignment: .leading) + exerciseThumbTile(exercise) + Button { selectedExercise = exercise } label: { @@ -189,12 +204,17 @@ extension WorkoutHomeView { .foregroundStyle(BFColors.textTertiary(for: colorScheme)) 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.accentInk) + .foregroundStyle(BFColors.textSecondary(for: colorScheme)) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(Capsule().fill(BFColors.accent)) + .background(Capsule().fill(BFColors.surfaceRaised(for: colorScheme))) + .overlay { + Capsule().stroke(BFColors.border(for: colorScheme), lineWidth: 1) + } } } @@ -202,6 +222,20 @@ extension WorkoutHomeView { } } + /// 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( @@ -223,7 +257,8 @@ extension WorkoutHomeView { ) Spacer(minLength: 0) } - .padding(.leading, 40) + // Align under the text column: 28pt gutter + 40pt thumbnail + 2×12pt gaps. + .padding(.leading, 92) } // MARK: - Start something else diff --git a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift index 9e029a7..666281d 100644 --- a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift @@ -7,9 +7,6 @@ import SwiftUI // MARK: - Plan (Workout tab) — Ledger layout -// Bridge BFSupersetStore from WorkoutHomeView's nested modules. -typealias SupersetStore = BFSupersetStore - struct WorkoutHomeView: View { @Environment(\.colorScheme) var colorScheme @@ -32,6 +29,8 @@ struct WorkoutHomeView: View { @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. @@ -157,7 +156,14 @@ struct WorkoutHomeView: View { .onAppear(perform: loadPlan) .sheet(isPresented: $showAddExercise) { AddExerciseSheet(theme: theme) { planned in - exercises.append(planned) + if let targetId = replaceTargetId, + let targetIndex = exercises.firstIndex(where: { $0.id == targetId }) + { + exercises[targetIndex] = planned + replaceTargetId = nil + } else { + exercises.append(planned) + } persistExercises() } .presentationDetents([.medium, .large]) @@ -175,8 +181,21 @@ struct WorkoutHomeView: View { persistExercises() selectedExercise = nil }, - onReplace: {}, - onSuperset: {}, + onReplace: { + // Swap this exercise in place via the add-exercise picker. + replaceTargetId = exercise.id + showAddExercise = true + }, + onSuperset: { + // 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 if let exerciseIndex = exercises.firstIndex(where: { $0.id == exercise.id }) { exercises[exerciseIndex] = updated 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/design.md b/design-system/design.md index 2ff2f8d..2057751 100644 --- a/design-system/design.md +++ b/design-system/design.md @@ -100,13 +100,13 @@ The training interface is dark-first, calm, and functional. Yellow carries the p | Tertiary text | `#63636D` | approximately `#94949E` | | Primary action | `#FFD60A` | `#FFD60A` | | Action ink | `#000000` | `#000000` | -| Action tint (subtle) | `#FFD60A` at 16% | `#FFD60A` at 16% | -| Success | `#4ADE80` | `#4ADE80` | -| Warning | `#F5B83D` | `#F5B83D` | -| Danger | `#F04438` | `#F04438` | -| Information / recovered | `#2E90FA` | `#2E90FA` | +| 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. Recovery runs from blue for recovered, through green and amber, to red for sore. Never rely on color alone to communicate status. +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. @@ -163,7 +163,7 @@ Assume one hand, in a gym, mid-session. ### 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 uses red and must be explicit. +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. @@ -232,7 +232,7 @@ Design a [screen, flow, campaign, or asset] for BetterFit, a personal strength t 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 green #4ADE80 for success, amber #F5B83D for warning as a small indicator only, red #F04438 for danger, and blue #2E90FA for information or recovered status. +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. diff --git a/design-system/readme.md b/design-system/readme.md index 8e4d3d7..38b01cf 100644 --- a/design-system/readme.md +++ b/design-system/readme.md @@ -121,9 +121,9 @@ Tinted surfaces are the accent or a ladder step at **15%** opacity (recovery bad ### 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) uses the mono stack. +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 12px semibold uppercase at `0.1em` tracking in secondary text. Nothing else is uppercase. +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. diff --git a/design-system/tokens/base.css b/design-system/tokens/base.css index f0e1ebe..809bbf5 100644 --- a/design-system/tokens/base.css +++ b/design-system/tokens/base.css @@ -4,6 +4,9 @@ body{margin:0;background:var(--bg-page);color:var(--text-primary);font-family:va a{color:var(--accent-text);text-decoration:none} a:hover{color:var(--text-primary)} button{font-family:inherit} +/* Branded display — BBH Hegarty when licensed (var(--font-display)). */ .bf-display{font-family:var(--font-display);letter-spacing:var(--tracking-display);line-height:var(--leading-tight)} -.bf-label{font-size:var(--text-caption);font-weight:var(--weight-semibold);text-transform:uppercase;letter-spacing:var(--tracking-label);color:var(--text-secondary)} +/* Uppercase micro label — BFTypography.eyebrow: 11px bold. */ +.bf-label{font-size:11px;font-weight:var(--weight-bold);text-transform:uppercase;letter-spacing:var(--tracking-label);color:var(--text-secondary)} +/* Numbers on the native system typeface with monospaced (tabular) digits. */ .bf-num{font-variant-numeric:tabular-nums;font-feature-settings:"tnum" 1} diff --git a/design-system/tokens/typography.css b/design-system/tokens/typography.css index 1c2a67c..27d234c 100644 --- a/design-system/tokens/typography.css +++ b/design-system/tokens/typography.css @@ -1,25 +1,32 @@ -/* iOS point scale from BFTypography.swift, carried 1:1 into px. */ +/* iOS point scale from BFTypography.swift — every size in that file, carried 1:1 into px. */ :root{ - /* Display / titles */ + /* Display / titles — BBH Hegarty when licensed, heavy rounded system face as fallback */ --text-hero:56px; /* redesign: full-bleed identity headline */ + --text-display:44px; /* BFTypography.display — slab / session titles */ --text-large-title:34px; --text-title-1:28px; + --text-screen-title:26px; /* BFTypography.screenTitle — screen head (Plan / Body / Log) */ --text-title-2:22px; --text-title-3:20px; - /* Body */ + /* UI copy — native system typeface */ --text-headline:17px; --text-body:17px; --text-callout:16px; --text-subheadline:15px; --text-footnote:13px; --text-caption:12px; + --text-eyebrow:11px; /* BFTypography.eyebrow — uppercase micro label */ - /* Numeric (monospaced digits — timers, weights, reps, stats) */ + /* Numeric — native system face with monospaced digits (timers, weights, reps, stats) */ + --text-readout:64px; --text-timer:40px; + --text-stepper-value:34px; --text-stat-lg:28px; + --text-spec-value:22px; --text-stat-md:20px; --text-stat-sm:15px; + --text-gutter:15px; --weight-regular:400; --weight-medium:500; diff --git a/design.md b/design.md index 2ff2f8d..2057751 100644 --- a/design.md +++ b/design.md @@ -100,13 +100,13 @@ The training interface is dark-first, calm, and functional. Yellow carries the p | Tertiary text | `#63636D` | approximately `#94949E` | | Primary action | `#FFD60A` | `#FFD60A` | | Action ink | `#000000` | `#000000` | -| Action tint (subtle) | `#FFD60A` at 16% | `#FFD60A` at 16% | -| Success | `#4ADE80` | `#4ADE80` | -| Warning | `#F5B83D` | `#F5B83D` | -| Danger | `#F04438` | `#F04438` | -| Information / recovered | `#2E90FA` | `#2E90FA` | +| 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. Recovery runs from blue for recovered, through green and amber, to red for sore. Never rely on color alone to communicate status. +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. @@ -163,7 +163,7 @@ Assume one hand, in a gym, mid-session. ### 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 uses red and must be explicit. +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. @@ -232,7 +232,7 @@ Design a [screen, flow, campaign, or asset] for BetterFit, a personal strength t 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 green #4ADE80 for success, amber #F5B83D for warning as a small indicator only, red #F04438 for danger, and blue #2E90FA for information or recovered status. +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. From c44120f3db0418c2190bd2a07d786e570d889abf Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:13:18 +1000 Subject: [PATCH 4/6] fix(ios): one keyboard Done button per screen The keyboard toolbar was attached inside the exercise-row view, so every row registered its own Done button. Four exercises meant four buttons stacked into the keyboard accessory, rendering as overlapping yellow pills over the tab bar. Hoist the toolbar to the screen level so there is exactly one: - WorkoutHomeView commits whichever plan field has focus, via a new commitFocusedPlanField() over PlanField. - ActiveSessionView's commitFocusedField() now resolves the exercise from `current` instead of taking it per row. Verified in the simulator: tapping a weight field shows a single Done, and tapping it dismisses the keyboard and commits the draft. Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> --- .../ActiveWorkout/ActiveSessionView+Drafts.swift | 3 ++- .../ActiveWorkout/ActiveSessionView+Sections.swift | 10 ---------- .../Features/ActiveWorkout/ActiveSessionView.swift | 10 ++++++++++ .../WorkoutHome/WorkoutHomeView+Helpers.swift | 11 +++++++++++ .../WorkoutHome/WorkoutHomeView+Sections.swift | 11 ----------- .../Features/WorkoutHome/WorkoutHomeView.swift | 10 ++++++++++ 6 files changed, 33 insertions(+), 22 deletions(-) diff --git a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Drafts.swift b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Drafts.swift index 2dc7a50..3ef161b 100644 --- a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Drafts.swift +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Drafts.swift @@ -116,7 +116,8 @@ extension ActiveSessionView { updateSet(we: we, index: index, load: pair.load, reps: value) } - func commitFocusedField(we: WorkoutExercise) { + func commitFocusedField() { + guard let we = current else { return } switch focusedField { case .headerLoad: commitHeaderLoad(we: we) case .headerReps: commitHeaderReps(we: we) diff --git a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sections.swift b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sections.swift index da75cb4..d160048 100644 --- a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sections.swift +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView+Sections.swift @@ -109,16 +109,6 @@ extension ActiveSessionView { ) } .padding(.top, 10) - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { - commitFocusedField(we: we) - focusedField = nil - } - .fontWeight(.semibold) - } - } } .padding(.horizontal, BFSpacing.pageHorizontal) .padding(.top, 4) diff --git a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift index 8cd26ab..32fc7d3 100644 --- a/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift +++ b/Apps/iOS/BetterFitApp/Features/ActiveWorkout/ActiveSessionView.swift @@ -133,6 +133,16 @@ struct ActiveSessionView: View { timer?.invalidate() restTimer?.invalidate() } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + commitFocusedField() + focusedField = nil + } + .fontWeight(.semibold) + } + } .confirmationDialog("Finish workout?", isPresented: $showFinish, titleVisibility: .visible) { Button("Finish & save") { finish() } Button("Keep training", role: .cancel) {} diff --git a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Helpers.swift b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Helpers.swift index c7b95a6..b73996b 100644 --- a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Helpers.swift +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Helpers.swift @@ -97,6 +97,17 @@ extension WorkoutHomeView { ) } + func commitFocusedPlanField() { + switch focusedPlanField { + case .weight(let id): + commitWeight(id) + case .reps(let id): + commitReps(id) + case nil: + break + } + } + func commitWeight(_ id: UUID) { guard let exerciseIndex = exercises.firstIndex(where: { $0.id == id }) else { return } let raw = draftWeight[id] ?? "" diff --git a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift index df4216b..52f6bf7 100644 --- a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView+Sections.swift @@ -130,17 +130,6 @@ extension WorkoutHomeView { .offset(x: -BFSpacing.pageHorizontal) } } - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { - commitWeight(exercise.id) - commitReps(exercise.id) - focusedPlanField = nil - } - .fontWeight(.semibold) - } - } .swipeActions(edge: .leading) { Button { if index + 1 < exercises.count { diff --git a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift index 666281d..78a9f27 100644 --- a/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift +++ b/Apps/iOS/BetterFitApp/Features/WorkoutHome/WorkoutHomeView.swift @@ -150,6 +150,16 @@ struct WorkoutHomeView: View { .modifier(PlanScrollCollapseModifier(isScrolled: $isScrolled)) .bfBackground(theme: theme) .toolbar(.hidden, for: .navigationBar) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + commitFocusedPlanField() + focusedPlanField = nil + } + .fontWeight(.semibold) + } + } .safeAreaInset(edge: .top, spacing: 0) { topBar } From ff2ef047bae142feba1bae0952501a7fa703b7fa Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:30:46 +1000 Subject: [PATCH 5/6] fix(ios): hide floating Start pill while the keyboard is up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing a weight dragged the floating Start workout pill to mid-screen, where it sat on top of the exercise rows directly above the keyboard. The dock is an overlay on the whole TabView, so keyboard avoidance moved it with the content — and a start-workout CTA is not the right affordance while typing anyway. Track keyboard visibility from the standard notifications and drop the dock while it is up, so only the keyboard accessory Done remains. Verified in the simulator: with a weight field focused the pill is gone and the rows stay readable; dismissing the keyboard restores it. Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> --- .../Features/RootTab/RootTabView.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift b/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift index 1551928..b3ec582 100644 --- a/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift +++ b/Apps/iOS/BetterFitApp/Features/RootTab/RootTabView.swift @@ -1,6 +1,9 @@ import Auth import BetterFit import SwiftUI +#if canImport(UIKit) + import UIKit +#endif enum AppTab: String, CaseIterable { case workout @@ -51,6 +54,7 @@ struct RootTabView: View { @State private var planManager = WorkoutPlanManager() @State private var summaryData: WorkoutSummaryData? @State private var showSummary = false + @State private var isKeyboardVisible = false init( betterFit: BetterFit, @@ -92,8 +96,10 @@ struct RootTabView: View { .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. - if selectedTab == .workout && !showActiveSession { + // 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) @@ -106,6 +112,14 @@ struct RootTabView: View { 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 { From ab2d10e4690580bda8ef0f7bbada191b3f079d90 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:22:05 +1000 Subject: [PATCH 6/6] fix(ios): make recovery headline and training time agree with their data Two display bugs found while walking the app in the simulator. Body tab: the headline read 100% while every muscle row read 96%. The headline came from the model's getOverallRecoveryPercentage() and the rows from RecoveryStatus.bfReadiness (recovered = 0.96), two different mappings. Aggregate the same per-region readiness the rows render so the two can no longer disagree. Log tab: "Time training 1 hours". Pluralise the unit. Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> --- Apps/iOS/BetterFitApp/Features/Log/LogView.swift | 6 +++++- Apps/iOS/BetterFitApp/Features/Recovery/RecoveryView.swift | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Apps/iOS/BetterFitApp/Features/Log/LogView.swift b/Apps/iOS/BetterFitApp/Features/Log/LogView.swift index c348aec..2d3b6b0 100644 --- a/Apps/iOS/BetterFitApp/Features/Log/LogView.swift +++ b/Apps/iOS/BetterFitApp/Features/Log/LogView.swift @@ -81,7 +81,7 @@ struct LogView: View { BFLedgerTrailing(value: totalVolumeLabel, meta: "kg") } BFLedgerRow(systemImage: "clock.fill", title: "Time training") { - BFLedgerTrailing(value: totalHoursLabel, meta: "hours") + BFLedgerTrailing(value: totalHoursLabel, meta: totalHoursUnit) } } .padding(.horizontal, BFSpacing.pageHorizontal) @@ -221,6 +221,10 @@ struct LogView: View { let hours = secs / 3600 return hours > 0 ? String(format: "%.0f", hours) : "—" } + + private var totalHoursUnit: String { + totalHoursLabel == "1" ? "hour" : "hours" + } } // MARK: - Compact contribution heatmap (Log) diff --git a/Apps/iOS/BetterFitApp/Features/Recovery/RecoveryView.swift b/Apps/iOS/BetterFitApp/Features/Recovery/RecoveryView.swift index bde5091..6647757 100644 --- a/Apps/iOS/BetterFitApp/Features/Recovery/RecoveryView.swift +++ b/Apps/iOS/BetterFitApp/Features/Recovery/RecoveryView.swift @@ -21,8 +21,13 @@ struct RecoveryView: View { .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 { - Int(betterFit.bodyMapManager.getOverallRecoveryPercentage().rounded()) + guard !regions.isEmpty else { + return Int(betterFit.bodyMapManager.getOverallRecoveryPercentage().rounded()) + } + return regions.reduce(0) { $0 + $1.percent } / regions.count } private var readyNames: [String] {