Selaa lähdekoodia

Add Phase 2 Indeed sandbox contract and finish Phase 1 UI polish.

Document permitted-source DADR, implement fixture-backed IndeedJobDataSource mapping with unit tests, and improve mock UI accessibility (Dynamic Type, filter chips, reduce motion).

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali 21 tuntia sitten
vanhempi
sitoutus
ad58b46442
22 muutettua tiedostoa jossa 1154 lisäystä ja 43 poistoa
  1. 99 0
      Docs/Phase2/DADR.md
  2. 112 0
      Docs/Phase2/Indeed_Access_Spike.md
  3. 117 0
      indeed_ai_job_search.xcodeproj/project.pbxproj
  4. 1 0
      indeed_ai_job_search/App/indeed_ai_job_searchApp.swift
  5. 75 0
      indeed_ai_job_search/DesignSystem/Components/SectionHeader.swift
  6. 16 15
      indeed_ai_job_search/DesignSystem/Theme/AppTypography.swift
  7. 5 0
      indeed_ai_job_search/Domain/Models/SearchCriteria.swift
  8. 1 5
      indeed_ai_job_search/Features/JobAlerts/Views/JobAlertsView.swift
  9. 7 1
      indeed_ai_job_search/Features/JobSearch/Views/FiltersPanelView.swift
  10. 6 22
      indeed_ai_job_search/Features/Settings/Views/SettingsView.swift
  11. 12 0
      indeed_ai_job_search/Notifications/NotificationsPlaceholder.swift
  12. 12 0
      indeed_ai_job_search/Persistence/PersistencePlaceholder.swift
  13. 17 0
      indeed_ai_job_search/Resources/IndeedFixtures/invalid_non_indeed.json
  14. 61 0
      indeed_ai_job_search/Resources/IndeedFixtures/search_page_0.json
  15. 22 0
      indeed_ai_job_search/Resources/IndeedFixtures/search_page_1.json
  16. 71 0
      indeed_ai_job_search/Services/Indeed/FixtureIndeedJobDataSource.swift
  17. 98 0
      indeed_ai_job_search/Services/Indeed/IndeedJobDataSource.swift
  18. 171 0
      indeed_ai_job_search/Services/Indeed/IndeedListingMapper.swift
  19. 84 0
      indeed_ai_job_search/Services/Indeed/IndeedURLCanonicalizer.swift
  20. 3 0
      indeed_ai_job_search/Services/Mock/MockServices.swift
  21. 12 0
      indeed_ai_job_search/Services/OpenAI/OpenAIServicePlaceholder.swift
  22. 152 0
      indeed_ai_job_searchTests/IndeedListingMapperTests.swift

+ 99 - 0
Docs/Phase2/DADR.md

@@ -0,0 +1,99 @@
+# Data Access Decision Record (DADR)
+
+**Product:** Indeed AI Jobs (macOS)  
+**Phase:** 2 — Indeed retrieval feasibility  
+**Status:** Proposed — awaiting product owner go / no-go  
+**Date:** 2026-10-12
+
+---
+
+## 1. Decision summary
+
+| Field | Value |
+|-------|--------|
+| **Chosen production source (recommended)** | **Licensed Indeed-index provider** (or Indeed partner search channel) that returns **Indeed-only** listings with permitted desktop redistribution and link-back |
+| **Interim implementation** | `FixtureIndeedJobDataSource` + mapper (no production network traffic) |
+| **Rejected for production** | Client-side scraping / unauthorized automated HTML collection |
+| **Rejected as seeker search API** | Indeed Job Sync API (employer/partner posting sync — not job-seeker search) |
+| **Go / no-go gate** | **No live Indeed fetch ships** until this DADR is explicitly approved **and** a permitted contract is in place |
+
+---
+
+## 2. Context
+
+The app must:
+
+- Show job listings **in-app**
+- Use **Indeed-only** results (every open action → Indeed TLD URL via `NSWorkspace`)
+- Remain App Store–viable under sandbox + privacy narrative
+- Keep ViewModels stable via `IndeedJobDataSource` while the concrete source is swapped
+
+There is **no public Indeed job-seeker search API** suitable for arbitrary third-party desktop search.
+
+---
+
+## 3. Options considered
+
+| Option | Permitted / ToS | Fit for in-app search | Decision |
+|--------|-----------------|------------------------|----------|
+| **A. Official Job Sync API** | Partner/employer posting API | **Poor** — not a seeker search API | **Out** |
+| **B. Client scraping / bots** | Unauthorized automated collection typically prohibited | Technically possible; brittle (CAPTCHA, HTML churn, IP blocks); high legal/ops risk | **Out for production** |
+| **C. Licensed Indeed-index / partner feed** | Permitted if contract allows desktop redistribution + Indeed-only index | **Best fit** if vendor confirms fields, geo TLDs, pagination, link-back | **Recommended** |
+| **D. Hybrid “open Indeed in browser” only** | Low risk | Does **not** meet “show listings in app” | **Out as sole strategy** |
+
+---
+
+## 4. Recommended decision
+
+**Proceed with Option C** as the production path:
+
+1. Identify and contract a **licensed** provider (or Indeed partnership) whose index is **Indeed-only** (or can be filtered to Indeed-only with contractual guarantee).
+2. Implement `IndeedJobDataSource` against that API once credentials and redistribution rights are confirmed.
+3. Until then, ship **fixture / sandbox** responses only (`FixtureIndeedJobDataSource`). Keep UI on mocks where appropriate.
+
+**Explicit no-go without approval:** any production adapter that scrapes Indeed HTML or mixes non-Indeed boards.
+
+---
+
+## 5. Contract requirements (must verify with vendor)
+
+Before wiring live traffic:
+
+- [ ] License allows **macOS desktop** redistribution of listing metadata
+- [ ] Results are **Indeed-only** (or hard filter with audit)
+- [ ] Every result includes a canonical **Indeed job URL** (regional TLD OK)
+- [ ] Stable **job key** suitable for dedupe (`indeedJobKey`)
+- [ ] Pagination and rate limits documented
+- [ ] Field coverage: title, company, location, remote signals, salary (when present), posted date, snippet
+- [ ] Expired / closed signals (if any) documented for validation (Phase 4+)
+
+---
+
+## 6. Technical contract (in-repo)
+
+Defined under `Services/Indeed/`:
+
+- `IndeedJobDataSource` — paginated search + error taxonomy
+- `IndeedListingMapper` — DTO → `JobListing`
+- `IndeedURLCanonicalizer` — regional hosts, `jk=` / path key extraction
+- `FixtureIndeedJobDataSource` — sandbox JSON fixtures (no network)
+
+See also [Indeed_Access_Spike.md](Indeed_Access_Spike.md).
+
+---
+
+## 7. App Store / privacy narrative (draft)
+
+> Job listings shown in the app come from a **permitted Indeed data channel**. Opening a listing launches the corresponding page on Indeed. The app does not scrape Indeed’s website. Search queries may later be interpreted by OpenAI (Phase 3); listing content remains Indeed-sourced.
+
+Update Privacy Policy before live fetch ships.
+
+---
+
+## 8. Approval
+
+| Role | Name | Decision | Date |
+|------|------|----------|------|
+| Product owner | _pending_ | Approve recommended Option C / request alternate / no-go | |
+
+**Sign-off required before Phase 3+ live Indeed traffic.** OpenAI work may proceed in parallel against mocks/fixtures.

