v2.1.0 — chaque univers ressemble à son site : polices officielles, tokens fidèles, héros de site
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
14 changed files +283 −62
modified
Package.swift
+1 −1
@@ -11,7 +11,7 @@ let package = Package( | ||
| 11 | 11 | .executableTarget( |
| 12 | 12 | name: "KA", |
| 13 | 13 | path: "Sources/KA", |
| 14 | − resources: [.copy("Resources/Logos")], | |
| 14 | + resources: [.copy("Resources/Logos"), .copy("Resources/Fonts")], | |
| 15 | 15 | linkerSettings: [.linkedFramework("Carbon")] |
| 16 | 16 | ) |
| 17 | 17 | ] |
modified
Sources/KA/App/KAApp.swift
+5 −0
@@ -140,6 +140,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { | ||
| 140 | 140 | @main |
| 141 | 141 | struct KAApp: App { |
| 142 | 142 | @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate |
| 143 | + | |
| 144 | + init() { | |
| 145 | + // Polices officielles des sites (Space Grotesk, JetBrains Mono) | |
| 146 | + KAFont.register() | |
| 147 | + } | |
| 143 | 148 | @StateObject private var pulse = EcosystemPulse.shared |
| 144 | 149 | @StateObject private var state = AppState.shared |
| 145 | 150 | @StateObject private var favorites = FavoritesStore.shared |
added
Sources/KA/Core/HeroCopy.swift
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// HeroCopy.swift (macOS) — 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
Sources/KA/DesignMac/KAFonts.swift
+34 −0
@@ -0,0 +1,34 @@ | ||
| 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é macOS). | |
| 5 | +// Spec : app iOS KA (~/Desktop/KA/KA/Design/KAFonts.swift). | |
| 6 | +import SwiftUI | |
| 7 | +import CoreText | |
| 8 | + | |
| 9 | +enum KAFont { | |
| 10 | + /// Enregistre les TTF embarquées dans le bundle SPM — à appeler UNE fois | |
| 11 | + /// au démarrage (avant le premier rendu SwiftUI). | |
| 12 | + static func register() { | |
| 13 | + for name in ["SpaceGrotesk-Regular", "SpaceGrotesk-Medium", "SpaceGrotesk-Bold", | |
| 14 | + "JetBrainsMono-Regular", "JetBrainsMono-Bold"] { | |
| 15 | + guard let url = Bundle.module.url(forResource: name, withExtension: "ttf", | |
| 16 | + subdirectory: "Fonts") else { continue } | |
| 17 | + CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil) | |
| 18 | + } | |
| 19 | + } | |
| 20 | + | |
| 21 | + /// Display « Space Grotesk » — titres, wordmarks, chiffres héros | |
| 22 | + static func display(_ size: CGFloat, _ weight: Font.Weight = .bold) -> Font { | |
| 23 | + switch weight { | |
| 24 | + case .bold, .heavy, .black: return .custom("SpaceGrotesk-Bold", size: size) | |
| 25 | + case .medium, .semibold: return .custom("SpaceGrotesk-Medium", size: size) | |
| 26 | + default: return .custom("SpaceGrotesk-Regular", size: size) | |
| 27 | + } | |
| 28 | + } | |
| 29 | + | |
| 30 | + /// Micro-étiquettes « JetBrains Mono » — kickers, compteurs, puces | |
| 31 | + static func mono(_ size: CGFloat, bold: Bool = true) -> Font { | |
| 32 | + .custom(bold ? "JetBrainsMono-Bold" : "JetBrainsMono-Regular", size: size) | |
| 33 | + } | |
| 34 | +} | |
modified
Sources/KA/DesignMac/Theme.swift
+29 −24
@@ -21,7 +21,8 @@ enum KATheme { | ||
| 21 | 21 | static func ink2(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: "#a9b0a9") : Color(hex: "#4d5551") } |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | −// MARK: - Carte « éditorial sharp » (bordure encre + ombre décalée) | |
| 24 | +// MARK: - Carte « éditorial sharp » — tokens EXACTS des sites (tokens.css) : | |
| 25 | +// rayon 10 px, bordure encre 1,5 px, ombre décalée DURE 6×6 pleine encre. | |
| 25 | 26 | |
| 26 | 27 | struct KACard: ViewModifier { |
| 27 | 28 | @Environment(\.colorScheme) private var scheme |
@@ -30,17 +31,19 @@ struct KACard: ViewModifier { | ||
| 30 | 31 | func body(content: Content) -> some View { |
| 31 | 32 | content |
| 32 | 33 | .background(KATheme.surface(scheme)) |
| 33 | − .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) | |
| 34 | + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) | |
| 34 | 35 | .overlay( |
| 35 | − RoundedRectangle(cornerRadius: 12, style: .continuous) | |
| 36 | − .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.3) | |
| 36 | + RoundedRectangle(cornerRadius: 10, style: .continuous) | |
| 37 | + .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5) | |
| 37 | 38 | ) |
| 38 | 39 | .background( |
| 39 | − RoundedRectangle(cornerRadius: 12, style: .continuous) | |
| 40 | − .fill((accent ?? KATheme.ink(scheme)).opacity(scheme == .dark ? 0.25 : 0.16)) | |
| 41 | − .offset(x: 4, y: 4) | |
| 40 | + RoundedRectangle(cornerRadius: 10, style: .continuous) | |
| 41 | + .fill(scheme == .dark | |
| 42 | + ? (accent ?? KATheme.inkDark).opacity(0.25) | |
| 43 | + : KATheme.inkLight) | |
| 44 | + .offset(x: 6, y: 6) | |
| 42 | 45 | ) |
| 43 | − .padding(.trailing, 4).padding(.bottom, 4) | |
| 46 | + .padding(.trailing, 6).padding(.bottom, 6) | |
| 44 | 47 | } |
| 45 | 48 | } |
| 46 | 49 | |
@@ -56,18 +59,18 @@ struct KAWordmark: View { | ||
| 56 | 59 | @Environment(\.colorScheme) private var scheme |
| 57 | 60 | |
| 58 | 61 | var body: some View { |
| 62 | + // fidèle à l'en-tête des sites : « Lou- » encre + boîte ACCENT au texte blanc | |
| 59 | 63 | let parts = universe.wordmark.split(separator: "·", maxSplits: 1) |
| 60 | 64 | HStack(alignment: .firstTextBaseline, spacing: 3) { |
| 61 | − Text(parts.first.map(String.init) ?? universe.wordmark) | |
| 62 | − .font(.system(size: size, weight: .bold, design: .rounded)) | |
| 65 | + Text(parts.count > 1 ? String(parts[0]) + "-" : universe.wordmark) | |
| 66 | + .font(KAFont.display(size)) | |
| 63 | 67 | if parts.count > 1 { |
| 64 | 68 | Text(String(parts[1])) |
| 65 | − .font(.system(size: size * 0.86, weight: .bold, design: .rounded)) | |
| 66 | − .foregroundStyle(universe.accent) | |
| 69 | + .font(KAFont.display(size * 0.86)) | |
| 70 | + .foregroundStyle(.white) | |
| 67 | 71 | .padding(.horizontal, size * 0.28) |
| 68 | 72 | .padding(.vertical, size * 0.08) |
| 69 | − .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous)) | |
| 70 | − .rotationEffect(.degrees(-2)) | |
| 73 | + .background(universe.accent, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous)) | |
| 71 | 74 | } |
| 72 | 75 | } |
| 73 | 76 | .foregroundStyle(KATheme.ink(scheme)) |
@@ -81,9 +84,9 @@ struct GroupeKAMark: View { | ||
| 81 | 84 | @Environment(\.colorScheme) private var scheme |
| 82 | 85 | var body: some View { |
| 83 | 86 | HStack(alignment: .firstTextBaseline, spacing: 4) { |
| 84 | − Text("Groupe").font(.system(size: size, weight: .bold, design: .rounded)) | |
| 87 | + Text("Groupe").font(KAFont.display(size)) | |
| 85 | 88 | Text("KA") |
| 86 | − .font(.system(size: size * 0.9, weight: .bold, design: .rounded)) | |
| 89 | + .font(KAFont.display(size * 0.9)) | |
| 87 | 90 | .foregroundStyle(KATheme.lime) |
| 88 | 91 | .padding(.horizontal, size * 0.3).padding(.vertical, size * 0.1) |
| 89 | 92 | .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.28, style: .continuous)) |
@@ -144,7 +147,7 @@ struct KAChip: View { | ||
| 144 | 147 | @Environment(\.colorScheme) private var scheme |
| 145 | 148 | var body: some View { |
| 146 | 149 | Text(text) |
| 147 | − .font(.system(.caption2, design: .monospaced).weight(.bold)) | |
| 150 | + .font(KAFont.mono(10)) | |
| 148 | 151 | .textCase(.uppercase) |
| 149 | 152 | .padding(.horizontal, 9).padding(.vertical, 4) |
| 150 | 153 | .background((accent ?? KATheme.ink(scheme)).opacity(0.12), in: Capsule()) |
@@ -280,19 +283,21 @@ struct KAItemCard: View { | ||
| 280 | 283 | .padding(10) |
| 281 | 284 | } |
| 282 | 285 | .background(KATheme.surface(scheme)) |
| 283 | − .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) | |
| 286 | + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) | |
| 284 | 287 | .overlay( |
| 285 | − RoundedRectangle(cornerRadius: 12, style: .continuous) | |
| 288 | + RoundedRectangle(cornerRadius: 10, style: .continuous) | |
| 286 | 289 | .strokeBorder(selected ? (universe?.accent ?? KATheme.ink(scheme)) |
| 287 | 290 | : KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), |
| 288 | − lineWidth: selected ? 2.6 : 1.3) | |
| 291 | + lineWidth: selected ? 2.6 : 1.5) | |
| 289 | 292 | ) |
| 290 | 293 | .background( |
| 291 | − RoundedRectangle(cornerRadius: 12, style: .continuous) | |
| 292 | − .fill((universe?.accent ?? KATheme.ink(scheme)).opacity(scheme == .dark ? 0.25 : 0.16)) | |
| 293 | − .offset(x: 4, y: 4) | |
| 294 | + RoundedRectangle(cornerRadius: 10, style: .continuous) | |
| 295 | + .fill(scheme == .dark | |
| 296 | + ? (universe?.accent ?? KATheme.inkDark).opacity(0.25) | |
| 297 | + : KATheme.inkLight) | |
| 298 | + .offset(x: 6, y: 6) | |
| 294 | 299 | ) |
| 295 | − .padding(.trailing, 4).padding(.bottom, 4) | |
| 300 | + .padding(.trailing, 6).padding(.bottom, 6) | |
| 296 | 301 | } |
| 297 | 302 | } |
| 298 | 303 | |
modified
Sources/KA/Features/HomeView.swift
+5 −5
@@ -41,18 +41,18 @@ struct HomeView: View { | ||
| 41 | 41 | VStack(alignment: .leading, spacing: 8) { |
| 42 | 42 | GroupeKAMark(size: 30) |
| 43 | 43 | Text("Le Québec entier, lu à la source.") |
| 44 | − .font(.system(.title2, design: .rounded).weight(.bold)) | |
| 44 | + .font(KAFont.display(22)) | |
| 45 | 45 | HStack(spacing: 8) { |
| 46 | 46 | if totalAll > 0 { |
| 47 | 47 | Text("\(totalAll.fr) éléments en direct") |
| 48 | − .font(.system(.caption, design: .monospaced).weight(.bold)) | |
| 48 | + .font(KAFont.mono(11)) | |
| 49 | 49 | .padding(.horizontal, 10).padding(.vertical, 5) |
| 50 | 50 | .background(KATheme.green.opacity(0.12), in: Capsule()) |
| 51 | 51 | .foregroundStyle(KATheme.green) |
| 52 | 52 | .contentTransition(.numericText()) |
| 53 | 53 | } |
| 54 | 54 | Text("\(pulse.servicesUp)/\(pulse.health.count) services en ligne") |
| 55 | − .font(.system(.caption, design: .monospaced).weight(.bold)) | |
| 55 | + .font(KAFont.mono(11)) | |
| 56 | 56 | .padding(.horizontal, 10).padding(.vertical, 5) |
| 57 | 57 | .background((pulse.servicesUp == pulse.health.count ? KATheme.green : .orange).opacity(0.12), in: Capsule()) |
| 58 | 58 | .foregroundStyle(pulse.servicesUp == pulse.health.count ? KATheme.green : .orange) |
@@ -150,11 +150,11 @@ struct HomeView: View { | ||
| 150 | 150 | .multilineTextAlignment(.leading) |
| 151 | 151 | if let total = pulse.totals[u.id] { |
| 152 | 152 | Text("\(total.fr) \(u.unit)") |
| 153 | − .font(.system(.caption2, design: .monospaced).weight(.bold)) | |
| 153 | + .font(KAFont.mono(10)) | |
| 154 | 154 | .foregroundStyle(u.accent) |
| 155 | 155 | .contentTransition(.numericText()) |
| 156 | 156 | } else { |
| 157 | − Text("—").font(.system(.caption2, design: .monospaced)) | |
| 157 | + Text("—").font(KAFont.mono(10, bold: false)) | |
| 158 | 158 | .foregroundStyle(.tertiary) |
| 159 | 159 | } |
| 160 | 160 | } |
modified
Sources/KA/Features/UniverseExplorerView.swift
+121 −29
@@ -100,24 +100,9 @@ struct UniverseExplorerView: View { | ||
| 100 | 100 | } |
| 101 | 101 | |
| 102 | 102 | private var hero: some View { |
| 103 | − VStack(alignment: .leading, spacing: 10) { | |
| 104 | − HStack(alignment: .firstTextBaseline, spacing: 12) { | |
| 105 | − KALogo(id: universe.id, size: 26, | |
| 106 | − fallbackSymbol: universe.symbol, fallbackAccent: universe.accent) | |
| 107 | − .alignmentGuide(.firstTextBaseline) { $0[VerticalAlignment.center] + 8 } | |
| 108 | − KAWordmark(universe: universe, size: 24) | |
| 109 | − Text(universe.tagline) | |
| 110 | − .font(.system(.subheadline, design: .rounded).weight(.bold)) | |
| 111 | − .foregroundStyle(KATheme.ink2(scheme)) | |
| 112 | − Spacer() | |
| 113 | − if let n = pulse.totals[universe.id] { | |
| 114 | − Text("\(n.fr) \(universe.unit)") | |
| 115 | − .font(.system(.caption, design: .monospaced).weight(.bold)) | |
| 116 | − .padding(.horizontal, 9).padding(.vertical, 4) | |
| 117 | − .background(universe.accent.opacity(0.15), in: Capsule()) | |
| 118 | − .foregroundStyle(universe.accent) | |
| 119 | − .contentTransition(.numericText()) | |
| 120 | − } | |
| 103 | + VStack(alignment: .leading, spacing: 12) { | |
| 104 | + SiteHero(universe: universe, liveTotal: pulse.totals[universe.id]) { mode = .site } | |
| 105 | + HStack(spacing: 8) { | |
| 121 | 106 | Picker("Présentation", selection: $asGrid) { |
| 122 | 107 | Image(systemName: "square.grid.2x2").tag(true) |
| 123 | 108 | Image(systemName: "list.bullet").tag(false) |
@@ -125,15 +110,6 @@ struct UniverseExplorerView: View { | ||
| 125 | 110 | .pickerStyle(.segmented) |
| 126 | 111 | .labelsHidden() |
| 127 | 112 | .frame(width: 90) |
| 128 | − Button { mode = .site } label: { | |
| 129 | − Label("Voir le site", systemImage: "safari") | |
| 130 | − .font(.caption.weight(.semibold)) | |
| 131 | − } | |
| 132 | − .buttonStyle(.plain) | |
| 133 | − .foregroundStyle(universe.accent) | |
| 134 | − .help("Ouvrir \(universe.domain) dans l'app") | |
| 135 | − } | |
| 136 | − HStack(spacing: 8) { | |
| 137 | 113 | KASearchField(prompt: "Chercher dans \(universe.name)…", text: $query) { |
| 138 | 114 | Task { await load() } |
| 139 | 115 | } |
@@ -194,10 +170,10 @@ struct UniverseExplorerView: View { | ||
| 194 | 170 | Button { selected = item } label: { |
| 195 | 171 | KAItemRow(item: item) |
| 196 | 172 | .overlay( |
| 197 | − RoundedRectangle(cornerRadius: 12, style: .continuous) | |
| 173 | + RoundedRectangle(cornerRadius: 10, style: .continuous) | |
| 198 | 174 | .strokeBorder(universe.accent, |
| 199 | 175 | lineWidth: selected?.id == item.id ? 2.4 : 0) |
| 200 | − .padding(.trailing, 4).padding(.bottom, 4) | |
| 176 | + .padding(.trailing, 6).padding(.bottom, 6) | |
| 201 | 177 | ) |
| 202 | 178 | } |
| 203 | 179 | .buttonStyle(KAPressStyle()) |
@@ -242,6 +218,122 @@ struct UniverseExplorerView: View { | ||
| 242 | 218 | } |
| 243 | 219 | } |
| 244 | 220 | |
| 221 | +// MARK: - Héros de site (reproduction fidèle de la page d'accueil de l'univers) | |
| 222 | +// Spec : app iOS KA (UniversesView.swift, struct SiteHero) — adapté desktop. | |
| 223 | + | |
| 224 | +struct SiteHero: View { | |
| 225 | + let universe: Universe | |
| 226 | + let liveTotal: Int? | |
| 227 | + var openSite: (() -> Void)? = nil | |
| 228 | + @Environment(\.colorScheme) private var scheme | |
| 229 | + | |
| 230 | + private var copy: HeroCopy? { KAHeroes.hero(universe.id) } | |
| 231 | + | |
| 232 | + var body: some View { | |
| 233 | + VStack(alignment: .leading, spacing: 10) { | |
| 234 | + ticker | |
| 235 | + if let c = copy { | |
| 236 | + if let k = c.kicker { | |
| 237 | + HStack(spacing: 10) { | |
| 238 | + Rectangle().fill(KATheme.green).frame(width: 26, height: 2.5) | |
| 239 | + Text(k.uppercased()) | |
| 240 | + .font(KAFont.mono(10)) | |
| 241 | + .foregroundStyle(scheme == .dark ? KATheme.lime : KATheme.green) | |
| 242 | + .kerning(1.1) | |
| 243 | + .lineLimit(1) | |
| 244 | + } | |
| 245 | + } | |
| 246 | + HStack(alignment: .top, spacing: 18) { | |
| 247 | + VStack(alignment: .leading, spacing: 7) { | |
| 248 | + headline(c) | |
| 249 | + Text(c.sub) | |
| 250 | + .font(.subheadline) | |
| 251 | + .foregroundStyle(KATheme.ink2(scheme)) | |
| 252 | + .lineLimit(3) | |
| 253 | + } | |
| 254 | + Spacer(minLength: 0) | |
| 255 | + VStack(alignment: .trailing, spacing: 9) { | |
| 256 | + statPill | |
| 257 | + if let openSite { | |
| 258 | + Button(action: openSite) { | |
| 259 | + Text("VOIR LE SITE ↗") | |
| 260 | + .font(KAFont.mono(10)) | |
| 261 | + .foregroundStyle(universe.accent) | |
| 262 | + .padding(.horizontal, 12).padding(.vertical, 8) | |
| 263 | + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) | |
| 264 | + } | |
| 265 | + .buttonStyle(KAPressStyle()) | |
| 266 | + .help("Ouvrir \(universe.domain) dans l'app") | |
| 267 | + .accessibilityLabel("Voir le site \(universe.name) dans l'app") | |
| 268 | + } | |
| 269 | + } | |
| 270 | + } | |
| 271 | + } | |
| 272 | + } | |
| 273 | + } | |
| 274 | + | |
| 275 | + /// Bandeau ticker encre — la signature du haut des sites | |
| 276 | + private var ticker: some View { | |
| 277 | + HStack(spacing: 9) { | |
| 278 | + KALogo(id: universe.id, size: 18, | |
| 279 | + fallbackSymbol: universe.symbol, fallbackAccent: universe.accent) | |
| 280 | + Text(universe.unit.uppercased()) | |
| 281 | + .font(KAFont.mono(9.5)) | |
| 282 | + .foregroundStyle(.white.opacity(0.9)) | |
| 283 | + Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent) | |
| 284 | + Text("EN DIRECT") | |
| 285 | + .font(KAFont.mono(9.5)) | |
| 286 | + .foregroundStyle(universe.accent) | |
| 287 | + Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent) | |
| 288 | + Text(universe.domain.replacingOccurrences(of: "www.", with: "").uppercased()) | |
| 289 | + .font(KAFont.mono(9.5)) | |
| 290 | + .foregroundStyle(.white.opacity(0.75)) | |
| 291 | + .lineLimit(1) | |
| 292 | + Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent) | |
| 293 | + Text(universe.tagline.uppercased()) | |
| 294 | + .font(KAFont.mono(9.5)) | |
| 295 | + .foregroundStyle(.white.opacity(0.6)) | |
| 296 | + .lineLimit(1) | |
| 297 | + Spacer(minLength: 0) | |
| 298 | + } | |
| 299 | + .padding(.horizontal, 12).padding(.vertical, 8) | |
| 300 | + .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) | |
| 301 | + } | |
| 302 | + | |
| 303 | + /// Titre display avec le segment surligné SUR l'accent (comme le h1 du site) | |
| 304 | + private func headline(_ c: HeroCopy) -> some View { | |
| 305 | + var attr = AttributedString(c.headline) | |
| 306 | + attr.font = KAFont.display(24) | |
| 307 | + attr.foregroundColor = KATheme.ink(scheme) | |
| 308 | + if let hl = c.highlight, | |
| 309 | + let range = attr.range(of: hl, options: .caseInsensitive) { | |
| 310 | + attr[range].backgroundColor = universe.accent | |
| 311 | + attr[range].foregroundColor = .white | |
| 312 | + } | |
| 313 | + return Text(attr).lineSpacing(2) | |
| 314 | + } | |
| 315 | + | |
| 316 | + private var statPill: some View { | |
| 317 | + HStack(spacing: 7) { | |
| 318 | + Circle().fill(universe.accent).frame(width: 8, height: 8) | |
| 319 | + if let n = liveTotal { | |
| 320 | + Text(n.fr) | |
| 321 | + .font(KAFont.display(14)) | |
| 322 | + .contentTransition(.numericText()) | |
| 323 | + Text(universe.unit) | |
| 324 | + .font(.caption).foregroundStyle(KATheme.ink2(scheme)) | |
| 325 | + } else { | |
| 326 | + Text("En direct").font(.caption).foregroundStyle(KATheme.ink2(scheme)) | |
| 327 | + } | |
| 328 | + } | |
| 329 | + .padding(.horizontal, 12).padding(.vertical, 7) | |
| 330 | + .background(KATheme.surface(scheme), in: Capsule()) | |
| 331 | + .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5)) | |
| 332 | + .background(Capsule().fill(scheme == .dark ? Color.clear : KATheme.inkLight).offset(x: 4, y: 4)) | |
| 333 | + .padding(.trailing, 4).padding(.bottom, 4) | |
| 334 | + } | |
| 335 | +} | |
| 336 | + | |
| 245 | 337 | // MARK: - Fiche universelle (panneau de droite) |
| 246 | 338 | |
| 247 | 339 | struct ItemDetailPane: View { |
added
Sources/KA/Resources/Fonts/JetBrainsMono-Bold.ttf
+0 −0
Binary file not shown.
added
Sources/KA/Resources/Fonts/JetBrainsMono-Regular.ttf
+0 −0
Binary file not shown.
added
Sources/KA/Resources/Fonts/SpaceGrotesk-Bold.ttf
+0 −0
Binary file not shown.
added
Sources/KA/Resources/Fonts/SpaceGrotesk-Medium.ttf
+0 −0
Binary file not shown.
added
Sources/KA/Resources/Fonts/SpaceGrotesk-Regular.ttf
+0 −0
Binary file not shown.
modified
scripts/notarize.sh
+1 −1
@@ -9,7 +9,7 @@ cd "$(dirname "$0")/.." | ||
| 9 | 9 | IDENTITY="Developer ID Application: Simon-Pierre Boucher (3YM54G49SN)" |
| 10 | 10 | KEYCHAIN_PROFILE="MacLustr-Notarize" |
| 11 | 11 | APP_DIR="dist/KA.app" |
| 12 | −DMG_NAME="dist/KA-macos-2.0.0.dmg" # publié en téléchargement sur groupe-ka.com | |
| 12 | +DMG_NAME="dist/KA-macos-2.1.0.dmg" # publié en téléchargement sur groupe-ka.com | |
| 13 | 13 | |
| 14 | 14 | ./scripts/package-app.sh release |
| 15 | 15 | |
modified
scripts/package-app.sh
+2 −2
@@ -29,8 +29,8 @@ cat > "$APP_DIR/Contents/Info.plist" <<'PLIST' | ||
| 29 | 29 | <key>CFBundleDisplayName</key><string>KA</string> |
| 30 | 30 | <key>CFBundleIdentifier</key><string>com.groupeka.ka.mac</string> |
| 31 | 31 | <key>CFBundleExecutable</key><string>KA</string> |
| 32 | − <key>CFBundleVersion</key><string>2</string> | |
| 33 | − <key>CFBundleShortVersionString</key><string>2.0.0</string> | |
| 32 | + <key>CFBundleVersion</key><string>3</string> | |
| 33 | + <key>CFBundleShortVersionString</key><string>2.1.0</string> | |
| 34 | 34 | <key>CFBundlePackageType</key><string>APPL</string> |
| 35 | 35 | <key>LSMinimumSystemVersion</key><string>14.0</string> |
| 36 | 36 | <key>LSApplicationCategoryType</key><string>public.app-category.lifestyle</string> |
| 37 | 37 | |