SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%

v2.0.0 (8) — MAJOR : l'app ressemble aux sites — logos officiels des 13 marques (ka-ui), sites web intégrés (WKWebView SiteBrowser par univers + hub), design system fidèle (Space Grotesk/JetBrains Mono embarquées, ombres dures 6×6, rayon 10, wordmark boîte accent), héros de site par univers (ticker encre, kicker, titre surligné, pilule live, contenu réel des sites)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 21, 2026) parent 49784a2

42 changed files +616 −62

modified KA/App/KAApp.swift +4 −2
@@ -35,8 +35,10 @@ struct KAApp: App {
35 35
36 36 struct RootView: View {
37 37 @State private var showAgent = false
38 // onglet initial pilotable pour les tests (defaults write com.groupeka.ka ka.debug.tab -int 3)
39 @State private var tab = UserDefaults.standard.integer(forKey: "ka.debug.tab")
38 + // onglet initial pilotable en debug — JAMAIS pendant les tests (les threads
39 + // MapLibre de la carte feraient une course avec le exit() de XCTest)
40 + @State private var tab = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
41 + ? 0 : UserDefaults.standard.integer(forKey: "ka.debug.tab")
40 42 @StateObject private var recents = RecentsStore.shared
41 43
42 44 var body: some View {
added KA/Core/HeroCopy.swift +85 −0
@@ -0,0 +1,85 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// HeroCopy.swift — le VRAI contenu héros de chaque site du Groupe KA (extrait
3 +// des pages d'accueil le 2026-08-21, source ka-ui/hero-copy.json) : kicker mono,
4 +// titre display avec segment surligné en boîte accent, sous-titre. C'est ce qui
5 +// fait que chaque univers de l'app ressemble à SON site.
6 +
7 +struct HeroCopy {
8 + let kicker: String?
9 + let headline: String
10 + /// Segment du titre affiché SUR l'accent (suffixe ou fragment de headline)
11 + let highlight: String?
12 + let sub: String
13 +}
14 +
15 +enum KAHeroes {
16 + static func hero(_ id: String) -> HeroCopy? { all[id] }
17 +
18 + static let all: [String: HeroCopy] = [
19 + "lou-ka": .init(
20 + kicker: "La porte d'entrée vers votre prochain chez-vous.",
21 + headline: "Trouvez votre prochain chez-vous.",
22 + highlight: "chez-vous.",
23 + sub: "Tous les logements à louer au Québec, regroupés au même endroit — mis à jour automatiquement, avec toutes les photos et un lien direct vers l'annonce originale."),
24 + "immo-ka": .init(
25 + kicker: "Agrégateur — toutes les agences du Québec",
26 + headline: "Toutes les propriétés à vendre, un seul endroit.",
27 + highlight: "un seul",
28 + sub: "Immo-Ka rassemble les propriétés à vendre affichées par les agences de courtage immobilier du Québec — RE/MAX, Sutton, Via Capitale, Proprio Direct et plus — avec toutes les photos et un lien direct vers l'annonce originale."),
29 + "vrai-prix": .init(
30 + kicker: "Québec · 2021-2026",
31 + headline: "La vraie valeur. Sans boîte noire.",
32 + highlight: "Sans boîte noire.",
33 + sub: "Estimation basée sur 3,7 millions d'unités d'évaluation et 745 000 ventes réelles au Québec (2021-2026). Pas de boîte noire : on vous montre le calcul."),
34 + "auto-ka": .init(
35 + kicker: "Agrégateur indépendant — direct des concessionnaires",
36 + headline: "Toutes les voitures usagées à vendre au Québec. Un seul endroit.",
37 + highlight: "Un seul endroit.",
38 + sub: "Auto-Ka visite les sites des concessionnaires et marchands d'occasion de toutes les régions, normalise chaque annonce et détecte les nouveautés, les ventes et les baisses de prix — automatiquement."),
39 + "fabri-ka": .init(
40 + kicker: "Le répertoire des produits d'ici",
41 + headline: "Tous les produits québécois. Un seul endroit.",
42 + highlight: "Un seul endroit.",
43 + sub: "Fabri-Ka rassemble les catalogues des boutiques en ligne d'ici — fabriqué, conçu et vendu au Québec."),
44 + "food-ka": .init(
45 + kicker: "Agrégateur — épicerie du Québec",
46 + headline: "Tous les prix d'épicerie du Québec. Un seul endroit.",
47 + highlight: "un seul",
48 + sub: "Food·Ka rassemble les produits affichés par les bannières d'épicerie du Québec — Metro, IGA, Maxi, Super C, Provigo et plus — mis à jour automatiquement, avec prix unitaires et lien direct vers la fiche originale."),
49 + "resto-ka": .init(
50 + kicker: "Agrégateur — 17 régions du Québec",
51 + headline: "Tous les restos. Tous les menus. Tous les prix.",
52 + highlight: "Tous les prix.",
53 + sub: "Resto-Ka rassemble les restaurants du Québec avec leurs menus complets et leurs prix réels — cherchez un resto ou un plat, comparez, le contexte du prix (salle, emporter, livraison) est toujours affiché."),
54 + "sorti-ka": .init(
55 + kicker: "Agrégateur indépendant · mise à jour horaire",
56 + headline: "Toutes les sorties du Québec, un seul endroit.",
57 + highlight: "un seul endroit.",
58 + sub: "Concerts, festivals, spectacles, expositions, sport, famille — agrégés depuis des billetteries et des sources ouvertes officielles, dans les 17 régions. Chaque fiche renvoie vers la billetterie ou la source originale."),
59 + "crea-ka": .init(
60 + kicker: "Annuaire des créateurs du Québec",
61 + headline: "Chaque créateur québécois. Tous ses liens.",
62 + highlight: "liens.",
63 + sub: "Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers, podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience."),
64 + "job-ka": .init(
65 + kicker: "Offres directement des employeurs",
66 + headline: "L'emploi au Québec, à la source.",
67 + highlight: "à la source.",
68 + sub: "Job·Ka agrège les offres directement depuis les pages carrières des employeurs québécois. Salaires transparents, lien vers l'offre originale, aucun intermédiaire."),
69 + "trouve-ka": .init(
70 + kicker: "Le moteur de recherche du web québécois",
71 + headline: "Trouve KA",
72 + highlight: "KA",
73 + sub: "Le moteur de recherche indépendant du web québécois."),
74 + "api-ka": .init(
75 + kicker: "Plateforme centrale · node M3U96b",
76 + headline: "Toutes les données KA, centralisées chaque jour.",
77 + highlight: "KA",
78 + sub: "API-Ka collecte, sauvegarde et historise quotidiennement les données de l'écosystème Groupe KA, puis les expose via une API publique, versionnée et documentée."),
79 + "groupe-ka": .init(
80 + kicker: "Holding d'agrégateurs automatisés · Québec",
81 + headline: "Mille sites. Un seul ·KA.",
82 + highlight: "·KA.",
83 + sub: "Ka, c'est l'agrégation. Des centaines de connecteurs lisent les sites à la source, normalisent la donnée et se resynchronisent seuls, jour et nuit."),
84 + ]
85 +}
added KA/Design/KAFonts.swift +21 −0
@@ -0,0 +1,21 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KAFonts.swift — les polices OFFICIELLES des sites du Groupe KA (tokens.css) :
3 +// display Space Grotesk · micro-étiquettes JetBrains Mono (embarquées, OFL).
4 +// Le corps de texte reste SF (équivalent d'Inter côté iOS).
5 +import SwiftUI
6 +
7 +enum KAFont {
8 + /// Display « Space Grotesk » — titres, wordmarks, chiffres héros
9 + static func display(_ size: CGFloat, _ weight: Font.Weight = .bold) -> Font {
10 + switch weight {
11 + case .bold, .heavy, .black: return .custom("SpaceGrotesk-Bold", size: size)
12 + case .medium, .semibold: return .custom("SpaceGrotesk-Medium", size: size)
13 + default: return .custom("SpaceGrotesk-Regular", size: size)
14 + }
15 + }
16 +
17 + /// Micro-étiquettes « JetBrains Mono » — kickers, compteurs, puces
18 + static func mono(_ size: CGFloat, bold: Bool = true) -> Font {
19 + .custom(bold ? "JetBrainsMono-Bold" : "JetBrainsMono-Regular", size: size)
20 + }
21 +}
modified KA/Design/Theme.swift +43 −16
@@ -29,7 +29,8 @@ enum Haptics {
29 29 static func rigid() { UIImpactFeedbackGenerator(style: .rigid).impactOccurred() }
30 30 }
31 31
32 // MARK: - Carte « éditorial sharp » (bordure encre + ombre décalée)
32 +// MARK: - Carte « éditorial sharp » — tokens EXACTS des sites (tokens.css) :
33 +// rayon 10 px, bordure encre 1,5 px, ombre décalée DURE 6×6 pleine encre.
33 34
34 35 struct KACard: ViewModifier {
35 36 @Environment(\.colorScheme) private var scheme
@@ -38,15 +39,17 @@ struct KACard: ViewModifier {
38 39 func body(content: Content) -> some View {
39 40 content
40 41 .background(KATheme.surface(scheme))
41 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
42 + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
42 43 .overlay(
43 RoundedRectangle(cornerRadius: 14, style: .continuous)
44 .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.4)
44 + RoundedRectangle(cornerRadius: 10, style: .continuous)
45 + .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5)
45 46 )
46 47 .background(
47 RoundedRectangle(cornerRadius: 14, style: .continuous)
48 .fill((accent ?? KATheme.ink(scheme)).opacity(scheme == .dark ? 0.25 : 0.16))
49 .offset(x: 5, y: 5)
48 + RoundedRectangle(cornerRadius: 10, style: .continuous)
49 + .fill(scheme == .dark
50 + ? (accent ?? KATheme.inkDark).opacity(0.25)
51 + : KATheme.inkLight)
52 + .offset(x: 6, y: 6)
50 53 )
51 54 }
52 55 }
@@ -63,18 +66,18 @@ struct KAWordmark: View {
63 66 @Environment(\.colorScheme) private var scheme
64 67
65 68 var body: some View {
69 + // fidèle à l'en-tête des sites : « Lou- » encre + boîte ACCENT au texte blanc
66 70 let parts = universe.wordmark.split(separator: "·", maxSplits: 1)
67 71 HStack(alignment: .firstTextBaseline, spacing: 3) {
68 Text(parts.first.map(String.init) ?? universe.wordmark)
69 .font(.system(size: size, weight: .bold, design: .rounded))
72 + Text(parts.count > 1 ? String(parts[0]) + "-" : universe.wordmark)
73 + .font(KAFont.display(size))
70 74 if parts.count > 1 {
71 75 Text(String(parts[1]))
72 .font(.system(size: size * 0.86, weight: .bold, design: .rounded))
73 .foregroundStyle(universe.accent)
76 + .font(KAFont.display(size * 0.86))
77 + .foregroundStyle(.white)
74 78 .padding(.horizontal, size * 0.28)
75 79 .padding(.vertical, size * 0.08)
76 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous))
77 .rotationEffect(.degrees(-2))
80 + .background(universe.accent, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous))
78 81 }
79 82 }
80 83 .foregroundStyle(KATheme.ink(scheme))
@@ -82,14 +85,38 @@ struct KAWordmark: View {
82 85 }
83 86 }
84 87
88 +// MARK: - Logo officiel (tuile de marque ka-ui, PNG 512 aux coins déjà arrondis)
89 +
90 +struct KALogo: View {
91 + let assetID: String // « lou-ka » … « groupe-ka »
92 + var size: CGFloat = 40
93 +
94 + init(universe: Universe, size: CGFloat = 40) {
95 + self.assetID = universe.id
96 + self.size = size
97 + }
98 + init(assetID: String, size: CGFloat = 40) {
99 + self.assetID = assetID
100 + self.size = size
101 + }
102 +
103 + var body: some View {
104 + Image("logo-\(assetID)")
105 + .resizable()
106 + .aspectRatio(contentMode: .fit)
107 + .frame(width: size, height: size)
108 + .accessibilityHidden(true)
109 + }
110 +}
111 +
85 112 struct GroupeKAMark: View {
86 113 var size: CGFloat = 24
87 114 @Environment(\.colorScheme) private var scheme
88 115 var body: some View {
89 116 HStack(alignment: .firstTextBaseline, spacing: 4) {
90 Text("Groupe").font(.system(size: size, weight: .bold, design: .rounded))
117 + Text("Groupe").font(KAFont.display(size))
91 118 Text("KA")
92 .font(.system(size: size * 0.9, weight: .bold, design: .rounded))
119 + .font(KAFont.display(size * 0.9))
93 120 .foregroundStyle(KATheme.lime)
94 121 .padding(.horizontal, size * 0.3).padding(.vertical, size * 0.1)
95 122 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.28, style: .continuous))
@@ -109,7 +136,7 @@ struct KAChip: View {
109 136 @Environment(\.colorScheme) private var scheme
110 137 var body: some View {
111 138 Text(text)
112 .font(.system(.caption2, design: .monospaced).weight(.bold))
139 + .font(KAFont.mono(10))
113 140 .textCase(.uppercase)
114 141 .padding(.horizontal, 9).padding(.vertical, 4)
115 142 .background((accent ?? KATheme.ink(scheme)).opacity(0.12), in: Capsule())
modified KA/Features/HomeView.swift +3 −2
@@ -64,7 +64,7 @@ struct HomeView: View {
64 64
65 65 private var header: some View {
66 66 VStack(alignment: .leading, spacing: 5) {
67 Text(greeting.title).font(.system(.largeTitle, design: .rounded).weight(.bold))
67 + Text(greeting.title).font(KAFont.display(32))
68 68 Text(greeting.sub).font(.subheadline).foregroundStyle(KATheme.ink2(scheme))
69 69 }
70 70 }
@@ -96,7 +96,8 @@ struct HomeView: View {
96 96
97 97 private func featuredSection(_ u: Universe, _ items: [KAItem]) -> some View {
98 98 VStack(alignment: .leading, spacing: 10) {
99 HStack {
99 + HStack(spacing: 8) {
100 + KALogo(universe: u, size: 24)
100 101 KAWordmark(universe: u, size: 17)
101 102 Spacer()
102 103 NavigationLink(value: u.id) {
modified KA/Features/ProfileView.swift +11 −3
@@ -10,6 +10,7 @@ struct ProfileView: View {
10 10 @StateObject private var kaid = KAIDManager.shared
11 11 @EnvironmentObject private var favorites: FavoritesStore
12 12 @EnvironmentObject private var recents: RecentsStore
13 + @State private var showHubSite = false
13 14
14 15 private var favIDs: Set<String> {
15 16 Set(favUniversesRaw.split(separator: ",").map(String.init))
@@ -88,7 +89,7 @@ struct ProfileView: View {
88 89 Haptics.tap()
89 90 } label: {
90 91 HStack {
91 Image(systemName: u.symbol).foregroundStyle(u.accent).frame(width: 26)
92 + KALogo(universe: u, size: 26)
92 93 Text(u.name).foregroundStyle(.primary)
93 94 Spacer()
94 95 if favIDs.contains(u.id) {
@@ -120,8 +121,11 @@ struct ProfileView: View {
120 121 }
121 122 }
122 123 }
123 Link(destination: Ecosystem.hubURL) {
124 Label("groupe-ka.com — le portail", systemImage: "globe")
124 + Button { showHubSite = true } label: {
125 + HStack(spacing: 10) {
126 + KALogo(assetID: "groupe-ka", size: 22)
127 + Text("groupe-ka.com — le portail")
128 + }
125 129 }
126 130 Link(destination: Ecosystem.statusURL) {
127 131 Label("État des services en direct", systemImage: "waveform.path.ecg")
@@ -148,6 +152,10 @@ struct ProfileView: View {
148 152 .navigationTitle("Profil")
149 153 .scrollContentBackground(.hidden)
150 154 .background(KATheme.paper(scheme))
155 + .fullScreenCover(isPresented: $showHubSite) {
156 + SiteBrowserCover(title: "Groupe KA", accent: KATheme.green,
157 + logoID: "groupe-ka", url: Ecosystem.hubURL)
158 + }
151 159 }
152 160 }
153 161 }
added KA/Features/SiteBrowserView.swift +201 −0
@@ -0,0 +1,201 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// SiteBrowserView.swift — les sites du Groupe KA DANS l'app : navigateur
3 +// WKWebView intégré aux couleurs de l'univers (progression, retour/avancer,
4 +// recharger, partager, ouvrir dans Safari). Utilisé en plein écran depuis
5 +// chaque univers et inline pour les univers sans liste native (hub).
6 +import SwiftUI
7 +import WebKit
8 +
9 +// MARK: - Modèle observable du WKWebView
10 +
11 +final class SiteWebModel: NSObject, ObservableObject, WKNavigationDelegate {
12 + let webView: WKWebView
13 + @Published var progress: Double = 0
14 + @Published var loading = false
15 + @Published var canGoBack = false
16 + @Published var canGoForward = false
17 + @Published var currentURL: URL
18 +
19 + private var observers: [NSKeyValueObservation] = []
20 +
21 + init(start: URL) {
22 + currentURL = start
23 + let config = WKWebViewConfiguration()
24 + config.allowsInlineMediaPlayback = true
25 + config.defaultWebpagePreferences.allowsContentJavaScript = true
26 + webView = WKWebView(frame: .zero, configuration: config)
27 + super.init()
28 + webView.navigationDelegate = self
29 + webView.allowsBackForwardNavigationGestures = true
30 + webView.isOpaque = false
31 + observers = [
32 + webView.observe(\.estimatedProgress) { [weak self] w, _ in
33 + DispatchQueue.main.async { self?.progress = w.estimatedProgress }
34 + },
35 + webView.observe(\.isLoading) { [weak self] w, _ in
36 + DispatchQueue.main.async { self?.loading = w.isLoading }
37 + },
38 + webView.observe(\.canGoBack) { [weak self] w, _ in
39 + DispatchQueue.main.async { self?.canGoBack = w.canGoBack }
40 + },
41 + webView.observe(\.canGoForward) { [weak self] w, _ in
42 + DispatchQueue.main.async { self?.canGoForward = w.canGoForward }
43 + },
44 + webView.observe(\.url) { [weak self] w, _ in
45 + DispatchQueue.main.async { if let u = w.url { self?.currentURL = u } }
46 + },
47 + ]
48 + webView.load(URLRequest(url: start))
49 + }
50 +
51 + // http(s) reste dans la vue ; tel:/mailto:/itms… → le système
52 + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
53 + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
54 + if let u = navigationAction.request.url,
55 + let scheme = u.scheme?.lowercased(),
56 + !["http", "https", "about", "blob", "data"].contains(scheme) {
57 + UIApplication.shared.open(u)
58 + decisionHandler(.cancel)
59 + return
60 + }
61 + decisionHandler(.allow)
62 + }
63 +
64 + // les target="_blank" s'ouvrent dans la même vue
65 + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
66 + for navigationAction: WKNavigationAction,
67 + windowFeatures: WKWindowFeatures) -> WKWebView? {
68 + if let u = navigationAction.request.url { webView.load(URLRequest(url: u)) }
69 + return nil
70 + }
71 +}
72 +
73 +private struct WebViewContainer: UIViewRepresentable {
74 + let webView: WKWebView
75 + func makeUIView(context: Context) -> WKWebView { webView }
76 + func updateUIView(_ uiView: WKWebView, context: Context) {}
77 +}
78 +
79 +// MARK: - Écran navigateur (embarquable : web + progression + barre de contrôle)
80 +
81 +struct SiteBrowserScreen: View {
82 + let accent: Color
83 + @ObservedObject var model: SiteWebModel
84 + @Environment(\.colorScheme) private var scheme
85 +
86 + var body: some View {
87 + VStack(spacing: 0) {
88 + WebViewContainer(webView: model.webView)
89 + .overlay(alignment: .top) {
90 + if model.loading {
91 + ProgressView(value: max(model.progress, 0.06))
92 + .progressViewStyle(.linear)
93 + .tint(accent)
94 + }
95 + }
96 + controls
97 + }
98 + .background(KATheme.paper(scheme))
99 + }
100 +
101 + private var controls: some View {
102 + HStack(spacing: 26) {
103 + Button { Haptics.tap(); model.webView.goBack() } label: {
104 + Image(systemName: "chevron.backward")
105 + }
106 + .disabled(!model.canGoBack)
107 + .accessibilityLabel("Page précédente")
108 + Button { Haptics.tap(); model.webView.goForward() } label: {
109 + Image(systemName: "chevron.forward")
110 + }
111 + .disabled(!model.canGoForward)
112 + .accessibilityLabel("Page suivante")
113 + Spacer()
114 + Text(model.currentURL.host()?.replacingOccurrences(of: "www.", with: "") ?? "")
115 + .font(.system(.caption2, design: .monospaced).weight(.bold))
116 + .foregroundStyle(.secondary)
117 + .lineLimit(1)
118 + Spacer()
119 + ShareLink(item: model.currentURL) { Image(systemName: "square.and.arrow.up") }
120 + .accessibilityLabel("Partager la page")
121 + Button {
122 + Haptics.tap()
123 + if model.loading { model.webView.stopLoading() } else { model.webView.reload() }
124 + } label: {
125 + Image(systemName: model.loading ? "xmark" : "arrow.clockwise")
126 + }
127 + .accessibilityLabel(model.loading ? "Arrêter" : "Recharger")
128 + Link(destination: model.currentURL) { Image(systemName: "safari") }
129 + .accessibilityLabel("Ouvrir dans Safari")
130 + }
131 + .font(.body.weight(.semibold))
132 + .tint(accent)
133 + .padding(.horizontal, 18)
134 + .padding(.vertical, 10)
135 + .background(.bar)
136 + }
137 +}
138 +
139 +// MARK: - Site embarqué inline (univers sans liste native, ex. le hub)
140 +
141 +struct EmbeddedSite: View {
142 + let universe: Universe
143 + @StateObject private var model: SiteWebModel
144 +
145 + init(universe: Universe) {
146 + self.universe = universe
147 + _model = StateObject(wrappedValue: SiteWebModel(start: universe.baseURL))
148 + }
149 +
150 + var body: some View {
151 + SiteBrowserScreen(accent: universe.accent, model: model)
152 + }
153 +}
154 +
155 +// MARK: - Plein écran (couverture) : titre + logo + fermer
156 +
157 +struct SiteBrowserCover: View {
158 + let title: String
159 + let accent: Color
160 + let logoID: String
161 + @StateObject private var model: SiteWebModel
162 + @Environment(\.dismiss) private var dismiss
163 +
164 + init(universe: Universe, path: String? = nil) {
165 + title = universe.name
166 + accent = universe.accent
167 + logoID = universe.id
168 + let url = path.map { universe.baseURL.appendingPathComponent($0) } ?? universe.baseURL
169 + _model = StateObject(wrappedValue: SiteWebModel(start: url))
170 + }
171 +
172 + init(title: String, accent: Color, logoID: String, url: URL) {
173 + self.title = title
174 + self.accent = accent
175 + self.logoID = logoID
176 + _model = StateObject(wrappedValue: SiteWebModel(start: url))
177 + }
178 +
179 + var body: some View {
180 + NavigationStack {
181 + SiteBrowserScreen(accent: accent, model: model)
182 + .toolbar {
183 + ToolbarItem(placement: .topBarLeading) {
184 + Button { dismiss() } label: {
185 + Image(systemName: "xmark")
186 + }
187 + .accessibilityLabel("Fermer le site")
188 + }
189 + ToolbarItem(placement: .principal) {
190 + HStack(spacing: 8) {
191 + KALogo(assetID: logoID, size: 24)
192 + Text(title).font(.headline)
193 + }
194 + }
195 + }
196 + .navigationBarTitleDisplayMode(.inline)
197 + .toolbarBackground(.visible, for: .navigationBar)
198 + }
199 + .tint(accent)
200 + }
201 +}
modified KA/Features/UniversesView.swift +119 −37
@@ -102,11 +102,7 @@ struct UniverseCard: View {
102 102 var body: some View {
103 103 VStack(alignment: .leading, spacing: 10) {
104 104 HStack {
105 Image(systemName: universe.symbol)
106 .font(.title3.weight(.semibold))
107 .foregroundStyle(universe.accent)
108 .frame(width: 40, height: 40)
109 .background(universe.accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
105 + KALogo(universe: universe, size: 40)
110 106 Spacer()
111 107 Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)
112 108 }
@@ -116,11 +112,11 @@ struct UniverseCard: View {
116 112 .lineLimit(2, reservesSpace: true)
117 113 if let total {
118 114 Text("\(total.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)")
119 .font(.system(.caption2, design: .monospaced).weight(.bold))
115 + .font(KAFont.mono(10))
120 116 .foregroundStyle(universe.accent)
121 117 .contentTransition(.numericText())
122 118 } else {
123 Text("—").font(.system(.caption2, design: .monospaced))
119 + Text("—").font(KAFont.mono(10, bold: false))
124 120 .foregroundStyle(.tertiary)
125 121 }
126 122 }
@@ -131,6 +127,109 @@ struct UniverseCard: View {
131 127 }
132 128 }
133 129
130 +// MARK: - Héros de site (reproduction fidèle de la page d'accueil de l'univers)
131 +
132 +struct SiteHero: View {
133 + let universe: Universe
134 + let liveTotal: Int?
135 + var openSite: (() -> Void)? = nil
136 + @Environment(\.colorScheme) private var scheme
137 +
138 + private var copy: HeroCopy? { KAHeroes.hero(universe.id) }
139 +
140 + var body: some View {
141 + VStack(alignment: .leading, spacing: 12) {
142 + ticker
143 + if let c = copy {
144 + if let k = c.kicker {
145 + HStack(spacing: 10) {
146 + Rectangle().fill(KATheme.green).frame(width: 26, height: 2.5)
147 + Text(k.uppercased())
148 + .font(KAFont.mono(10))
149 + .foregroundStyle(scheme == .dark ? KATheme.lime : KATheme.green)
150 + .kerning(1.1)
151 + .lineLimit(2)
152 + }
153 + }
154 + headline(c)
155 + Text(c.sub)
156 + .font(.subheadline)
157 + .foregroundStyle(KATheme.ink2(scheme))
158 + .lineLimit(4)
159 + }
160 + HStack(spacing: 10) {
161 + statPill
162 + Spacer()
163 + if let openSite {
164 + Button(action: { Haptics.tap(); openSite() }) {
165 + Text("VOIR LE SITE ↗")
166 + .font(KAFont.mono(10))
167 + .foregroundStyle(universe.accent)
168 + .padding(.horizontal, 12).padding(.vertical, 8)
169 + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
170 + }
171 + .buttonStyle(KAPressStyle())
172 + .accessibilityLabel("Voir le site \(universe.name) dans l'app")
173 + }
174 + }
175 + }
176 + }
177 +
178 + /// Bandeau ticker encre — la signature du haut des sites
179 + private var ticker: some View {
180 + HStack(spacing: 8) {
181 + KALogo(universe: universe, size: 18)
182 + Text(universe.unit.uppercased())
183 + .font(KAFont.mono(9.5))
184 + .foregroundStyle(.white.opacity(0.9))
185 + Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent)
186 + Text("EN DIRECT")
187 + .font(KAFont.mono(9.5))
188 + .foregroundStyle(universe.accent)
189 + Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent)
190 + Text(universe.domain.replacingOccurrences(of: "www.", with: "").uppercased())
191 + .font(KAFont.mono(9.5))
192 + .foregroundStyle(.white.opacity(0.75))
193 + .lineLimit(1)
194 + Spacer(minLength: 0)
195 + }
196 + .padding(.horizontal, 12).padding(.vertical, 8)
197 + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
198 + }
199 +
200 + /// Titre display avec le segment surligné SUR l'accent (comme le h1 du site)
201 + private func headline(_ c: HeroCopy) -> some View {
202 + var attr = AttributedString(c.headline)
203 + attr.font = KAFont.display(27)
204 + attr.foregroundColor = KATheme.ink(scheme)
205 + if let hl = c.highlight,
206 + let range = attr.range(of: hl, options: .caseInsensitive) {
207 + attr[range].backgroundColor = universe.accent
208 + attr[range].foregroundColor = .white
209 + }
210 + return Text(attr).lineSpacing(2)
211 + }
212 +
213 + private var statPill: some View {
214 + HStack(spacing: 7) {
215 + Circle().fill(universe.accent).frame(width: 8, height: 8)
216 + if let n = liveTotal {
217 + Text(n.formatted(.number.locale(Locale(identifier: "fr_CA"))))
218 + .font(KAFont.display(15))
219 + .contentTransition(.numericText())
220 + Text(universe.unit)
221 + .font(.caption).foregroundStyle(KATheme.ink2(scheme))
222 + } else {
223 + Text("En direct").font(.caption).foregroundStyle(KATheme.ink2(scheme))
224 + }
225 + }
226 + .padding(.horizontal, 13).padding(.vertical, 8)
227 + .background(KATheme.surface(scheme), in: Capsule())
228 + .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5))
229 + .background(Capsule().fill(scheme == .dark ? Color.clear : KATheme.inkLight).offset(x: 4, y: 4))
230 + }
231 +}
232 +
134 233 // MARK: - Explorateur d'un univers
135 234
136 235 struct UniverseHomeView: View {
@@ -141,6 +240,7 @@ struct UniverseHomeView: View {
141 240 @State private var errorText: String?
142 241 @State private var filterParams: [String: String] = [:]
143 242 @State private var liveTotal: Int?
243 + @State private var showSite = false
144 244 @Environment(\.colorScheme) private var scheme
145 245
146 246 var body: some View {
@@ -160,49 +260,31 @@ struct UniverseHomeView: View {
160 260 .navigationBarTitleDisplayMode(.inline)
161 261 .toolbar {
162 262 ToolbarItem(placement: .topBarTrailing) {
163 Link(destination: universe.baseURL) {
164 Image(systemName: "safari").accessibilityLabel("Ouvrir le site \(universe.name)")
263 + Button { Haptics.tap(); showSite = true } label: {
264 + Image(systemName: "globe").accessibilityLabel("Voir le site \(universe.name) dans l'app")
165 265 }
166 266 }
167 267 }
268 + .fullScreenCover(isPresented: $showSite) {
269 + SiteBrowserCover(universe: universe)
270 + }
168 271 .tint(universe.accent)
169 272 }
170 273
274 + /// Univers sans liste native : le site lui-même, embarqué
171 275 private var webUniverse: some View {
172 VStack(spacing: 18) {
173 Image(systemName: universe.symbol).font(.system(size: 52)).foregroundStyle(universe.accent)
174 KAWordmark(universe: universe, size: 30)
175 Text(universe.tagline).font(.subheadline).foregroundStyle(.secondary)
176 .multilineTextAlignment(.center)
177 Link(destination: universe.baseURL) {
178 Label("Ouvrir \(universe.name)", systemImage: "arrow.up.right.square")
179 .font(.headline).padding(.horizontal, 22).padding(.vertical, 13)
180 .background(universe.accent, in: Capsule())
181 .foregroundStyle(.white)
182 }
183 }
184 .padding(30)
185 .frame(maxWidth: .infinity, maxHeight: .infinity)
276 + EmbeddedSite(universe: universe)
186 277 }
187 278
188 /// En-tête façon héros de site : tagline + compteur live + filtres métier
279 + /// En-tête FIDÈLE au site de l'univers : ticker encre, kicker vert mono,
280 + /// titre Space Grotesk avec segment surligné accent, sous-titre, pilule live.
189 281 private var hero: some View {
190 VStack(alignment: .leading, spacing: 10) {
191 HStack(alignment: .firstTextBaseline) {
192 Text(universe.tagline)
193 .font(.system(.subheadline, design: .rounded).weight(.bold))
194 Spacer()
195 if let n = liveTotal {
196 Text("\(n.formatted(.number.locale(Locale(identifier: "fr_CA")))) \(universe.unit)")
197 .font(.system(.caption2, design: .monospaced).weight(.bold))
198 .padding(.horizontal, 9).padding(.vertical, 4)
199 .background(universe.accent.opacity(0.15), in: Capsule())
200 .foregroundStyle(universe.accent)
201 }
202 }
282 + VStack(alignment: .leading, spacing: 0) {
283 + SiteHero(universe: universe, liveTotal: liveTotal) { showSite = true }
203 284 FilterBar(universe: universe, params: $filterParams) {
204 285 Task { await load() }
205 286 }
287 + .padding(.top, 12)
206 288 }
207 289 }
208 290
modified KA/Info.plist +8 −0
@@ -31,6 +31,14 @@
31 31 <string>1</string>
32 32 <key>ITSAppUsesNonExemptEncryption</key>
33 33 <false/>
34 + <key>UIAppFonts</key>
35 + <array>
36 + <string>SpaceGrotesk-Regular.ttf</string>
37 + <string>SpaceGrotesk-Medium.ttf</string>
38 + <string>SpaceGrotesk-Bold.ttf</string>
39 + <string>JetBrainsMono-Regular.ttf</string>
40 + <string>JetBrainsMono-Bold.ttf</string>
41 + </array>
34 42 <key>UILaunchScreen</key>
35 43 <dict/>
36 44 <key>UISupportedInterfaceOrientations</key>
added KA/Resources/Assets.xcassets/logo-api-ka.imageset/Contents.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "images" : [
3 + { "filename" : "logo-512.png", "idiom" : "universal" }
4 + ],
5 + "info" : { "author" : "xcode", "version" : 1 },
6 + "properties" : { "preserves-vector-representation" : false }
7 +}
added KA/Resources/Assets.xcassets/logo-api-ka.imageset/logo-512.png +0 −0

Binary file not shown.

added KA/Resources/Assets.xcassets/logo-auto-ka.imageset/Contents.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "images" : [
3 + { "filename" : "logo-512.png", "idiom" : "universal" }
4 + ],
5 + "info" : { "author" : "xcode", "version" : 1 },
6 + "properties" : { "preserves-vector-representation" : false }
7 +}
added KA/Resources/Assets.xcassets/logo-auto-ka.imageset/logo-512.png +0 −0

Binary file not shown.

added KA/Resources/Assets.xcassets/logo-crea-ka.imageset/Contents.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "images" : [
3 + { "filename" : "logo-512.png", "idiom" : "universal" }
4 + ],
5 + "info" : { "author" : "xcode", "version" : 1 },
6 + "properties" : { "preserves-vector-representation" : false }
7 +}
added KA/Resources/Assets.xcassets/logo-crea-ka.imageset/logo-512.png +0 −0

Binary file not shown.

added KA/Resources/Assets.xcassets/logo-fabri-ka.imageset/Contents.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "images" : [
3 + { "filename" : "logo-512.png", "idiom" : "universal" }
4 + ],
5 + "info" : { "author" : "xcode", "version" : 1 },
6 + "properties" : { "preserves-vector-representation" : false }
7 +}
added KA/Resources/Assets.xcassets/logo-fabri-ka.imageset/logo-512.png +0 −0

Binary file not shown.

added KA/Resources/Assets.xcassets/logo-food-ka.imageset/Contents.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "images" : [
3 + { "filename" : "logo-512.png", "idiom" : "universal" }
4 + ],
5 + "info" : { "author" : "xcode", "version" : 1 },
6 + "properties" : { "preserves-vector-representation" : false }
7 +}
added KA/Resources/Assets.xcassets/logo-food-ka.imageset/logo-512.png +0 −0

Binary file not shown.

added KA/Resources/Assets.xcassets/logo-groupe-ka.imageset/Contents.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "images" : [
3 + { "filename" : "logo-512.png", "idiom" : "universal" }
4 + ],
5 + "info" : { "author" : "xcode", "version" : 1 },
6 + "properties" : { "preserves-vector-representation" : false }
7 +}
added KA/Resources/Assets.xcassets/logo-groupe-ka.imageset/logo-512.png +0 −0

Binary file not shown.

added KA/Resources/Assets.xcassets/logo-immo-ka.imageset/Contents.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "images" : [
3 + { "filename" : "logo-512.png", "idiom" : "universal" }
4 + ],
5 + "info" : { "author" : "xcode", "version" : 1 },
6 + "properties" : { "preserves-vector-representation" : false }
7 +}
added KA/Resources/Assets.xcassets/logo-immo-ka.imageset/logo-512.png +0 −0

Binary file not shown.

added KA/Resources/Assets.xcassets/logo-job-ka.imageset/Contents.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "images" : [
3 + { "filename" : "logo-512.png", "idiom" : "universal" }
4 + ],
5 + "info" : { "author" : "xcode", "version" : 1 },
6 + "properties" : { "preserves-vector-representation" : false }
7 +}
added KA/Resources/Assets.xcassets/logo-job-ka.imageset/logo-512.png +0 −0

Binary file not shown.

added KA/Resources/Assets.xcassets/logo-lou-ka.imageset/Contents.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "images" : [
3 + { "filename" : "logo-512.png", "idiom" : "universal" }
4 + ],
5 + "info" : { "author" : "xcode", "version" : 1 },
6 + "properties" : { "preserves-vector-representation" : false }
7 +}
added KA/Resources/Assets.xcassets/logo-lou-ka.imageset/logo-512.png +0 −0

Binary file not shown.

added KA/Resources/Assets.xcassets/logo-resto-ka.imageset/Contents.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "images" : [
3 + { "filename" : "logo-512.png", "idiom" : "universal" }
4 + ],
5 + "info" : { "author" : "xcode", "version" : 1 },
6 + "properties" : { "preserves-vector-representation" : false }
7 +}
added KA/Resources/Assets.xcassets/logo-resto-ka.imageset/logo-512.png +0 −0

Binary file not shown.

added KA/Resources/Assets.xcassets/logo-sorti-ka.imageset/Contents.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "images" : [
3 + { "filename" : "logo-512.png", "idiom" : "universal" }
4 + ],
5 + "info" : { "author" : "xcode", "version" : 1 },
6 + "properties" : { "preserves-vector-representation" : false }
7 +}
added KA/Resources/Assets.xcassets/logo-sorti-ka.imageset/logo-512.png +0 −0

Binary file not shown.

added KA/Resources/Assets.xcassets/logo-trouve-ka.imageset/Contents.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "images" : [
3 + { "filename" : "logo-512.png", "idiom" : "universal" }
4 + ],
5 + "info" : { "author" : "xcode", "version" : 1 },
6 + "properties" : { "preserves-vector-representation" : false }
7 +}
added KA/Resources/Assets.xcassets/logo-trouve-ka.imageset/logo-512.png +0 −0

Binary file not shown.

added KA/Resources/Assets.xcassets/logo-vrai-prix.imageset/Contents.json +7 −0
@@ -0,0 +1,7 @@
1 +{
2 + "images" : [
3 + { "filename" : "logo-512.png", "idiom" : "universal" }
4 + ],
5 + "info" : { "author" : "xcode", "version" : 1 },
6 + "properties" : { "preserves-vector-representation" : false }
7 +}
added KA/Resources/Assets.xcassets/logo-vrai-prix.imageset/logo-512.png +0 −0

Binary file not shown.

added KA/Resources/Fonts/JetBrainsMono-Bold.ttf +0 −0

Binary file not shown.

added KA/Resources/Fonts/JetBrainsMono-Regular.ttf +0 −0

Binary file not shown.

added KA/Resources/Fonts/SpaceGrotesk-Bold.ttf +0 −0

Binary file not shown.

added KA/Resources/Fonts/SpaceGrotesk-Medium.ttf +0 −0

Binary file not shown.

added KA/Resources/Fonts/SpaceGrotesk-Regular.ttf +0 −0

Binary file not shown.

modified KATests/KATests.swift +22 −0
@@ -3,6 +3,7 @@
3 3 // KAItem pour chaque adaptateur d'univers (échantillons réels des API),
4 4 // construction des URL de liste, favoris/collections.
5 5 import XCTest
6 +import UIKit
6 7 @testable import KA
7 8
8 9 final class AdapterTests: XCTestCase {
@@ -153,3 +154,24 @@ final class RefonteTests: XCTestCase {
153 154 XCTAssertEqual(store.savedSearches[0].lastTotal, 112, "le total vu absorbe les nouveautés")
154 155 }
155 156 }
157 +
158 +// MARK: - v2.0.0 : logos officiels + sites intégrés
159 +
160 +final class SitesLogosTests: XCTestCase {
161 + /// Chaque univers (+ le hub) a son logo officiel dans le catalogue d'assets.
162 + func testEveryUniverseHasItsOfficialLogo() {
163 + for id in Ecosystem.all.map(\.id) + ["groupe-ka"] {
164 + XCTAssertNotNil(UIImage(named: "logo-\(id)"),
165 + "logo manquant dans Assets.xcassets : logo-\(id)")
166 + }
167 + }
168 +
169 + /// Le navigateur intégré garde http(s) et connaît son URL de départ.
170 + @MainActor
171 + func testSiteWebModelStartsOnUniverseSite() {
172 + guard let u = Ecosystem.universe("lou-ka") else { return XCTFail("lou-ka absent") }
173 + let model = SiteWebModel(start: u.baseURL)
174 + XCTAssertEqual(model.currentURL, u.baseURL)
175 + XCTAssertTrue(model.webView.allowsBackForwardNavigationGestures)
176 + }
177 +}
modified project.yml +8 −2
@@ -9,8 +9,8 @@ options:
9 9 settings:
10 10 base:
11 11 SWIFT_VERSION: "5.0"
12 MARKETING_VERSION: "1.0.0"
13 CURRENT_PROJECT_VERSION: "7"
12 + MARKETING_VERSION: "2.0.0"
13 + CURRENT_PROJECT_VERSION: "8"
14 14 DEVELOPMENT_TEAM: "3YM54G49SN"
15 15 CODE_SIGN_STYLE: Automatic
16 16 GENERATE_INFOPLIST_FILE: true
@@ -52,6 +52,12 @@ targets:
52 52 - UIInterfaceOrientationLandscapeRight
53 53 UILaunchScreen: {}
54 54 ITSAppUsesNonExemptEncryption: false
55 + UIAppFonts:
56 + - SpaceGrotesk-Regular.ttf
57 + - SpaceGrotesk-Medium.ttf
58 + - SpaceGrotesk-Bold.ttf
59 + - JetBrainsMono-Regular.ttf
60 + - JetBrainsMono-Bold.ttf
55 61 KAWidgets:
56 62 type: app-extension
57 63 platform: iOS
58 64