+ 112 - 0
Docs/Phase2/Indeed_Access_Spike.md

@@ -0,0 +1,112 @@
+# Indeed access spike notes
+
+**Phase:** 2  
+**Scope:** Technical + policy constraints only — **not** authorization to ship scraping  
+**Date:** 2026-10-12
+
+---
+
+## 1. ToS / automation constraints (planning)
+
+- Indeed’s terms generally prohibit unauthorized automated access / scraping of site content.
+- App Store review and user trust require a **permitted** data story.
+- A controlled spike that measures HTML fragility (CAPTCHA, rate limits, layout churn) may inform a **don’t-build** decision; it must **not** become a production path without permission.
+
+**Spike posture for this repo:** no live scrape client. Fixtures simulate permitted JSON payloads.
+
+---
+
+## 2. Field coverage needed by the app
+
+Mapped to `JobListing`:
+
+| Domain field | Required | Notes |
+|--------------|----------|-------|
+| `indeedJobKey` | Yes | Dedupe + save identity |
+| `title` | Yes | |
+| `company` | Yes | |
+| `location` | Yes | Free-text OK |
+| `remoteType` | Preferred | Map remote/hybrid/onsite/unknown |
+| `salary` | Optional | min/max/currency/period |
+| `postedAt` | Preferred | Relative date UI |
+| `snippet` | Preferred | Detail / row preview |
+| `indeedURL` | Yes | Must be Indeed TLD |
+| `availability` | Preferred | open / unknown / unavailable |
+| employment / experience | Optional | Filter support |
+
+---
+
+## 3. Regional TLDs
+
+Indeed hosts vary by country (examples): `indeed.com`, `indeed.co.uk`, `pk.indeed.com`, `ca.indeed.com`, etc.
+
+**Canonicalization rules** (implemented in `IndeedURLCanonicalizer`):
+
+- Accept only hosts matching `indeed.` / `*.indeed.` patterns
+- Prefer HTTPS
+- Extract job key from query `jk=` or known path segments when present
+- Preserve regional host when opening in browser so the user lands on the correct locale
+
+Country setting (Settings → Search defaults / criteria.country) should map to the vendor’s locale parameter **and** preferred TLD when the provider supports it.
+
+---
+
+## 4. Pagination, caching, rate limits (contract defaults)
+
+| Concern | Default (until vendor docs override) |
+|---------|--------------------------------------|
+| Page size | 25 |
+| Max pages per user search (UI) | 4 (100 results) before “load more” |
+| Cache TTL (listing metadata) | 15 minutes in-memory (Phase 4+) |
+| Saved-job revalidation TTL | 24 hours (Phase 5) |
+| Rate limit handling | Surface `IndeedDataError.rateLimited(retryAfter:)` — backoff UI |
+| Empty page | Treat as end of results |
+
+---
+
+## 5. Error taxonomy
+
+| Error | User-facing intent |
+|-------|--------------------|
+| `unauthorized` | Source credentials missing/invalid |
+| `forbidden` | License / geo not allowed |
+| `rateLimited` | Retry later |
+| `invalidResponse` | Decode / schema failure |
+| `unsupportedFilter` | Best-effort note (hide or label in UI later) |
+| `network` | Offline / transport |
+| `cancelled` | Task cancelled |
+| `nonIndeedURL` | Mapper rejected non-Indeed link |
+
+---
+
+## 6. `indeedJobKey` extraction
+
+Priority:
+
+1. Explicit `jobKey` / `jk` field from provider DTO  
+2. Query item `jk` on Indeed URL  
+3. Stable path token when vendor uses `/viewjob?jk=`  
+4. Fallback: hash of canonical Indeed URL (last resort; prefer real key)
+
+---
+
+## 7. Technical viability checklist (when a vendor is chosen)
+
+Measure against sandbox credentials (still no scrape):
+
+- [ ] Latency p50 / p95 for page of 25
+- [ ] Geo coverage for target countries (e.g. Pakistan, US, UK)
+- [ ] Remote/hybrid signals accuracy
+- [ ] Salary presence rate
+- [ ] Pagination cursor vs offset
+- [ ] Expired listing signals
+
+Record results in this doc when a vendor sandbox is available.
+
+---
+
+## 8. Outcome for Phase 2
+
+- DADR recommends **licensed / partner Indeed-only** channel  
+- In-repo: protocol + mapper + fixtures + unit tests  
+- **No production network adapter** until DADR approval + contract

+ 117 - 0
indeed_ai_job_search.xcodeproj/project.pbxproj

@@ -6,8 +6,19 @@
 	objectVersion = 110;
 	objects = {
 
+/* Begin PBXContainerItemProxy section */
+		A7B63C10307CB001003F0D4E /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = A7B63BD3307CA8F5003F0D4E /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = A7B63BDA307CA8F5003F0D4E;
+			remoteInfo = indeed_ai_job_search;
+		};
+/* End PBXContainerItemProxy section */
+
 /* Begin PBXFileReference section */
 		A7B63BDB307CA8F5003F0D4E /* indeed_ai_job_search.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = indeed_ai_job_search.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		A7B63C0F307CB001003F0D4E /* indeed_ai_job_searchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = indeed_ai_job_searchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -16,6 +27,11 @@
 			path = indeed_ai_job_search;
 			sourceTree = "<group>";
 		};
+		A7B63C11307CB001003F0D4E /* indeed_ai_job_searchTests */ = {
+			isa = PBXFileSystemSynchronizedRootGroup;
+			path = indeed_ai_job_searchTests;
+			sourceTree = "<group>";
+		};
 /* End PBXFileSystemSynchronizedRootGroup section */
 
 /* Begin PBXFrameworksBuildPhase section */
@@ -24,6 +40,11 @@
 			files = (
 			);
 		};
+		A7B63C0C307CB001003F0D4E /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			files = (
+			);
+		};
 /* End PBXFrameworksBuildPhase section */
 
 /* Begin PBXGroup section */
@@ -31,6 +52,7 @@
 			isa = PBXGroup;
 			children = (
 				A7B63BDD307CA8F5003F0D4E /* indeed_ai_job_search */,
+				A7B63C11307CB001003F0D4E /* indeed_ai_job_searchTests */,
 				A7B63BDC307CA8F5003F0D4E /* Products */,
 			);
 			sourceTree = "<group>";
@@ -39,6 +61,7 @@
 			isa = PBXGroup;
 			children = (
 				A7B63BDB307CA8F5003F0D4E /* indeed_ai_job_search.app */,
+				A7B63C0F307CB001003F0D4E /* indeed_ai_job_searchTests.xctest */,
 			);
 			name = Products;
 			sourceTree = "<group>";
@@ -64,6 +87,27 @@
 			productReference = A7B63BDB307CA8F5003F0D4E /* indeed_ai_job_search.app */;
 			productType = "com.apple.product-type.application";
 		};
