Swift 98.3%
Shell 1.7%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Theme.swift — design system Groupe-KA « éditorial sharp » adapté macOS :3// papier #f5f3ee / encre #141814, cartes bordure encre + ombre décalée,4// wordmark boîte accent, thème CLAIR par défaut.5// Adapté de l'app iOS KA (~/Desktop/KA/KA/Design/Theme.swift), sans UIKit.6import SwiftUI78// MARK: - Palette910enum KATheme {11 static let paperLight = Color(hex: "#f5f3ee")12 static let paperDark = Color(hex: "#101410")13 static let inkLight = Color(hex: "#141814")14 static let inkDark = Color(hex: "#f0efe8")15 static let lime = Color(hex: "#d9f26b")16 static let green = Color(hex: "#1c5c41")1718 static func paper(_ scheme: ColorScheme) -> Color { scheme == .dark ? paperDark : paperLight }19 static func ink(_ scheme: ColorScheme) -> Color { scheme == .dark ? inkDark : inkLight }20 static func surface(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: "#1a1f1a") : .white }21 static func ink2(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: "#a9b0a9") : Color(hex: "#4d5551") }22}2324// 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.2627struct KACard: ViewModifier {28 @Environment(\.colorScheme) private var scheme29 var accent: Color? = nil3031 func body(content: Content) -> some View {32 content33 .background(KATheme.surface(scheme))34 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))35 .overlay(36 RoundedRectangle(cornerRadius: 10, style: .continuous)37 .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5)38 )39 .background(40 RoundedRectangle(cornerRadius: 10, style: .continuous)41 .fill(scheme == .dark42 ? (accent ?? KATheme.inkDark).opacity(0.25)43 : KATheme.inkLight)44 .offset(x: 6, y: 6)45 )46 .padding(.trailing, 6).padding(.bottom, 6)47 }48}4950extension View {51 func kaCard(accent: Color? = nil) -> some View { modifier(KACard(accent: accent)) }52}5354// MARK: - Wordmark (« Lou » + boîte [Ka] accent)5556struct KAWordmark: View {57 let universe: Universe58 var size: CGFloat = 2059 @Environment(\.colorScheme) private var scheme6061 var body: some View {62 // fidèle à l'en-tête des sites : « Lou- » encre + boîte ACCENT au texte blanc63 let parts = universe.wordmark.split(separator: "·", maxSplits: 1)64 HStack(alignment: .firstTextBaseline, spacing: 3) {65 Text(parts.count > 1 ? String(parts[0]) + "-" : universe.wordmark)66 .font(KAFont.display(size))67 if parts.count > 1 {68 Text(String(parts[1]))69 .font(KAFont.display(size * 0.86))70 .foregroundStyle(.white)71 .padding(.horizontal, size * 0.28)72 .padding(.vertical, size * 0.08)73 .background(universe.accent, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous))74 }75 }76 .foregroundStyle(KATheme.ink(scheme))77 .fixedSize()78 .accessibilityLabel(universe.name)79 }80}8182struct GroupeKAMark: View {83 var size: CGFloat = 2484 @Environment(\.colorScheme) private var scheme85 var body: some View {86 HStack(alignment: .firstTextBaseline, spacing: 4) {87 Text("Groupe").font(KAFont.display(size))88 Text("KA")89 .font(KAFont.display(size * 0.9))90 .foregroundStyle(KATheme.lime)91 .padding(.horizontal, size * 0.3).padding(.vertical, size * 0.1)92 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.28, style: .continuous))93 .rotationEffect(.degrees(-2))94 }95 .fixedSize()96 .foregroundStyle(KATheme.ink(scheme))97 .accessibilityLabel("Groupe KA")98 }99}100101// MARK: - Logo officiel d'une marque (PNG ka-ui embarqué, repli symbole SF)102103/// Vrai logo de la marque (tuile encre + « Ka » accent, rendu depuis ka-ui).104/// `id` = id d'univers (« lou-ka »…) ou « groupe-ka » pour le hub.105struct KALogo: View {106 let id: String107 var size: CGFloat = 20108 /// Repli si le PNG manque (ex. id inconnu) — symbole SF teinté accent.109 var fallbackSymbol: String = "square.grid.2x2"110 var fallbackAccent: Color = .gray111112 private static var cache: [String: NSImage] = [:]113114 static func image(_ id: String) -> NSImage? {115 if let hit = cache[id] { return hit }116 guard let url = Bundle.module.url(forResource: "logo-\(id)", withExtension: "png",117 subdirectory: "Logos"),118 let img = NSImage(contentsOf: url) else { return nil }119 cache[id] = img120 return img121 }122123 var body: some View {124 if let img = Self.image(id) {125 Image(nsImage: img)126 .resizable()127 .interpolation(.high)128 .aspectRatio(contentMode: .fit)129 .frame(width: size, height: size)130 .clipShape(RoundedRectangle(cornerRadius: size * 0.22, style: .continuous))131 .accessibilityHidden(true)132 } else {133 Image(systemName: fallbackSymbol)134 .font(.system(size: size * 0.6, weight: .semibold))135 .foregroundStyle(fallbackAccent)136 .frame(width: size, height: size)137 .accessibilityHidden(true)138 }139 }140}141142// MARK: - Puce mono (klabel)143144struct KAChip: View {145 let text: String146 var accent: Color? = nil147 @Environment(\.colorScheme) private var scheme148 var body: some View {149 Text(text)150 .font(KAFont.mono(10))151 .textCase(.uppercase)152 .padding(.horizontal, 9).padding(.vertical, 4)153 .background((accent ?? KATheme.ink(scheme)).opacity(0.12), in: Capsule())154 .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.4), lineWidth: 1))155 }156}157158// MARK: - Pastille de statut (vert/rouge/en cours)159160struct KAStatusDot: View {161 let up: Bool?162 var body: some View {163 Circle()164 .fill(up == nil ? Color.gray.opacity(0.4) : (up! ? Color(hex: "#2f9e44") : Color(hex: "#e03131")))165 .frame(width: 9, height: 9)166 .accessibilityLabel(up == nil ? "Vérification" : (up! ? "En ligne" : "Hors ligne"))167 }168}169170// MARK: - Rangée d'item universelle171172struct KAItemRow: View {173 let item: KAItem174 var compact = false175 @Environment(\.colorScheme) private var scheme176 @EnvironmentObject private var favorites: FavoritesStore177178 private var universe: Universe? { Ecosystem.universe(item.universeID) }179180 var body: some View {181 HStack(alignment: .top, spacing: 12) {182 if let img = item.imageURL {183 KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")184 .frame(width: compact ? 42 : 66, height: compact ? 42 : 66)185 .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))186 .overlay(RoundedRectangle(cornerRadius: 9, style: .continuous)187 .strokeBorder(.primary.opacity(0.25), lineWidth: 1))188 .accessibilityHidden(true)189 } else {190 RoundedRectangle(cornerRadius: 8, style: .continuous)191 .fill(universe?.accent.opacity(0.9) ?? .gray)192 .frame(width: 5)193 .padding(.vertical, 2)194 }195 VStack(alignment: .leading, spacing: 3) {196 Text(item.title)197 .font(compact ? .subheadline.weight(.semibold) : .headline)198 .lineLimit(compact ? 1 : 2)199 if let sub = item.subtitle, !sub.isEmpty {200 Text(sub).font(compact ? .caption : .subheadline)201 .foregroundStyle(KATheme.ink2(scheme)).lineLimit(compact ? 1 : 2)202 }203 HStack(spacing: 8) {204 if let price = item.priceLabel {205 Text(price).font(.system(compact ? .caption : .subheadline, design: .rounded).weight(.bold))206 .foregroundStyle(universe?.accent ?? .primary)207 .lineLimit(1)208 }209 if let city = item.city, !city.isEmpty {210 Text(city).font(.caption).foregroundStyle(KATheme.ink2(scheme)).lineLimit(1)211 }212 Spacer(minLength: 0)213 if let u = universe, !compact {214 KAChip(text: u.wordmark, accent: u.accent)215 }216 }217 }218 if favorites.isFavorite(item) {219 Image(systemName: "heart.fill")220 .foregroundStyle(.red).font(.caption)221 .accessibilityLabel("Dans vos favoris")222 }223 }224 .padding(compact ? 8 : 12)225 .frame(maxWidth: .infinity, alignment: .leading)226 .kaCard(accent: universe?.accent)227 }228}229230// MARK: - Carte d'item (grille photo, grandes fenêtres)231232struct KAItemCard: View {233 let item: KAItem234 var selected = false235 @Environment(\.colorScheme) private var scheme236 @EnvironmentObject private var favorites: FavoritesStore237238 private var universe: Universe? { Ecosystem.universe(item.universeID) }239240 var body: some View {241 VStack(alignment: .leading, spacing: 0) {242 ZStack(alignment: .topTrailing) {243 KAImage(url: item.imageURL, accent: universe?.accent ?? .gray,244 symbol: universe?.symbol ?? "photo")245 .frame(height: 130)246 .frame(maxWidth: .infinity)247 .clipped()248 if favorites.isFavorite(item) {249 Image(systemName: "heart.fill")250 .font(.caption).foregroundStyle(.red)251 .padding(6)252 .background(.ultraThinMaterial, in: Circle())253 .padding(6)254 .accessibilityLabel("Dans vos favoris")255 }256 if item.imageURLs.count > 1 {257 Text("\(item.imageURLs.count) 📷")258 .font(.system(size: 9, weight: .bold, design: .monospaced))259 .padding(.horizontal, 6).padding(.vertical, 3)260 .background(.ultraThinMaterial, in: Capsule())261 .padding(6)262 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)263 }264 }265 VStack(alignment: .leading, spacing: 4) {266 Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1)267 if let sub = item.subtitle, !sub.isEmpty {268 Text(sub).font(.caption).foregroundStyle(KATheme.ink2(scheme)).lineLimit(1)269 }270 HStack(spacing: 6) {271 if let p = item.priceLabel {272 Text(p).font(.system(.caption, design: .rounded).weight(.bold))273 .foregroundStyle(universe?.accent ?? .primary)274 .lineLimit(1)275 }276 Spacer(minLength: 0)277 if let c = item.city, !c.isEmpty {278 Text(c).font(.system(size: 9, weight: .semibold, design: .monospaced))279 .foregroundStyle(.secondary).lineLimit(1)280 }281 }282 }283 .padding(10)284 }285 .background(KATheme.surface(scheme))286 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))287 .overlay(288 RoundedRectangle(cornerRadius: 10, style: .continuous)289 .strokeBorder(selected ? (universe?.accent ?? KATheme.ink(scheme))290 : KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85),291 lineWidth: selected ? 2.6 : 1.5)292 )293 .background(294 RoundedRectangle(cornerRadius: 10, style: .continuous)295 .fill(scheme == .dark296 ? (universe?.accent ?? KATheme.inkDark).opacity(0.25)297 : KATheme.inkLight)298 .offset(x: 6, y: 6)299 )300 .padding(.trailing, 6).padding(.bottom, 6)301 }302}303304// MARK: - Image réseau fluide (fondu à l'arrivée, cache URLCache partagé)305306struct KAImage: View {307 let url: URL?308 var accent: Color = .gray309 var symbol: String = "photo"310311 var body: some View {312 AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.25))) { phase in313 switch phase {314 case .success(let image):315 image.resizable().aspectRatio(contentMode: .fill)316 .transition(.opacity)317 case .failure:318 accent.opacity(0.1)319 .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.6)))320 default:321 accent.opacity(0.08)322 .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.35)))323 }324 }325 }326}327328// MARK: - Pression (échelle + réactivité, sans haptique sur Mac)329330struct KAPressStyle: ButtonStyle {331 func makeBody(configuration: Configuration) -> some View {332 configuration.label333 .contentShape(Rectangle())334 .scaleEffect(configuration.isPressed ? 0.98 : 1)335 .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)336 }337}338339// MARK: - Squelette de chargement (liste)340341struct KASkeletonRow: View {342 var body: some View {343 HStack(alignment: .top, spacing: 12) {344 RoundedRectangle(cornerRadius: 9).fill(.quaternary).frame(width: 66, height: 66)345 VStack(alignment: .leading, spacing: 8) {346 RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(height: 14)347 RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 180, height: 11)348 RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 90, height: 11)349 }350 }351 .padding(12).frame(maxWidth: .infinity, alignment: .leading).kaCard()352 .redacted(reason: .placeholder)353 .shimmering()354 }355}356357struct Shimmer: ViewModifier {358 @State private var on = false359 func body(content: Content) -> some View {360 content361 .opacity(on ? 0.5 : 0.9)362 .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: on)363 .onAppear { on = true }364 }365}366extension View { func shimmering() -> some View { modifier(Shimmer()) } }367368// MARK: - États369370struct KAEmptyState: View {371 let symbol: String372 let title: String373 var message: String? = nil374 var body: some View {375 VStack(spacing: 10) {376 Image(systemName: symbol).font(.system(size: 40)).foregroundStyle(.secondary)377 Text(title).font(.headline)378 if let m = message {379 Text(m).font(.subheadline).foregroundStyle(.secondary)380 .multilineTextAlignment(.center)381 }382 }383 .padding(30)384 .frame(maxWidth: .infinity)385 }386}387388// MARK: - Champ de recherche signature (bordure encre)389390struct KASearchField: View {391 let prompt: String392 @Binding var text: String393 var onSubmit: () -> Void394 @Environment(\.colorScheme) private var scheme395396 var body: some View {397 HStack(spacing: 8) {398 Image(systemName: "magnifyingglass").foregroundStyle(.secondary)399 TextField(prompt, text: $text)400 .textFieldStyle(.plain)401 .onSubmit(onSubmit)402 if !text.isEmpty {403 Button {404 text = ""405 } label: {406 Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)407 }408 .buttonStyle(.plain)409 .accessibilityLabel("Effacer")410 }411 }412 .padding(.horizontal, 11).padding(.vertical, 8)413 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 10, style: .continuous))414 .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)415 .strokeBorder(KATheme.ink(scheme).opacity(0.5), lineWidth: 1.2))416 }417}418