From 42b7392f3975aa6a58f79e6da7a202ddd3c78ee7 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 12:22:41 -0500 Subject: [PATCH] P0.1: iOS reference artifacts (colors, assets, screens inventory) --- docs/ios-parity/assets.json | 57 +++++ docs/ios-parity/colors.json | 423 +++++++++++++++++++++++++++++++++ docs/ios-parity/screens.json | 409 +++++++++++++++++++++++++++++++ scripts/extract_ios_assets.py | 147 ++++++++++++ scripts/extract_ios_colors.py | 260 ++++++++++++++++++++ scripts/extract_ios_screens.py | 139 +++++++++++ 6 files changed, 1435 insertions(+) create mode 100644 docs/ios-parity/assets.json create mode 100644 docs/ios-parity/colors.json create mode 100644 docs/ios-parity/screens.json create mode 100644 scripts/extract_ios_assets.py create mode 100644 scripts/extract_ios_colors.py create mode 100644 scripts/extract_ios_screens.py diff --git a/docs/ios-parity/assets.json b/docs/ios-parity/assets.json new file mode 100644 index 0000000..1ad131f --- /dev/null +++ b/docs/ios-parity/assets.json @@ -0,0 +1,57 @@ +{ + "image_sets": [ + { + "name": "icon", + "path": "iosApp/iosApp/Assets.xcassets/icon.imageset", + "files": [ + "HoneyDue-01-Standard@2x.png" + ], + "format": "png" + }, + { + "name": "outline", + "path": "iosApp/iosApp/Assets.xcassets/outline.imageset", + "files": [ + "outline.pdf" + ], + "format": "pdf" + }, + { + "name": "tab_view", + "path": "iosApp/iosApp/Assets.xcassets/tab_view.imageset", + "files": [ + "outline_1x.png", + "outline_2x.png", + "outline_3x.png" + ], + "format": "png" + } + ], + "app_icons": [ + { + "name": "AppIcon", + "path": "iosApp/iosApp/Assets.xcassets/AppIcon.appiconset", + "sizes": [ + "1024x1024 (universal)", + "1024x1024 (universal)", + "1024x1024 (universal)" + ], + "files": [ + "HoneyDue-01-Standard@2x.png" + ] + }, + { + "name": "AppIcon", + "path": "iosApp/HoneyDue/Assets.xcassets/AppIcon.appiconset", + "sizes": [ + "1024x1024 (universal)", + "1024x1024 (universal)", + "1024x1024 (universal)" + ], + "files": [ + "icon.png" + ] + } + ], + "widget_assets": [] +} diff --git a/docs/ios-parity/colors.json b/docs/ios-parity/colors.json new file mode 100644 index 0000000..2beea1e --- /dev/null +++ b/docs/ios-parity/colors.json @@ -0,0 +1,423 @@ +{ + "themes": { + "Default": { + "Primary": { + "light": "#007AFF", + "dark": "#0A84FF" + }, + "Secondary": { + "light": "#5AC8FA", + "dark": "#64D2FF" + }, + "Accent": { + "light": "#FF9500", + "dark": "#FF9F0A" + }, + "Error": { + "light": "#FF3B30", + "dark": "#FF453A" + }, + "BackgroundPrimary": { + "light": "#FFFFFF", + "dark": "#1C1C1C" + }, + "BackgroundSecondary": { + "light": "#F2F7F7", + "dark": "#2C2C2C" + }, + "TextPrimary": { + "light": "#111111", + "dark": "#FFFFFF" + }, + "TextSecondary": { + "light": "#3D3D3D99", + "dark": "#EBEBEB99" + }, + "TextOnPrimary": { + "light": "#FFFFFF", + "dark": "#FFFFFF" + } + }, + "Crimson": { + "Primary": { + "light": "#B51E28", + "dark": "#FF827D" + }, + "Secondary": { + "light": "#992E38", + "dark": "#FA9994" + }, + "Accent": { + "light": "#E36100", + "dark": "#FFB56B" + }, + "Error": { + "light": "#DD1C1A", + "dark": "#FF5344" + }, + "BackgroundPrimary": { + "light": "#F6EEEC", + "dark": "#1B1216" + }, + "BackgroundSecondary": { + "light": "#DECFCC", + "dark": "#412F39" + }, + "TextPrimary": { + "light": "#111111", + "dark": "#F5F5F5" + }, + "TextSecondary": { + "light": "#444444", + "dark": "#C7C7C7" + }, + "TextOnPrimary": { + "light": "#FFFFFF", + "dark": "#FFFFFF" + } + }, + "Desert": { + "Primary": { + "light": "#B0614A", + "dark": "#F2B594" + }, + "Secondary": { + "light": "#9E7D61", + "dark": "#EBD1B0" + }, + "Accent": { + "light": "#D1942E", + "dark": "#FFD96B" + }, + "Error": { + "light": "#DD1C1A", + "dark": "#FF5344" + }, + "BackgroundPrimary": { + "light": "#F6F1EB", + "dark": "#201C17" + }, + "BackgroundSecondary": { + "light": "#E6D9C7", + "dark": "#4A4138" + }, + "TextPrimary": { + "light": "#111111", + "dark": "#F5F5F5" + }, + "TextSecondary": { + "light": "#444444", + "dark": "#C7C7C7" + }, + "TextOnPrimary": { + "light": "#FFFFFF", + "dark": "#FFFFFF" + } + }, + "Forest": { + "Primary": { + "light": "#2D5016", + "dark": "#94C76B" + }, + "Secondary": { + "light": "#6B8E23", + "dark": "#B0D182" + }, + "Accent": { + "light": "#FFD700", + "dark": "#FFD700" + }, + "Error": { + "light": "#DD1C1A", + "dark": "#FF5344" + }, + "BackgroundPrimary": { + "light": "#ECEFE3", + "dark": "#191E18" + }, + "BackgroundSecondary": { + "light": "#C1C9AE", + "dark": "#384436" + }, + "TextPrimary": { + "light": "#111111", + "dark": "#F5F5F5" + }, + "TextSecondary": { + "light": "#444444", + "dark": "#C7C7C7" + }, + "TextOnPrimary": { + "light": "#FFFFFF", + "dark": "#FFFFFF" + } + }, + "Lavender": { + "Primary": { + "light": "#6B418B", + "dark": "#D1B0E3" + }, + "Secondary": { + "light": "#8B61B0", + "dark": "#DEBFEB" + }, + "Accent": { + "light": "#E34A82", + "dark": "#FF9EC7" + }, + "Error": { + "light": "#DD1C1A", + "dark": "#FF5344" + }, + "BackgroundPrimary": { + "light": "#F2F0F5", + "dark": "#18141E" + }, + "BackgroundSecondary": { + "light": "#D9D1E0", + "dark": "#393142" + }, + "TextPrimary": { + "light": "#111111", + "dark": "#F5F5F5" + }, + "TextSecondary": { + "light": "#444444", + "dark": "#C7C7C7" + }, + "TextOnPrimary": { + "light": "#FFFFFF", + "dark": "#FFFFFF" + } + }, + "Midnight": { + "Primary": { + "light": "#1E4A94", + "dark": "#82B5EB" + }, + "Secondary": { + "light": "#2E61B0", + "dark": "#94C7F2" + }, + "Accent": { + "light": "#4A94E3", + "dark": "#9ED9FF" + }, + "Error": { + "light": "#DD1C1A", + "dark": "#FF5344" + }, + "BackgroundPrimary": { + "light": "#EEF1F7", + "dark": "#121720" + }, + "BackgroundSecondary": { + "light": "#CCD6E3", + "dark": "#303849" + }, + "TextPrimary": { + "light": "#111111", + "dark": "#F5F5F5" + }, + "TextSecondary": { + "light": "#444444", + "dark": "#C7C7C7" + }, + "TextOnPrimary": { + "light": "#FFFFFF", + "dark": "#FFFFFF" + } + }, + "Mint": { + "Primary": { + "light": "#38B094", + "dark": "#94F2D9" + }, + "Secondary": { + "light": "#61C7B0", + "dark": "#BFFAEB" + }, + "Accent": { + "light": "#2E9EB0", + "dark": "#6BEBF2" + }, + "Error": { + "light": "#DD1C1A", + "dark": "#FF5344" + }, + "BackgroundPrimary": { + "light": "#EEF6F1", + "dark": "#172020" + }, + "BackgroundSecondary": { + "light": "#D1E3D9", + "dark": "#384A4A" + }, + "TextPrimary": { + "light": "#111111", + "dark": "#F5F5F5" + }, + "TextSecondary": { + "light": "#444444", + "dark": "#C7C7C7" + }, + "TextOnPrimary": { + "light": "#FFFFFF", + "dark": "#FFFFFF" + } + }, + "Monochrome": { + "Primary": { + "light": "#333333", + "dark": "#E6E6E6" + }, + "Secondary": { + "light": "#666666", + "dark": "#BFBFBF" + }, + "Accent": { + "light": "#999999", + "dark": "#D1D1D1" + }, + "Error": { + "light": "#DD1C1A", + "dark": "#FF5344" + }, + "BackgroundPrimary": { + "light": "#F1F1F1", + "dark": "#171717" + }, + "BackgroundSecondary": { + "light": "#D5D5D5", + "dark": "#3C3C3C" + }, + "TextPrimary": { + "light": "#111111", + "dark": "#F5F5F5" + }, + "TextSecondary": { + "light": "#444444", + "dark": "#C7C7C7" + }, + "TextOnPrimary": { + "light": "#FFFFFF", + "dark": "#FFFFFF" + } + }, + "Ocean": { + "Primary": { + "light": "#006B8F", + "dark": "#4AB5D1" + }, + "Secondary": { + "light": "#008B8B", + "dark": "#61D1C7" + }, + "Accent": { + "light": "#FF7F50", + "dark": "#FF7F50" + }, + "Error": { + "light": "#DD1C1A", + "dark": "#FF5344" + }, + "BackgroundPrimary": { + "light": "#E5ECF2", + "dark": "#171B23" + }, + "BackgroundSecondary": { + "light": "#BDCBD6", + "dark": "#323B4C" + }, + "TextPrimary": { + "light": "#111111", + "dark": "#F5F5F5" + }, + "TextSecondary": { + "light": "#444444", + "dark": "#C7C7C7" + }, + "TextOnPrimary": { + "light": "#FFFFFF", + "dark": "#FFFFFF" + } + }, + "Sunset": { + "Primary": { + "light": "#FF4500", + "dark": "#FF9E61" + }, + "Secondary": { + "light": "#FF6347", + "dark": "#FFAD7D" + }, + "Accent": { + "light": "#FFD700", + "dark": "#FFD700" + }, + "Error": { + "light": "#DD1C1A", + "dark": "#FF5344" + }, + "BackgroundPrimary": { + "light": "#F7F1E8", + "dark": "#211914" + }, + "BackgroundSecondary": { + "light": "#DCD0BB", + "dark": "#433329" + }, + "TextPrimary": { + "light": "#111111", + "dark": "#F5F5F5" + }, + "TextSecondary": { + "light": "#444444", + "dark": "#C7C7C7" + }, + "TextOnPrimary": { + "light": "#FFFFFF", + "dark": "#FFFFFF" + } + }, + "Teal": { + "Primary": { + "light": "#07A0C3", + "dark": "#61CCE3" + }, + "Secondary": { + "light": "#0055A5", + "dark": "#61A6D9" + }, + "Accent": { + "light": "#F0C808", + "dark": "#F0C808" + }, + "Error": { + "light": "#DD1C1A", + "dark": "#FF5344" + }, + "BackgroundPrimary": { + "light": "#FFF1D0", + "dark": "#0A1929" + }, + "BackgroundSecondary": { + "light": "#FFFFFF", + "dark": "#1A2F3F" + }, + "TextPrimary": { + "light": "#111111", + "dark": "#F5F5F5" + }, + "TextSecondary": { + "light": "#444444", + "dark": "#C7C7C7" + }, + "TextOnPrimary": { + "light": "#FFFFFF", + "dark": "#FFFFFF" + } + } + }, + "widget": {} +} diff --git a/docs/ios-parity/screens.json b/docs/ios-parity/screens.json new file mode 100644 index 0000000..24f8895 --- /dev/null +++ b/docs/ios-parity/screens.json @@ -0,0 +1,409 @@ +{ + "screens": [ + { + "name": "ForgotPasswordView", + "path": "iosApp/iosApp/PasswordReset/ForgotPasswordView.swift", + "category": "auth" + }, + { + "name": "LoginView", + "path": "iosApp/iosApp/Login/LoginView.swift", + "category": "auth" + }, + { + "name": "RegisterView", + "path": "iosApp/iosApp/Register/RegisterView.swift", + "category": "auth" + }, + { + "name": "ResetPasswordView", + "path": "iosApp/iosApp/PasswordReset/ResetPasswordView.swift", + "category": "auth" + }, + { + "name": "VerifyEmailView", + "path": "iosApp/iosApp/VerifyEmail/VerifyEmailView.swift", + "category": "auth" + }, + { + "name": "VerifyResetCodeView", + "path": "iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift", + "category": "auth" + }, + { + "name": "ContractorDetailView", + "path": "iosApp/iosApp/Contractor/ContractorDetailView.swift", + "category": "contractor" + }, + { + "name": "ContractorsListView", + "path": "iosApp/iosApp/Contractor/ContractorsListView.swift", + "category": "contractor" + }, + { + "name": "AddDocumentView", + "path": "iosApp/iosApp/Documents/AddDocumentView.swift", + "category": "document" + }, + { + "name": "DocumentDetailView", + "path": "iosApp/iosApp/Documents/DocumentDetailView.swift", + "category": "document" + }, + { + "name": "DocumentFormView", + "path": "iosApp/iosApp/Documents/DocumentFormView.swift", + "category": "document" + }, + { + "name": "DocumentsWarrantiesView", + "path": "iosApp/iosApp/Documents/DocumentsWarrantiesView.swift", + "category": "document" + }, + { + "name": "EditDocumentView", + "path": "iosApp/iosApp/Documents/EditDocumentView.swift", + "category": "document" + }, + { + "name": "OnboardingCreateAccountView", + "path": "iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift", + "category": "onboarding" + }, + { + "name": "OnboardingFirstTaskView", + "path": "iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift", + "category": "onboarding" + }, + { + "name": "OnboardingJoinResidenceView", + "path": "iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift", + "category": "onboarding" + }, + { + "name": "OnboardingNameResidenceView", + "path": "iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift", + "category": "onboarding" + }, + { + "name": "OnboardingSubscriptionView", + "path": "iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift", + "category": "onboarding" + }, + { + "name": "OnboardingValuePropsView", + "path": "iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift", + "category": "onboarding" + }, + { + "name": "OnboardingVerifyEmailView", + "path": "iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift", + "category": "onboarding" + }, + { + "name": "OnboardingWelcomeView", + "path": "iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift", + "category": "onboarding" + }, + { + "name": "AnimationTestingView", + "path": "iosApp/iosApp/Profile/AnimationTesting/AnimationTestingView.swift", + "category": "profile" + }, + { + "name": "FireworkCheckmarkView", + "path": "iosApp/iosApp/Profile/AnimationTesting/TaskAnimations.swift", + "category": "profile" + }, + { + "name": "ImplodeCheckmarkView", + "path": "iosApp/iosApp/Profile/AnimationTesting/TaskAnimations.swift", + "category": "profile" + }, + { + "name": "NotificationPreferencesView", + "path": "iosApp/iosApp/Profile/NotificationPreferencesView.swift", + "category": "profile" + }, + { + "name": "ProfileTabView", + "path": "iosApp/iosApp/Profile/ProfileTabView.swift", + "category": "profile" + }, + { + "name": "ProfileView", + "path": "iosApp/iosApp/Profile/ProfileView.swift", + "category": "profile" + }, + { + "name": "RippleCheckmarkView", + "path": "iosApp/iosApp/Profile/AnimationTesting/TaskAnimations.swift", + "category": "profile" + }, + { + "name": "StarburstCheckmarkView", + "path": "iosApp/iosApp/Profile/AnimationTesting/TaskAnimations.swift", + "category": "profile" + }, + { + "name": "TestColumnView", + "path": "iosApp/iosApp/Profile/AnimationTesting/AnimationTestingView.swift", + "category": "profile" + }, + { + "name": "ThemeSelectionView", + "path": "iosApp/iosApp/Profile/ThemeSelectionView.swift", + "category": "profile" + }, + { + "name": "AddResidenceView", + "path": "iosApp/iosApp/AddResidenceView.swift", + "category": "residence" + }, + { + "name": "EditResidenceView", + "path": "iosApp/iosApp/EditResidenceView.swift", + "category": "residence" + }, + { + "name": "EmptyResidencesView", + "path": "iosApp/iosApp/Subviews/Residence/EmptyResidencesView.swift", + "category": "residence" + }, + { + "name": "JoinResidenceView", + "path": "iosApp/iosApp/Residence/JoinResidenceView.swift", + "category": "residence" + }, + { + "name": "ManageUsersView", + "path": "iosApp/iosApp/Residence/ManageUsersView.swift", + "category": "residence" + }, + { + "name": "ResidenceDetailView", + "path": "iosApp/iosApp/Residence/ResidenceDetailView.swift", + "category": "residence" + }, + { + "name": "ResidenceFormView", + "path": "iosApp/iosApp/ResidenceFormView.swift", + "category": "residence" + }, + { + "name": "ResidencesListView", + "path": "iosApp/iosApp/Residence/ResidencesListView.swift", + "category": "residence" + }, + { + "name": "SummaryStatView", + "path": "iosApp/iosApp/Subviews/Residence/SummaryStatView.swift", + "category": "residence" + }, + { + "name": "AnimatedHoneyDueIconView", + "path": "iosApp/iosApp/Subviews/Common/HoneyDueIconView.swift", + "category": "shared" + }, + { + "name": "AsyncEmptyStateView", + "path": "iosApp/iosApp/Core/AsyncContentView.swift", + "category": "shared" + }, + { + "name": "DefaultErrorView", + "path": "iosApp/iosApp/Core/AsyncContentView.swift", + "category": "shared" + }, + { + "name": "DefaultLoadingView", + "path": "iosApp/iosApp/Core/AsyncContentView.swift", + "category": "shared" + }, + { + "name": "EmptyStateView", + "path": "iosApp/iosApp/Documents/Components/EmptyStateView.swift", + "category": "shared" + }, + { + "name": "ErrorMessageView", + "path": "iosApp/iosApp/Subviews/Common/ErrorMessageView.swift", + "category": "shared" + }, + { + "name": "ErrorView", + "path": "iosApp/iosApp/Subviews/Common/ErrorView.swift", + "category": "shared" + }, + { + "name": "HoneyDueIconView", + "path": "iosApp/iosApp/Subviews/Common/HoneyDueIconView.swift", + "category": "shared" + }, + { + "name": "HoneycombSummaryView", + "path": "iosApp/iosApp/Shared/Components/HoneycombCompletionGrid.swift", + "category": "shared" + }, + { + "name": "ImageThumbnailView", + "path": "iosApp/iosApp/Subviews/Common/ImageThumbnailView.swift", + "category": "shared" + }, + { + "name": "InlineLoadingView", + "path": "iosApp/iosApp/Core/LoadingOverlay.swift", + "category": "shared" + }, + { + "name": "MainTabView", + "path": "iosApp/iosApp/MainTabView.swift", + "category": "shared" + }, + { + "name": "RootView", + "path": "iosApp/iosApp/RootView.swift", + "category": "shared" + }, + { + "name": "SkeletonView", + "path": "iosApp/iosApp/Core/LoadingOverlay.swift", + "category": "shared" + }, + { + "name": "StandardEmptyStateView", + "path": "iosApp/iosApp/Shared/Components/SharedEmptyStateView.swift", + "category": "shared" + }, + { + "name": "StandardLoadingView", + "path": "iosApp/iosApp/Shared/Extensions/ViewExtensions.swift", + "category": "shared" + }, + { + "name": "StatView", + "path": "iosApp/iosApp/Subviews/Common/StatView.swift", + "category": "shared" + }, + { + "name": "FeatureComparisonView", + "path": "iosApp/iosApp/Subscription/FeatureComparisonView.swift", + "category": "subscription" + }, + { + "name": "PromoContentView", + "path": "iosApp/iosApp/Subscription/UpgradePromptView.swift", + "category": "subscription" + }, + { + "name": "UpgradeFeatureView", + "path": "iosApp/iosApp/Subscription/UpgradeFeatureView.swift", + "category": "subscription" + }, + { + "name": "UpgradePromptView", + "path": "iosApp/iosApp/Subscription/UpgradePromptView.swift", + "category": "subscription" + }, + { + "name": "AddTaskView", + "path": "iosApp/iosApp/Task/AddTaskView.swift", + "category": "task" + }, + { + "name": "AddTaskWithResidenceView", + "path": "iosApp/iosApp/Task/AddTaskWithResidenceView.swift", + "category": "task" + }, + { + "name": "AllTasksView", + "path": "iosApp/iosApp/Task/AllTasksView.swift", + "category": "task" + }, + { + "name": "CompleteTaskView", + "path": "iosApp/iosApp/Task/CompleteTaskView.swift", + "category": "task" + }, + { + "name": "CompletionCardView", + "path": "iosApp/iosApp/Subviews/Task/CompletionCardView.swift", + "category": "task" + }, + { + "name": "ContractorPickerView", + "path": "iosApp/iosApp/Task/CompleteTaskView.swift", + "category": "task" + }, + { + "name": "DynamicTaskColumnView", + "path": "iosApp/iosApp/Subviews/Task/DynamicTaskColumnView.swift", + "category": "task" + }, + { + "name": "EditTaskView", + "path": "iosApp/iosApp/Task/EditTaskView.swift", + "category": "task" + }, + { + "name": "EmptyTasksView", + "path": "iosApp/iosApp/Subviews/Task/EmptyTasksView.swift", + "category": "task" + }, + { + "name": "SwipeHintView", + "path": "iosApp/iosApp/Subviews/Task/TasksSection.swift", + "category": "task" + }, + { + "name": "TaskFormView", + "path": "iosApp/iosApp/Task/TaskFormView.swift", + "category": "task" + }, + { + "name": "TaskSuggestionsView", + "path": "iosApp/iosApp/Task/TaskSuggestionsView.swift", + "category": "task" + }, + { + "name": "TaskTemplatesBrowserView", + "path": "iosApp/iosApp/Task/TaskTemplatesBrowserView.swift", + "category": "task" + }, + { + "name": "FreeWidgetView", + "path": "iosApp/HoneyDue/HoneyDue.swift", + "category": "widget" + }, + { + "name": "HoneyDueEntryView", + "path": "iosApp/HoneyDue/HoneyDue.swift", + "category": "widget" + }, + { + "name": "LargeWidgetView", + "path": "iosApp/HoneyDue/HoneyDue.swift", + "category": "widget" + }, + { + "name": "MediumWidgetView", + "path": "iosApp/HoneyDue/HoneyDue.swift", + "category": "widget" + }, + { + "name": "OrganicStatsView", + "path": "iosApp/HoneyDue/HoneyDue.swift", + "category": "widget" + }, + { + "name": "OrganicTaskRowView", + "path": "iosApp/HoneyDue/HoneyDue.swift", + "category": "widget" + }, + { + "name": "SmallWidgetView", + "path": "iosApp/HoneyDue/HoneyDue.swift", + "category": "widget" + } + ] +} diff --git a/scripts/extract_ios_assets.py b/scripts/extract_ios_assets.py new file mode 100644 index 0000000..41f8d73 --- /dev/null +++ b/scripts/extract_ios_assets.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Inventory iOS Asset Catalogs (imagesets + appiconsets) for parity tracking. + +Scans both production asset catalogs: + - iosApp/iosApp/Assets.xcassets/ + - iosApp/HoneyDue/Assets.xcassets/ + +Skips build/DerivedData output (PostHog examples etc.). + +Output schema: +{ + "image_sets": [ + {"name": "outline", "path": "...", "files": ["outline.pdf"], "format": "pdf"}, + ... + ], + "app_icons": [ + {"name": "AppIcon", "path": "...", "sizes": ["1024x1024", ...]} + ], + "widget_assets": [ + { ...same shape as image_sets... } + ] +} +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parent.parent +MAIN_XCASSETS = REPO_ROOT / "iosApp" / "iosApp" / "Assets.xcassets" +WIDGET_XCASSETS = REPO_ROOT / "iosApp" / "HoneyDue" / "Assets.xcassets" +OUT_PATH = REPO_ROOT / "docs" / "ios-parity" / "assets.json" + +_IMAGE_EXTS = {".pdf", ".png", ".jpg", ".jpeg", ".svg", ".heic"} + + +def infer_format(files: list[str]) -> str: + exts = {Path(f).suffix.lower().lstrip(".") for f in files} + image_exts = exts & {"pdf", "png", "jpg", "jpeg", "svg", "heic"} + if not image_exts: + return "unknown" + if len(image_exts) == 1: + return next(iter(image_exts)) + return "mixed(" + ",".join(sorted(image_exts)) + ")" + + +def list_asset_files(dir_path: Path) -> list[str]: + out: list[str] = [] + for entry in sorted(dir_path.iterdir()): + if entry.is_file() and entry.suffix.lower() in _IMAGE_EXTS: + out.append(entry.name) + return out + + +def describe_imageset(imageset_dir: Path) -> dict[str, Any]: + name = imageset_dir.name[: -len(".imageset")] + files = list_asset_files(imageset_dir) + return { + "name": name, + "path": str(imageset_dir.relative_to(REPO_ROOT)), + "files": files, + "format": infer_format(files), + } + + +def describe_appicon(appicon_dir: Path) -> dict[str, Any]: + name = appicon_dir.name[: -len(".appiconset")] + contents = appicon_dir / "Contents.json" + sizes: list[str] = [] + files = list_asset_files(appicon_dir) + if contents.is_file(): + try: + data = json.loads(contents.read_text(encoding="utf-8")) + except json.JSONDecodeError: + data = {} + for image in data.get("images", []): + size = image.get("size") + scale = image.get("scale") + idiom = image.get("idiom") + if size: + label = size + if scale: + label = f"{label}@{scale}" + if idiom: + label = f"{label} ({idiom})" + sizes.append(label) + return { + "name": name, + "path": str(appicon_dir.relative_to(REPO_ROOT)), + "sizes": sizes, + "files": files, + } + + +def walk_catalog(root: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + imagesets: list[dict[str, Any]] = [] + appicons: list[dict[str, Any]] = [] + if not root.is_dir(): + return imagesets, appicons + for dirpath, dirnames, _ in root.walk() if hasattr(root, "walk") else _walk(root): + p = Path(dirpath) + if p.name.endswith(".imageset"): + imagesets.append(describe_imageset(p)) + dirnames[:] = [] # don't recurse inside + elif p.name.endswith(".appiconset"): + appicons.append(describe_appicon(p)) + dirnames[:] = [] + imagesets.sort(key=lambda x: x["name"]) + appicons.sort(key=lambda x: x["name"]) + return imagesets, appicons + + +def _walk(root: Path): + """Fallback walker for Python < 3.12 where Path.walk is unavailable.""" + import os + + for dirpath, dirnames, filenames in os.walk(root): + yield dirpath, dirnames, filenames + + +def main() -> int: + main_images, main_icons = walk_catalog(MAIN_XCASSETS) + widget_images, widget_icons = walk_catalog(WIDGET_XCASSETS) + + output = { + "image_sets": main_images, + "app_icons": main_icons + widget_icons, + "widget_assets": widget_images, + } + + OUT_PATH.parent.mkdir(parents=True, exist_ok=True) + with OUT_PATH.open("w", encoding="utf-8") as f: + json.dump(output, f, indent=2) + f.write("\n") + + print(f"[extract_ios_assets] wrote {OUT_PATH}") + print( + f" image_sets={len(output['image_sets'])} " + f"app_icons={len(output['app_icons'])} " + f"widget_assets={len(output['widget_assets'])}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/extract_ios_colors.py b/scripts/extract_ios_colors.py new file mode 100644 index 0000000..61a88db --- /dev/null +++ b/scripts/extract_ios_colors.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Extract color values from iOS Asset Catalogs into a machine-readable JSON file. + +Scans two asset catalogs: + - iosApp/iosApp/Assets.xcassets/Colors/** (11 themes x 9 colors) + - iosApp/HoneyDue/Assets.xcassets/** (widget accent + bg) + +Each color Contents.json defines up to two `colors` entries: + - one universal (light-mode default) + - one with appearances=[luminosity:dark] + +Component values may be: + - float strings "0.000" .. "1.000" -> multiply by 255 and round + - hex strings "0xHH" -> parse as int + +sRGB-only. If any display-p3 entry is encountered it is recorded +separately (at the top level) so Android implementers can decide how to +handle them; values are otherwise passed through as-is. + +Output schema (see plan in rc-android-ios-parity.md): +{ + "displayP3_themes": [ ... optional ... ], + "themes": { : { : { "light": "#RRGGBB", "dark": "#RRGGBB" } } }, + "widget": { : { "light": "#RRGGBB", "dark": "#RRGGBB" } } +} +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parent.parent +COLOR_ROOT = REPO_ROOT / "iosApp" / "iosApp" / "Assets.xcassets" / "Colors" +WIDGET_ROOT = REPO_ROOT / "iosApp" / "HoneyDue" / "Assets.xcassets" +OUT_PATH = REPO_ROOT / "docs" / "ios-parity" / "colors.json" + +EXPECTED_THEME_COUNT = 11 +EXPECTED_COLORS_PER_THEME = 9 +EXPECTED_COLOR_NAMES = { + "Primary", + "Secondary", + "Accent", + "Error", + "BackgroundPrimary", + "BackgroundSecondary", + "TextPrimary", + "TextSecondary", + "TextOnPrimary", +} + + +def component_to_int(value: str | float | int) -> int: + """Convert a color component (hex string, float-as-string, or numeric) to 0..255.""" + if isinstance(value, (int, float)): + if 0.0 <= float(value) <= 1.0 and not (isinstance(value, int) and value > 1): + return round(float(value) * 255) + return int(value) + s = str(value).strip() + if s.lower().startswith("0x"): + return int(s, 16) + # float like "0.478" or "1.000" + f = float(s) + if 0.0 <= f <= 1.0: + return round(f * 255) + return int(f) + + +def hex_string(r: int, g: int, b: int, a: float) -> str: + if abs(a - 1.0) < 1e-6: + return f"#{r:02X}{g:02X}{b:02X}" + a_int = round(a * 255) if 0.0 <= a <= 1.0 else int(a) + return f"#{r:02X}{g:02X}{b:02X}{a_int:02X}" + + +def alpha_value(value: Any) -> float: + if isinstance(value, (int, float)): + return float(value) + s = str(value).strip() + if s.lower().startswith("0x"): + return int(s, 16) / 255.0 + return float(s) + + +def parse_colorset(contents_path: Path) -> tuple[str | None, str | None, str | None]: + """Return (light_hex, dark_hex, color_space) for a .colorset/Contents.json. + + Returns (None, None, None) if the colorset has no color data (e.g. Xcode + placeholder `AccentColor` with only idiom but no components). + """ + with contents_path.open("r", encoding="utf-8") as f: + data = json.load(f) + + light = None + dark = None + color_space = None + + for entry in data.get("colors", []): + color = entry.get("color") + if not color: + continue + components = color.get("components") or {} + if not components: + continue + color_space = color.get("color-space") or color_space + r = component_to_int(components.get("red", 0)) + g = component_to_int(components.get("green", 0)) + b = component_to_int(components.get("blue", 0)) + a = alpha_value(components.get("alpha", 1.0)) + hex_str = hex_string(r, g, b, a) + + appearances = entry.get("appearances") or [] + is_dark = any( + a.get("appearance") == "luminosity" and a.get("value") == "dark" + for a in appearances + ) + if is_dark: + dark = hex_str + else: + light = hex_str + + if light is None and dark is None: + return None, None, None + # If one variant is missing, mirror it + if light is None: + light = dark + if dark is None: + dark = light + return light, dark, color_space + + +def extract_theme_colors() -> tuple[dict[str, dict[str, dict[str, str]]], set[str]]: + themes: dict[str, dict[str, dict[str, str]]] = {} + display_p3_themes: set[str] = set() + + if not COLOR_ROOT.is_dir(): + raise SystemExit(f"color root not found: {COLOR_ROOT}") + + for theme_dir in sorted(COLOR_ROOT.iterdir()): + if not theme_dir.is_dir(): + continue + theme_name = theme_dir.name + theme_colors: dict[str, dict[str, str]] = {} + + for colorset_dir in sorted(theme_dir.iterdir()): + if not colorset_dir.is_dir() or not colorset_dir.name.endswith(".colorset"): + continue + color_name = colorset_dir.name[: -len(".colorset")] + contents_path = colorset_dir / "Contents.json" + if not contents_path.is_file(): + continue + light, dark, cs = parse_colorset(contents_path) + if light is None: + continue + theme_colors[color_name] = {"light": light, "dark": dark} + if cs and "display-p3" in cs.lower(): + display_p3_themes.add(theme_name) + + if theme_colors: + themes[theme_name] = theme_colors + + return themes, display_p3_themes + + +def extract_widget_colors() -> dict[str, dict[str, str]]: + widget: dict[str, dict[str, str]] = {} + if not WIDGET_ROOT.is_dir(): + print(f"[warn] widget asset root missing: {WIDGET_ROOT}", file=sys.stderr) + return widget + for entry in sorted(WIDGET_ROOT.iterdir()): + if not entry.is_dir() or not entry.name.endswith(".colorset"): + continue + color_name = entry.name[: -len(".colorset")] + contents_path = entry / "Contents.json" + if not contents_path.is_file(): + continue + light, dark, _ = parse_colorset(contents_path) + if light is None: + # Asset catalog placeholder with no concrete color; skip. + continue + widget[color_name] = {"light": light, "dark": dark} + return widget + + +def main() -> int: + themes, display_p3 = extract_theme_colors() + widget = extract_widget_colors() + + # Validation + errors: list[str] = [] + if len(themes) != EXPECTED_THEME_COUNT: + errors.append( + f"Expected {EXPECTED_THEME_COUNT} themes, got {len(themes)}: " + f"{sorted(themes.keys())}" + ) + for name, colors in sorted(themes.items()): + if len(colors) != EXPECTED_COLORS_PER_THEME: + errors.append( + f"Theme {name!r} has {len(colors)} colors, expected " + f"{EXPECTED_COLORS_PER_THEME}: {sorted(colors.keys())}" + ) + missing = EXPECTED_COLOR_NAMES - set(colors.keys()) + extra = set(colors.keys()) - EXPECTED_COLOR_NAMES + if missing: + errors.append(f"Theme {name!r} missing colors: {sorted(missing)}") + if extra: + errors.append(f"Theme {name!r} has unexpected colors: {sorted(extra)}") + + if errors: + print("[extract_ios_colors] VALIDATION FAILED:", file=sys.stderr) + for e in errors: + print(f" - {e}", file=sys.stderr) + return 1 + + output: dict[str, Any] = {} + if display_p3: + output["displayP3_themes"] = sorted(display_p3) + # Preserve key order: Primary, Secondary, Accent, Error, Backgrounds, Text + color_order = [ + "Primary", + "Secondary", + "Accent", + "Error", + "BackgroundPrimary", + "BackgroundSecondary", + "TextPrimary", + "TextSecondary", + "TextOnPrimary", + ] + ordered_themes: dict[str, dict[str, dict[str, str]]] = {} + # Put Default first if present + theme_order = sorted(themes.keys(), key=lambda n: (n != "Default", n)) + for theme_name in theme_order: + ordered_themes[theme_name] = { + cname: themes[theme_name][cname] + for cname in color_order + if cname in themes[theme_name] + } + output["themes"] = ordered_themes + output["widget"] = widget + + OUT_PATH.parent.mkdir(parents=True, exist_ok=True) + with OUT_PATH.open("w", encoding="utf-8") as f: + json.dump(output, f, indent=2) + f.write("\n") + + print(f"[extract_ios_colors] wrote {OUT_PATH}") + print( + f" themes={len(ordered_themes)} " + f"colors/theme={EXPECTED_COLORS_PER_THEME} " + f"widget_entries={len(widget)} " + f"displayP3_themes={sorted(display_p3) if display_p3 else 'none'}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/extract_ios_screens.py b/scripts/extract_ios_screens.py new file mode 100644 index 0000000..1a9f63e --- /dev/null +++ b/scripts/extract_ios_screens.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Static inventory of SwiftUI screens in iosApp/iosApp/. + +Finds every `struct View: View { ... }` or `struct Screen: View { ... }` +declaration across the production iOS source tree (excluding generated/build +dirs) and categorises them by path. + +Output schema: +{ + "screens": [ + {"name": "LoginView", "path": "iosApp/iosApp/Login/LoginView.swift", "category": "auth"}, + ... + ] +} +""" +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parent.parent +SOURCE_ROOT = REPO_ROOT / "iosApp" / "iosApp" +OUT_PATH = REPO_ROOT / "docs" / "ios-parity" / "screens.json" + +# Also scan widget target (HoneyDue/) for completeness. +WIDGET_SOURCE_ROOT = REPO_ROOT / "iosApp" / "HoneyDue" + +EXCLUDED_DIR_PARTS = {"build", "DerivedData", ".build", "Pods"} + +STRUCT_RE = re.compile( + r"^\s*struct\s+([A-Za-z_][A-Za-z0-9_]*(?:View|Screen))\s*:\s*View\s*\{", + re.MULTILINE, +) + +CATEGORY_RULES: list[tuple[str, str]] = [ + # path-part substring (case-insensitive) -> category + ("Login", "auth"), + ("Register", "auth"), + ("PasswordReset", "auth"), + ("VerifyEmail", "auth"), + ("Onboarding", "onboarding"), + ("Task", "task"), + ("Residence", "residence"), + ("Document", "document"), + ("Contractor", "contractor"), + ("Profile", "profile"), + ("Subscription", "subscription"), + ("Widget", "widget"), # matches WidgetIconView etc. (HoneyDue/) + ("HoneyDue", "widget"), # widget target dir + ("Shared", "shared"), + ("Core", "shared"), + ("Subviews", "shared"), + ("Dev", "dev"), + ("AnimationTesting", "dev"), +] + + +def category_for(rel_path: Path) -> str: + parts_lower = [p.lower() for p in rel_path.parts] + for needle, cat in CATEGORY_RULES: + if needle.lower() in parts_lower: + return cat + # filename fallback + stem = rel_path.stem.lower() + for needle, cat in CATEGORY_RULES: + if needle.lower() in stem: + return cat + return "shared" + + +def should_skip(path: Path) -> bool: + return any(part in EXCLUDED_DIR_PARTS for part in path.parts) + + +def find_swift_files(root: Path) -> list[Path]: + if not root.is_dir(): + return [] + out: list[Path] = [] + for p in root.rglob("*.swift"): + if should_skip(p.relative_to(REPO_ROOT)): + continue + out.append(p) + return sorted(out) + + +def extract_from(path: Path) -> list[dict[str, Any]]: + try: + text = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + return [] + found: list[dict[str, Any]] = [] + seen: set[str] = set() + for m in STRUCT_RE.finditer(text): + name = m.group(1) + if name in seen: + continue + seen.add(name) + rel = path.relative_to(REPO_ROOT) + found.append( + { + "name": name, + "path": str(rel), + "category": category_for(rel), + } + ) + return found + + +def main() -> int: + screens: list[dict[str, Any]] = [] + for swift_file in find_swift_files(SOURCE_ROOT): + screens.extend(extract_from(swift_file)) + for swift_file in find_swift_files(WIDGET_SOURCE_ROOT): + screens.extend(extract_from(swift_file)) + + # Sort by category then name for stable output. + screens.sort(key=lambda s: (s["category"], s["name"], s["path"])) + + output = {"screens": screens} + OUT_PATH.parent.mkdir(parents=True, exist_ok=True) + with OUT_PATH.open("w", encoding="utf-8") as f: + json.dump(output, f, indent=2) + f.write("\n") + + print(f"[extract_ios_screens] wrote {OUT_PATH}") + print(f" screens={len(screens)}") + # category histogram + hist: dict[str, int] = {} + for s in screens: + hist[s["category"]] = hist.get(s["category"], 0) + 1 + for cat, n in sorted(hist.items(), key=lambda kv: (-kv[1], kv[0])): + print(f" {cat}: {n}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())