+		A7B63C0B307CB001003F0D4E /* indeed_ai_job_searchTests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = A7B63C14307CB001003F0D4E /* Build configuration list for PBXNativeTarget "indeed_ai_job_searchTests" */;
+			buildPhases = (
+				A7B63C0A307CB001003F0D4E /* Sources */,
+				A7B63C0C307CB001003F0D4E /* Frameworks */,
+				A7B63C0D307CB001003F0D4E /* Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				A7B63C12307CB001003F0D4E /* PBXTargetDependency */,
+			);
+			fileSystemSynchronizedGroups = (
+				A7B63C11307CB001003F0D4E /* indeed_ai_job_searchTests */,
+			);
+			name = indeed_ai_job_searchTests;
+			productName = indeed_ai_job_searchTests;
+			productReference = A7B63C0F307CB001003F0D4E /* indeed_ai_job_searchTests.xctest */;
+			productType = "com.apple.product-type.bundle.unit-test";
+		};
 /* End PBXNativeTarget section */
 
 /* Begin PBXProject section */
@@ -77,6 +121,10 @@
 					A7B63BDA307CA8F5003F0D4E = {
 						CreatedOnToolsVersion = 27.0;
 					};
+					A7B63C0B307CB001003F0D4E = {
+						CreatedOnToolsVersion = 27.0;
+						TestTargetID = A7B63BDA307CA8F5003F0D4E;
+					};
 				};
 			};
 			buildConfigurationList = A7B63BD6307CA8F5003F0D4E /* Build configuration list for PBXProject "indeed_ai_job_search" */;
@@ -94,6 +142,7 @@
 			projectRoot = "";
 			targets = (
 				A7B63BDA307CA8F5003F0D4E /* indeed_ai_job_search */,
+				A7B63C0B307CB001003F0D4E /* indeed_ai_job_searchTests */,
 			);
 		};
 /* End PBXProject section */
@@ -104,6 +153,11 @@
 			files = (
 			);
 		};
+		A7B63C0D307CB001003F0D4E /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			files = (
+			);
+		};
 /* End PBXResourcesBuildPhase section */
 
 /* Begin PBXSourcesBuildPhase section */
@@ -112,8 +166,21 @@
 			files = (
 			);
 		};
+		A7B63C0A307CB001003F0D4E /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			files = (
+			);
+		};
 /* End PBXSourcesBuildPhase section */
 
+/* Begin PBXTargetDependency section */
+		A7B63C12307CB001003F0D4E /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = A7B63BDA307CA8F5003F0D4E /* indeed_ai_job_search */;
+			targetProxy = A7B63C10307CB001003F0D4E /* PBXContainerItemProxy */;
+		};
+/* End PBXTargetDependency section */
+
 /* Begin XCBuildConfiguration section */
 		A7B63BE4307CA8F7003F0D4E /* Debug configuration for PBXProject "indeed_ai_job_search" */ = {
 			isa = XCBuildConfiguration;
@@ -293,6 +360,48 @@
 			};
 			name = Release;
 		};
+		A7B63C15307CB001003F0D4E /* Debug configuration for PBXNativeTarget "indeed_ai_job_searchTests" */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				GENERATE_INFOPLIST_FILE = YES;
+				MACOSX_DEPLOYMENT_TARGET = 27.0;
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = "com.indeed-ai-job-search.tests";
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				STRING_CATALOG_GENERATE_SYMBOLS = NO;
+				SWIFT_APPROACHABLE_CONCURRENCY = YES;
+				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+				SWIFT_EMIT_LOC_STRINGS = NO;
+				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+				SWIFT_VERSION = 5.0;
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/indeed_ai_job_search.app/Contents/MacOS/indeed_ai_job_search";
+			};
+			name = Debug;
+		};
+		A7B63C16307CB001003F0D4E /* Release configuration for PBXNativeTarget "indeed_ai_job_searchTests" */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				GENERATE_INFOPLIST_FILE = YES;
+				MACOSX_DEPLOYMENT_TARGET = 27.0;
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = "com.indeed-ai-job-search.tests";
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				STRING_CATALOG_GENERATE_SYMBOLS = NO;
+				SWIFT_APPROACHABLE_CONCURRENCY = YES;
+				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+				SWIFT_EMIT_LOC_STRINGS = NO;
+				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+				SWIFT_VERSION = 5.0;
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/indeed_ai_job_search.app/Contents/MacOS/indeed_ai_job_search";
+			};
+			name = Release;
+		};
 /* End XCBuildConfiguration section */
 
 /* Begin XCConfigurationList section */
@@ -312,6 +421,14 @@
 			);
 			defaultConfigurationName = Release;
 		};
+		A7B63C14307CB001003F0D4E /* Build configuration list for PBXNativeTarget "indeed_ai_job_searchTests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				A7B63C15307CB001003F0D4E /* Debug configuration for PBXNativeTarget "indeed_ai_job_searchTests" */,
+				A7B63C16307CB001003F0D4E /* Release configuration for PBXNativeTarget "indeed_ai_job_searchTests" */,
+			);
+			defaultConfigurationName = Release;
+		};
 /* End XCConfigurationList section */
 	};
 	rootObject = A7B63BD3307CA8F5003F0D4E /* Project object */;

+ 1 - 0
indeed_ai_job_search/App/indeed_ai_job_searchApp.swift

@@ -13,6 +13,7 @@ struct indeed_ai_job_searchApp: App {
         WindowGroup {
             RootShellView(dependencies: dependencies)
                 .ignoresSafeArea()
+                .frame(minWidth: AppSpacing.minWindowWidth, minHeight: 520)
         }
         .windowStyle(.hiddenTitleBar)
         .defaultSize(width: 1280, height: 820)

+ 75 - 0
indeed_ai_job_search/DesignSystem/Components/SectionHeader.swift

@@ -0,0 +1,75 @@
+//
+//  SectionHeader.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+struct SectionHeader: View {
+    let title: String
+    var subtitle: String? = nil
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 4) {
+            Text(title)
+                .font(AppTypography.filterHeader)
+                .foregroundStyle(AppColors.textPrimary)
+            if let subtitle {
+                Text(subtitle)
+                    .font(AppTypography.meta)
+                    .foregroundStyle(AppColors.textSecondary)
+                    .fixedSize(horizontal: false, vertical: true)
+            }
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .accessibilityElement(children: .combine)
+    }
+}
+
+struct AlertFrequencyPicker: View {
+    @Binding var frequency: AlertFrequency
+
+    var body: some View {
+        Picker("Frequency", selection: $frequency) {
+            ForEach(AlertFrequency.allCases) { option in
+                Text(option.displayName).tag(option)
+            }
+        }
+        .accessibilityLabel("Alert frequency")
+    }
+}
+
+struct NotificationPermissionBanner: View {
+    let statusLabel: String
+    var message: String = "System notifications for job alerts require macOS permission."
+
+    var body: some View {
+        HStack(alignment: .top, spacing: AppSpacing.sm) {
+            Image(systemName: "bell.badge")
+                .font(.body.weight(.semibold))
+                .foregroundStyle(AppColors.brand)
+                .frame(width: 28, height: 28)
+                .background(AppColors.brandSoft)
+                .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
+                .accessibilityHidden(true)
+
+            VStack(alignment: .leading, spacing: 2) {
+                Text("Notification permission")
+                    .font(AppTypography.filterHeader)
+                    .foregroundStyle(AppColors.textPrimary)
+                Text("\(statusLabel). \(message)")
+                    .font(AppTypography.meta)
+                    .foregroundStyle(AppColors.textSecondary)
+                    .fixedSize(horizontal: false, vertical: true)
+            }
+
+            Spacer(minLength: 0)
+        }
+        .padding(AppSpacing.sm)
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .background(AppColors.brandSoft.opacity(0.55))
+        .clipShape(RoundedRectangle(cornerRadius: AppSpacing.inputRadius, style: .continuous))
+        .accessibilityElement(children: .combine)
+        .accessibilityLabel("Notification permission: \(statusLabel)")
+    }
+}

+ 16 - 15
indeed_ai_job_search/DesignSystem/Theme/AppTypography.swift

@@ -5,20 +5,21 @@
 
 import SwiftUI
 
+/// Semantic text styles that scale with Dynamic Type / Large Text.
 enum AppTypography {
-    static let brandTitle = Font.system(size: 15, weight: .semibold, design: .default)
-    static let sidebarItem = Font.system(size: 13, weight: .medium, design: .default)
-    static let sectionTitle = Font.system(size: 15, weight: .semibold, design: .default)
-    static let jobTitle = Font.system(size: 15, weight: .semibold, design: .default)
-    static let company = Font.system(size: 13, weight: .regular, design: .default)
-    static let body = Font.system(size: 13, weight: .regular, design: .default)
-    static let meta = Font.system(size: 12, weight: .regular, design: .default)
-    static let caption = Font.system(size: 11, weight: .medium, design: .default)
-    static let salary = Font.system(size: 13, weight: .semibold, design: .default)
-    static let button = Font.system(size: 13, weight: .semibold, design: .default)
-    static let chip = Font.system(size: 12, weight: .medium, design: .default)
-    static let resultsCount = Font.system(size: 13, weight: .semibold, design: .default)
-    static let filterHeader = Font.system(size: 13, weight: .semibold, design: .default)
-    static let heroTitle = Font.system(size: 28, weight: .bold, design: .default)
-    static let heroSubtitle = Font.system(size: 15, weight: .regular, design: .default)
+    static let brandTitle = Font.subheadline.weight(.semibold)
+    static let sidebarItem = Font.callout.weight(.medium)
+    static let sectionTitle = Font.headline.weight(.semibold)
+    static let jobTitle = Font.headline.weight(.semibold)
+    static let company = Font.callout
+    static let body = Font.callout
+    static let meta = Font.caption
+    static let caption = Font.caption2.weight(.medium)
+    static let salary = Font.callout.weight(.semibold)
+    static let button = Font.callout.weight(.semibold)
+    static let chip = Font.caption.weight(.medium)
+    static let resultsCount = Font.callout.weight(.semibold)
+    static let filterHeader = Font.callout.weight(.semibold)
+    static let heroTitle = Font.largeTitle.weight(.bold)
+    static let heroSubtitle = Font.title3
 }

+ 5 - 0
indeed_ai_job_search/Domain/Models/SearchCriteria.swift

@@ -52,6 +52,11 @@ struct SearchCriteria: Hashable, Codable {
             chips.append(FilterChip(id: "salary", label: "\(formatted)+", kind: .salary))
         }
 
+        let excluded = excludedKeywords.trimmingCharacters(in: .whitespacesAndNewlines)
+        if !excluded.isEmpty {
+            chips.append(FilterChip(id: "exclude", label: "Exclude: \(excluded)", kind: .exclude))
+        }
+
         return chips
     }
 

+ 1 - 5
indeed_ai_job_search/Features/JobAlerts/Views/JobAlertsView.swift

@@ -180,11 +180,7 @@ struct AlertEditorSheet: View {
                 TextField("Country", text: $viewModel.draftCriteria.country)
                 TextField("City", text: $viewModel.draftCriteria.city)
 
-                Picker("Frequency", selection: $viewModel.draftFrequency) {
-                    ForEach(AlertFrequency.allCases) { frequency in
-                        Text(frequency.displayName).tag(frequency)
-                    }
-                }
+                AlertFrequencyPicker(frequency: $viewModel.draftFrequency)
 
                 Toggle("In-app notifications", isOn: $viewModel.draftNotifyInApp)
                 Toggle("System notifications", isOn: $viewModel.draftNotifySystem)

+ 7 - 1
indeed_ai_job_search/Features/JobSearch/Views/FiltersPanelView.swift

@@ -10,6 +10,7 @@ struct FiltersPanelView: View {
     var onReset: () -> Void
     var onChange: () -> Void
 
+    @Environment(\.accessibilityReduceMotion) private var reduceMotion
     @State private var expanded: Set<FilterSection> = [
         .jobTitle, .exclude, .location, .employment, .experience
     ]
@@ -59,13 +60,18 @@ struct FiltersPanelView: View {
     ) -> some View {
         VStack(alignment: .leading, spacing: 0) {
             Button {
-                withAnimation(.easeInOut(duration: 0.2)) {
+                let toggle = {
                     if expanded.contains(section) {
                         expanded.remove(section)
                     } else {
                         expanded.insert(section)
                     }
                 }
+                if reduceMotion {
+                    toggle()
+                } else {
+                    withAnimation(.easeInOut(duration: 0.2), toggle)
+                }
             } label: {
                 HStack {
                     Text(section.title)

+ 6 - 22
indeed_ai_job_search/Features/Settings/Views/SettingsView.swift

@@ -75,7 +75,7 @@ struct SettingsView: View {
                 } label: {
                     HStack(spacing: 6) {
                         Image(systemName: mode.systemImage)
-                            .font(.system(size: 12, weight: .semibold))
+                            .font(.caption.weight(.semibold))
                         Text(mode.displayName)
                             .font(AppTypography.chip)
                     }
@@ -111,14 +111,7 @@ struct SettingsView: View {
             title: "Notifications",
             subtitle: "Control how job alerts reach you on this Mac."
         ) {
-            settingsRow(
-                icon: "bell.badge",
-                title: "macOS permission",
-                subtitle: viewModel.notificationPermissionLabel,
-                showsChevron: false
-            )
-
-            Divider().foregroundStyle(AppColors.border)
+            NotificationPermissionBanner(statusLabel: viewModel.notificationPermissionLabel)
 
             Toggle("Quiet hours", isOn: $viewModel.quietHoursEnabled)
                 .toggleStyle(FilterCheckboxToggleStyle())
@@ -216,7 +209,7 @@ struct SettingsView: View {
                         .fill(AppColors.brand)
                         .frame(width: 40, height: 40)
                     Image(systemName: "checkmark")
-                        .font(.system(size: 16, weight: .bold))
+                        .font(.body.weight(.bold))
                         .foregroundStyle(.white)
                 }
 
@@ -274,16 +267,7 @@ struct SettingsView: View {
         @ViewBuilder content: () -> Content
     ) -> some View {
         VStack(alignment: .leading, spacing: AppSpacing.md) {
-            VStack(alignment: .leading, spacing: 4) {
-                Text(title)
-                    .font(AppTypography.filterHeader)
-                    .foregroundStyle(AppColors.textPrimary)
-                Text(subtitle)
-                    .font(AppTypography.meta)
-                    .foregroundStyle(AppColors.textSecondary)
-                    .fixedSize(horizontal: false, vertical: true)
-            }
-
+            SectionHeader(title: title, subtitle: subtitle)
             content()
         }
         .padding(AppSpacing.lg)
@@ -308,7 +292,7 @@ struct SettingsView: View {
     ) -> some View {
         let rowContent = HStack(alignment: .center, spacing: AppSpacing.sm) {
             Image(systemName: icon)
-                .font(.system(size: 13, weight: .semibold))
+                .font(.caption.weight(.semibold))
                 .foregroundStyle(AppColors.brand)
                 .frame(width: 28, height: 28)
                 .background(AppColors.brandSoft)
@@ -334,7 +318,7 @@ struct SettingsView: View {
 
             if showsChevron {
                 Image(systemName: "chevron.right")
-                    .font(.system(size: 11, weight: .semibold))
+                    .font(.caption2.weight(.semibold))
                     .foregroundStyle(AppColors.textTertiary)
             }
         }

+ 12 - 0
indeed_ai_job_search/Notifications/NotificationsPlaceholder.swift

@@ -0,0 +1,12 @@
+//
+//  NotificationsPlaceholder.swift
+//  indeed_ai_job_search
+//
+//  Phase 6 will replace this stub with AlertScheduler + UserNotifications.
+//
+
+import Foundation
+
+enum NotificationsPlaceholder {
+    static let phaseNote = "Alert scheduling and system notifications land in Phase 6."
+}

+ 12 - 0
indeed_ai_job_search/Persistence/PersistencePlaceholder.swift

@@ -0,0 +1,12 @@
+//
+//  PersistencePlaceholder.swift
+//  indeed_ai_job_search
+//
+//  Phase 5 will replace this stub with SwiftData PersistenceController.
+//
+
+import Foundation
+
+enum PersistencePlaceholder {
+    static let phaseNote = "SwiftData saved jobs and alerts land in Phase 5."
+}

+ 17 - 0
indeed_ai_job_search/Resources/IndeedFixtures/invalid_non_indeed.json

@@ -0,0 +1,17 @@
+{
+  "pageIndex": 0,
+  "pageSize": 25,
+  "totalEstimated": 1,
+  "nextCursor": null,
+  "hasMore": false,
+  "results": [
+    {
+      "jobKey": "bad-board-001",
+      "title": "Should Be Rejected",
+      "company": "Other Board Inc",
+      "location": "Remote",
+      "url": "https://example-jobs.com/jobs/123",
+      "availability": "open"
+    }
+  ]
+}

+ 61 - 0
indeed_ai_job_search/Resources/IndeedFixtures/search_page_0.json

@@ -0,0 +1,61 @@
+{
+  "pageIndex": 0,
+  "pageSize": 25,
+  "totalEstimated": 3,
+  "nextCursor": "page-1",
+  "hasMore": true,
+  "results": [
+    {
+      "jobKey": "fixture-ios-lahore-001",
+      "title": "iOS Developer",
+      "company": "Nexlify Studio",
+      "location": "Lahore, Pakistan",
+      "remoteType": "hybrid",
+      "employmentType": "full-time",
+      "experienceLevel": "mid",
+      "salaryMin": 1800000,
+      "salaryMax": 2800000,
+      "salaryCurrency": "PKR",
+      "salaryPeriod": "year",
+      "postedAt": "2026-10-08T09:30:00Z",
+      "snippet": "Build SwiftUI features for a consumer productivity app. Collaboration with design and backend.",
+      "url": "https://pk.indeed.com/viewjob?jk=fixture-ios-lahore-001",
+      "availability": "open",
+      "isEasyApply": true,
+      "isFeatured": false,
+      "isUrgentlyHiring": false
+    },
+    {
+      "jobKey": "fixture-swift-remote-002",
+      "title": "Senior Swift Engineer",
+      "company": "Alpine Cloud",
+      "location": "Remote, Pakistan",
+      "remoteType": "remote",
+      "employmentType": "full-time",
+      "experienceLevel": "senior",
+      "salaryMin": 90000,
+      "salaryMax": 130000,
+      "salaryCurrency": "USD",
+      "salaryPeriod": "year",
+      "postedAt": "2026-10-10T14:00:00Z",
+      "snippet": "Own macOS and iOS modules. Mentorship and architecture reviews expected.",
+      "url": "https://www.indeed.com/viewjob?jk=fixture-swift-remote-002",
+      "availability": "open",
+      "isEasyApply": false,
+      "isFeatured": true,
+      "isUrgentlyHiring": false
+    },
+    {
+      "title": "Android Developer",
+      "company": "Karachi Labs",
+      "location": "Karachi, Pakistan",
+      "remoteType": "onsite",
+      "employmentType": "contract",
+      "experienceLevel": "junior",
+      "postedAt": "2026-10-05T11:15:00Z",
+      "snippet": "Kotlin multiplatform experiments for internal tools.",
+      "url": "https://pk.indeed.com/viewjob?jk=fixture-android-khi-003",
+      "availability": "unknown"
+    }
+  ]
+}

+ 22 - 0
indeed_ai_job_search/Resources/IndeedFixtures/search_page_1.json

@@ -0,0 +1,22 @@
+{
+  "pageIndex": 1,
+  "pageSize": 25,
+  "totalEstimated": 3,
+  "nextCursor": null,
+  "hasMore": false,
+  "results": [
+    {
+      "jobKey": "fixture-expired-004",
+      "title": "Mobile Engineer (Filled)",
+      "company": "Legacy Corp",
+      "location": "Islamabad, Pakistan",
+      "remoteType": "hybrid",
+      "employmentType": "full-time",
+      "experienceLevel": "mid",
+      "postedAt": "2026-09-01T08:00:00Z",
+      "snippet": "This role is no longer accepting applications.",
+      "url": "https://pk.indeed.com/viewjob?jk=fixture-expired-004",
+      "availability": "unavailable"
+    }
+  ]
+}

+ 71 - 0
indeed_ai_job_search/Services/Indeed/FixtureIndeedJobDataSource.swift

@@ -0,0 +1,71 @@
+//
+//  FixtureIndeedJobDataSource.swift
+//  indeed_ai_job_search
+//
+//  Sandbox source — reads bundled JSON fixtures. No production network traffic.
+//
+
+import Foundation
+
+struct FixtureIndeedJobDataSource: IndeedJobDataSource {
+    /// Bundle containing `IndeedFixtures/*.json`. Defaults to main app bundle.
+    var bundle: Bundle = .main
+    var fixtureDirectory: String = "IndeedFixtures"
+    /// Simulate latency for UI demos without hitting a network.
+    var delayNanoseconds: UInt64 = 0
+    var shouldFail: IndeedDataError?
+
+    func searchPage(
+        criteria: SearchCriteria,
+        query: String,
+        page: IndeedSearchPageRequest
+    ) async throws -> IndeedSearchPage {
+        if let shouldFail {
+            throw shouldFail
+        }
+
+        if delayNanoseconds > 0 {
+            try await Task.sleep(nanoseconds: delayNanoseconds)
+        }
+
+        let resourceName = "search_page_\(page.pageIndex)"
+        guard let url = bundle.url(
+            forResource: resourceName,
+            withExtension: "json",
+            subdirectory: fixtureDirectory
+        ) ?? bundle.url(forResource: resourceName, withExtension: "json") else {
+            throw IndeedDataError.fixtureMissing(resourceName)
+        }
+
+        let data = try Data(contentsOf: url)
+        var result = try IndeedListingMapper.decodeFixture(from: data)
+
+        // Light client-side filter so fixtures still respect criteria in demos.
+        result.listings = filter(result.listings, criteria: criteria, query: query)
+        return result
+    }
+
+    private func filter(
+        _ listings: [JobListing],
+        criteria: SearchCriteria,
+        query: String
+    ) -> [JobListing] {
+        let needle = (query + " " + criteria.jobTitle + " " + criteria.keywords)
+            .lowercased()
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+
+        return listings.filter { job in
+            if !needle.isEmpty {
+                let haystack = "\(job.title) \(job.company) \(job.location) \(job.snippet)".lowercased()
+                let tokens = needle.split(separator: " ").map(String.init)
+                if !tokens.contains(where: { haystack.contains($0) }) {
+                    return false
+                }
+            }
+            if !criteria.remoteTypes.isEmpty, !criteria.remoteTypes.contains(job.remoteType) {
+                return false
+            }
+            return true
+        }
+    }
+}

+ 98 - 0
indeed_ai_job_search/Services/Indeed/IndeedJobDataSource.swift

@@ -0,0 +1,98 @@
+//
+//  IndeedJobDataSource.swift
+//  indeed_ai_job_search
+//
+//  Phase 2 contract — live network adapters require DADR approval.
+//
+
+import Foundation
+
+/// Paginated Indeed-only job search. Conforms to the Phase 1 `JobDataSource` surface
+/// via `search(criteria:query:)` defaulting to the first page.
+protocol IndeedJobDataSource: JobDataSource {
+    func searchPage(
+        criteria: SearchCriteria,
+        query: String,
+        page: IndeedSearchPageRequest
+    ) async throws -> IndeedSearchPage
+}
+
+extension IndeedJobDataSource {
+    func search(criteria: SearchCriteria, query: String) async throws -> [JobListing] {
+        let page = try await searchPage(
+            criteria: criteria,
+            query: query,
+            page: IndeedSearchPageRequest(pageIndex: 0, pageSize: IndeedSearchPageRequest.defaultPageSize)
+        )
+        return page.listings
+    }
+}
+
+struct IndeedSearchPageRequest: Hashable, Sendable {
+    static let defaultPageSize = 25
+
+    var pageIndex: Int
+    var pageSize: Int
+    /// Opaque cursor from a prior response when the provider uses cursor pagination.
+    var cursor: String?
+
+    init(pageIndex: Int = 0, pageSize: Int = Self.defaultPageSize, cursor: String? = nil) {
+        self.pageIndex = max(0, pageIndex)
+        self.pageSize = min(max(1, pageSize), 100)
+        self.cursor = cursor
+    }
+}
+
+struct IndeedSearchPage: Hashable, Sendable {
+    var listings: [JobListing]
+    var pageIndex: Int
+    var pageSize: Int
+    var totalEstimated: Int?
+    var nextCursor: String?
+    var hasMore: Bool
+}
+
+enum IndeedDataError: LocalizedError, Equatable, Sendable {
+    case unauthorized
+    case forbidden
+    case rateLimited(retryAfterSeconds: Int?)
+    case invalidResponse(String)
+    case unsupportedFilter(String)
+    case network(String)
+    case cancelled
+    case nonIndeedURL(String)
+    case fixtureMissing(String)
+
+    var errorDescription: String? {
+        switch self {
+        case .unauthorized:
+            return "Indeed data source credentials are missing or invalid."
+        case .forbidden:
+            return "This Indeed data source is not allowed for the current region or license."
+        case .rateLimited(let seconds):
+            if let seconds {
+                return "Too many requests. Try again in about \(seconds) seconds."
+            }
+            return "Too many requests. Try again shortly."
+        case .invalidResponse(let detail):
+            return "Unexpected job data format. \(detail)"
+        case .unsupportedFilter(let name):
+            return "Filter “\(name)” is not supported by the Indeed source."
+        case .network(let detail):
+            return "Could not reach the Indeed data source. \(detail)"
+        case .cancelled:
+            return "The search was cancelled."
+        case .nonIndeedURL(let value):
+            return "Rejected a non-Indeed job URL: \(value)"
+        case .fixtureMissing(let name):
+            return "Missing fixture “\(name)”."
+        }
+    }
+}
+
+/// Suggested in-memory cache policy for Phase 4+ adapters (documented here for DADR).
+enum IndeedCachePolicy {
+    static let listingMetadataTTL: TimeInterval = 15 * 60
+    static let savedJobRevalidationTTL: TimeInterval = 24 * 60 * 60
+    static let maxPagesPerSearchUI = 4
+}

+ 171 - 0
indeed_ai_job_search/Services/Indeed/IndeedListingMapper.swift

@@ -0,0 +1,171 @@
+//
+//  IndeedListingMapper.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+/// Provider-agnostic DTO for sandbox fixtures and future licensed feeds.
+struct IndeedJobDTO: Codable, Hashable, Sendable {
+    var jobKey: String? = nil
+    var title: String
+    var company: String
+    var location: String
+    var remoteType: String? = nil
+    var employmentType: String? = nil
+    var experienceLevel: String? = nil
+    var salaryMin: Double? = nil
+    var salaryMax: Double? = nil
+    var salaryCurrency: String? = nil
+    var salaryPeriod: String? = nil
+    var postedAt: String? = nil
+    var snippet: String? = nil
+    var url: String
+    var availability: String? = nil
+    var isEasyApply: Bool? = nil
+    var isFeatured: Bool? = nil
+    var isUrgentlyHiring: Bool? = nil
+}
+
+struct IndeedSearchFixtureDTO: Codable, Hashable, Sendable {
+    var pageIndex: Int
+    var pageSize: Int
+    var totalEstimated: Int?
+    var nextCursor: String?
+    var hasMore: Bool
+    var results: [IndeedJobDTO]
+}
+
+enum IndeedListingMapper {
+    private static let isoFractional: ISO8601DateFormatter = {
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+        return formatter
+    }()
+
+    private static let isoBasic: ISO8601DateFormatter = {
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime]
+        return formatter
+    }()
+
+    static func map(_ dto: IndeedJobDTO) throws -> JobListing {
+        let url = try IndeedURLCanonicalizer.canonicalize(dto.url)
+        let key = IndeedURLCanonicalizer.resolveJobKey(providerKey: dto.jobKey, url: url)
+
+        let title = dto.title.trimmingCharacters(in: .whitespacesAndNewlines)
+        let company = dto.company.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !title.isEmpty, !company.isEmpty else {
+            throw IndeedDataError.invalidResponse("Missing title or company for job key \(key).")
+        }
+
+        return JobListing(
+            indeedJobKey: key,
+            title: title,
+            company: company,
+            location: dto.location.trimmingCharacters(in: .whitespacesAndNewlines),
+            remoteType: mapRemote(dto.remoteType),
+            employmentType: mapEmployment(dto.employmentType),
+            experienceLevel: mapExperience(dto.experienceLevel),
+            salary: mapSalary(dto),
+            postedAt: parseDate(dto.postedAt) ?? Date(timeIntervalSince1970: 0),
+            snippet: (dto.snippet ?? "").trimmingCharacters(in: .whitespacesAndNewlines),
+            indeedURL: url,
+            availability: mapAvailability(dto.availability),
+            isEasyApply: dto.isEasyApply ?? false,
+            isFeatured: dto.isFeatured ?? false,
+            isUrgentlyHiring: dto.isUrgentlyHiring ?? false
+        )
+    }
+
+    static func mapPage(_ fixture: IndeedSearchFixtureDTO) throws -> IndeedSearchPage {
+        var listings: [JobListing] = []
+        listings.reserveCapacity(fixture.results.count)
+        for dto in fixture.results {
+            listings.append(try map(dto))
+        }
+        return IndeedSearchPage(
+            listings: listings,
+            pageIndex: fixture.pageIndex,
+            pageSize: fixture.pageSize,
+            totalEstimated: fixture.totalEstimated,
+            nextCursor: fixture.nextCursor,
+            hasMore: fixture.hasMore
+        )
+    }
+
+    static func decodeFixture(from data: Data) throws -> IndeedSearchPage {
+        do {
+            let fixture = try JSONDecoder().decode(IndeedSearchFixtureDTO.self, from: data)
+            return try mapPage(fixture)
+        } catch let error as IndeedDataError {
+            throw error
+        } catch {
+            throw IndeedDataError.invalidResponse(error.localizedDescription)
+        }
+    }
+
+    // MARK: - Field maps
+
+    private static func mapRemote(_ raw: String?) -> RemoteType {
+        switch raw?.lowercased() {
+        case "remote": return .remote
+        case "hybrid": return .hybrid
+        case "onsite", "on-site", "on_site": return .onsite
+        default: return .unknown
+        }
+    }
+
+    private static func mapEmployment(_ raw: String?) -> EmploymentType? {
+        switch raw?.lowercased() {
+        case "fulltime", "full_time", "full-time": return .fullTime
+        case "parttime", "part_time", "part-time": return .partTime
+        case "contract": return .contract
+        case "temporary", "temp": return .temporary
+        case "internship", "intern": return .internship
+        default: return nil
+        }
+    }
+
+    private static func mapExperience(_ raw: String?) -> ExperienceLevel? {
+        switch raw?.lowercased() {
+        case "entry", "entry-level", "entry_level": return .entry
+        case "junior": return .junior
+        case "mid", "mid-level", "mid_level": return .mid
+        case "senior": return .senior
+        default: return nil
+        }
+    }
+
+    private static func mapAvailability(_ raw: String?) -> JobAvailability {
+        switch raw?.lowercased() {
+        case "open": return .open
+        case "unavailable", "closed", "expired": return .unavailable
+        default: return .unknown
+        }
+    }
+
+    private static func mapSalary(_ dto: IndeedJobDTO) -> SalaryRange? {
+        guard dto.salaryMin != nil || dto.salaryMax != nil else { return nil }
+        let period: SalaryPeriod
+        switch dto.salaryPeriod?.lowercased() {
+        case "month", "monthly": period = .month
+        case "hour", "hourly": period = .hour
+        case "week", "weekly": period = .week
+        default: period = .year
+        }
+        return SalaryRange(
+            min: dto.salaryMin,
+            max: dto.salaryMax,
+            currency: dto.salaryCurrency ?? "USD",
+            period: period
+        )
+    }
+
+    private static func parseDate(_ raw: String?) -> Date? {
+        guard let raw, !raw.isEmpty else { return nil }
+        if let date = isoFractional.date(from: raw) { return date }
+        if let date = isoBasic.date(from: raw) { return date }
+        return nil
+    }
+}

+ 84 - 0
indeed_ai_job_search/Services/Indeed/IndeedURLCanonicalizer.swift

@@ -0,0 +1,84 @@
+//
+//  IndeedURLCanonicalizer.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+enum IndeedURLCanonicalizer {
+    /// Hosts that look like Indeed regional sites (e.g. indeed.com, pk.indeed.com, indeed.co.uk).
+    static func isIndeedHost(_ host: String) -> Bool {
+        let lowered = host.lowercased()
+        if lowered == "indeed.com" || lowered.hasSuffix(".indeed.com") {
+            return true
+        }
+        // Country-style second-level domains: indeed.co.uk, indeed.com.au, etc.
+        let parts = lowered.split(separator: ".")
+        guard let indeedIndex = parts.firstIndex(of: "indeed") else { return false }
+        return indeedIndex == 0 || indeedIndex == parts.startIndex
+    }
+
+    static func canonicalize(_ raw: String) throws -> URL {
+        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard var components = URLComponents(string: trimmed) else {
+            throw IndeedDataError.nonIndeedURL(raw)
+        }
+
+        if components.scheme == nil {
+            components.scheme = "https"
+        }
+        guard components.scheme?.lowercased() == "https" || components.scheme?.lowercased() == "http" else {
+            throw IndeedDataError.nonIndeedURL(raw)
+        }
+        components.scheme = "https"
+
+        guard let host = components.host, isIndeedHost(host) else {
+            throw IndeedDataError.nonIndeedURL(raw)
+        }
+
+        guard let url = components.url else {
+            throw IndeedDataError.nonIndeedURL(raw)
+        }
+        return url
+    }
+
+    static func extractJobKey(from url: URL) -> String? {
+        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
+            return nil
+        }
+        if let jk = components.queryItems?.first(where: { $0.name.lowercased() == "jk" })?.value,
+           !jk.isEmpty {
+            return jk
+        }
+
+        let path = components.path
+        // e.g. /viewjob/abc123 or /rc/clk/... rarely; prefer query.
+        let segments = path.split(separator: "/").map(String.init)
+        if let viewIndex = segments.firstIndex(of: "viewjob"),
+           viewIndex + 1 < segments.count {
+            let candidate = segments[viewIndex + 1]
+            if !candidate.isEmpty { return candidate }
+        }
+        return nil
+    }
+
+    /// Prefer provider key; else URL `jk`; else stable hash of canonical URL.
+    static func resolveJobKey(providerKey: String?, url: URL) -> String {
+        if let providerKey {
+            let trimmed = providerKey.trimmingCharacters(in: .whitespacesAndNewlines)
+            if !trimmed.isEmpty { return trimmed }
+        }
+        if let fromURL = extractJobKey(from: url) {
+            return fromURL
+        }
+        return "url-\(stableHash(url.absoluteString))"
+    }
+
+    private static func stableHash(_ string: String) -> String {
+        var hash: UInt64 = 5381
+        for byte in string.utf8 {
+            hash = ((hash << 5) &+ hash) &+ UInt64(byte)
+        }
+        return String(hash, radix: 16)
+    }
+}

+ 3 - 0
indeed_ai_job_search/Services/Mock/MockServices.swift

@@ -6,6 +6,9 @@
 import Foundation
 import Observation
 
+/// Plan alias — Phase 1 mock search uses `MockJobDataSource`.
+typealias MockJobSearchService = MockJobDataSource
+
 struct MockJobDataSource: JobDataSource {
     var delayNanoseconds: UInt64 = 700_000_000
     var shouldFail: Bool = false

+ 12 - 0
indeed_ai_job_search/Services/OpenAI/OpenAIServicePlaceholder.swift

@@ -0,0 +1,12 @@
+//
+//  OpenAIServicePlaceholder.swift
+//  indeed_ai_job_search
+//
+//  Phase 3 will replace this stub with OpenAIClient + SecretsProvider.
+//
+
+import Foundation
+
+enum OpenAIServicePlaceholder {
+    static let phaseNote = "OpenAI interpretation lands in Phase 3."
+}

+ 152 - 0
indeed_ai_job_searchTests/IndeedListingMapperTests.swift

@@ -0,0 +1,152 @@
+//
+//  IndeedListingMapperTests.swift
+//  indeed_ai_job_searchTests
+//
+
+import XCTest
+@testable import indeed_ai_job_search
+
+private let searchPage0JSON = """
+{
+  "pageIndex": 0,
+  "pageSize": 25,
+  "totalEstimated": 3,
+  "nextCursor": "page-1",
+  "hasMore": true,
+  "results": [
+    {
+      "jobKey": "fixture-ios-lahore-001",
+      "title": "iOS Developer",
+      "company": "Nexlify Studio",
+      "location": "Lahore, Pakistan",
+      "remoteType": "hybrid",
+      "employmentType": "full-time",
+      "experienceLevel": "mid",
+      "salaryMin": 1800000,
+      "salaryMax": 2800000,
+      "salaryCurrency": "PKR",
+      "salaryPeriod": "year",
+      "postedAt": "2026-10-08T09:30:00Z",
+      "snippet": "Build SwiftUI features for a consumer productivity app.",
+      "url": "https://pk.indeed.com/viewjob?jk=fixture-ios-lahore-001",
+      "availability": "open",
+      "isEasyApply": true
+    },
+    {
+      "jobKey": "fixture-swift-remote-002",
+      "title": "Senior Swift Engineer",
+      "company": "Alpine Cloud",
+      "location": "Remote, Pakistan",
+      "remoteType": "remote",
+      "employmentType": "full-time",
+      "experienceLevel": "senior",
+      "salaryMin": 90000,
+      "salaryMax": 130000,
+      "salaryCurrency": "USD",
+      "salaryPeriod": "year",
+      "postedAt": "2026-10-10T14:00:00Z",
+      "snippet": "Own macOS and iOS modules.",
+      "url": "https://www.indeed.com/viewjob?jk=fixture-swift-remote-002",
+      "availability": "open"
+    },
+    {
+      "title": "Android Developer",
+      "company": "Karachi Labs",
+      "location": "Karachi, Pakistan",
+      "remoteType": "onsite",
+      "url": "https://pk.indeed.com/viewjob?jk=fixture-android-khi-003",
+      "availability": "unknown",
+      "snippet": "Kotlin multiplatform experiments."
+    }
+  ]
+}
+"""
+
+final class IndeedListingMapperTests: XCTestCase {
+    func testMapsFixturePageToJobListings() throws {
+        let page = try IndeedListingMapper.decodeFixture(from: Data(searchPage0JSON.utf8))
+
+        XCTAssertEqual(page.listings.count, 3)
+        XCTAssertEqual(page.pageIndex, 0)
+        XCTAssertTrue(page.hasMore)
+        XCTAssertEqual(page.nextCursor, "page-1")
+
+        let first = try XCTUnwrap(page.listings.first)
+        XCTAssertEqual(first.indeedJobKey, "fixture-ios-lahore-001")
+        XCTAssertEqual(first.title, "iOS Developer")
+        XCTAssertEqual(first.remoteType, .hybrid)
+        XCTAssertEqual(first.employmentType, .fullTime)
+        XCTAssertEqual(first.experienceLevel, .mid)
+        XCTAssertEqual(first.salary?.currency, "PKR")
+        XCTAssertEqual(first.availability, .open)
+        XCTAssertTrue(IndeedURLCanonicalizer.isIndeedHost(first.indeedURL.host ?? ""))
+    }
+
+    func testExtractsJobKeyFromURLWhenProviderKeyMissing() throws {
+        let page = try IndeedListingMapper.decodeFixture(from: Data(searchPage0JSON.utf8))
+        let android = try XCTUnwrap(page.listings.first { $0.title.contains("Android") })
+        XCTAssertEqual(android.indeedJobKey, "fixture-android-khi-003")
+    }
+
+    func testRejectsNonIndeedURL() {
+        let dto = IndeedJobDTO(
+            jobKey: "x",
+            title: "Role",
+            company: "Co",
+            location: "Remote",
+            url: "https://example.com/jobs/1"
+        )
+        XCTAssertThrowsError(try IndeedListingMapper.map(dto)) { error in
+            guard case IndeedDataError.nonIndeedURL = error else {
+                return XCTFail("Expected nonIndeedURL, got \(error)")
+            }
+        }
+    }
+}
+
+final class IndeedURLCanonicalizerTests: XCTestCase {
+    func testAcceptsRegionalHosts() throws {
+        let url = try IndeedURLCanonicalizer.canonicalize("https://pk.indeed.com/viewjob?jk=abc")
+        XCTAssertEqual(url.host, "pk.indeed.com")
+        XCTAssertEqual(IndeedURLCanonicalizer.extractJobKey(from: url), "abc")
+    }
+
+    func testAcceptsIndeedCoUK() throws {
+        let url = try IndeedURLCanonicalizer.canonicalize("http://indeed.co.uk/viewjob?jk=uk1")
+        XCTAssertEqual(url.scheme, "https")
+        XCTAssertEqual(url.host, "indeed.co.uk")
+    }
+
+    func testRejectsForeignJobBoard() {
+        XCTAssertThrowsError(try IndeedURLCanonicalizer.canonicalize("https://linkedin.com/jobs/1"))
+    }
+}
+
+final class FixtureIndeedJobDataSourceTests: XCTestCase {
+    func testSearchPageContractFiltersByQuery() async throws {
+        let page = try IndeedListingMapper.decodeFixture(from: Data(searchPage0JSON.utf8))
+        let source = InlineFixtureIndeedJobDataSource(page: page)
+        let results = try await source.search(criteria: SearchCriteria(), query: "iOS")
+        XCTAssertTrue(results.contains { $0.title.localizedCaseInsensitiveContains("iOS") })
+    }
+}
+
+/// Test double that satisfies `IndeedJobDataSource` without depending on app bundle copy phases.
+private struct InlineFixtureIndeedJobDataSource: IndeedJobDataSource {
+    let page: IndeedSearchPage
+
+    func searchPage(
+        criteria: SearchCriteria,
+        query: String,
+        page: IndeedSearchPageRequest
+    ) async throws -> IndeedSearchPage {
+        var filtered = self.page
+        let needle = query.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
+        if !needle.isEmpty {
+            filtered.listings = filtered.listings.filter {
+                "\($0.title) \($0.company) \($0.snippet)".lowercased().contains(needle)
+            }
+        }
+        return filtered
+    }
+}