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 » (bordure encre + ombre décalée)2526struct KACard: ViewModifier {27 @Environment(\.colorScheme) private var scheme28 var accent: Color? = nil2930 func body(content: Content) -> some View {31 content32 .background(KATheme.surface(scheme))33 .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))34 .overlay(35 RoundedRectangle(cornerRadius: 12, style: .continuous)36 .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.3)37 )38 .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)42 )43 .padding(.trailing, 4).padding(.bottom, 4)44 }45}4647extension View {48 func kaCard(accent: Color? = nil) -> some View { modifier(KACard(accent: accent)) }49}5051// MARK: - Wordmark (« Lou » + boîte [Ka] accent)5253struct KAWordmark: View {54 let universe: Universe55 var size: CGFloat = 2056 @Environment(\.colorScheme) private var scheme5758 var body: some View {59 let parts = universe.wordmark.split(separator: "·", maxSplits: 1)60 HStack(alignment: .firstTextBaseline, spacing: 3) {61 Text(parts.first.map(String.init) ?? universe.wordmark)62 .font(.system(size: size, weight: .bold, design: .rounded))63 if parts.count > 1 {64 Text(String(parts[1]))65 .font(.system(size: size * 0.86, weight: .bold, design: .rounded))66 .foregroundStyle(universe.accent)67 .padding(.horizontal, size * 0.28)68 .padding(.vertical, size * 0.08)69 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous))70 .rotationEffect(.degrees(-2))71 }72 }73 .foregroundStyle(KATheme.ink(scheme))74 .fixedSize()75 .accessibilityLabel(universe.name)76 }77}7879struct GroupeKAMark: View {80 var size: CGFloat = 2481 @Environment(\.colorScheme) private var scheme82 var body: some View {83 HStack(alignment: .firstTextBaseline, spacing: 4) {84 Text("Groupe").font(.system(size: size, weight: .bold, design: .rounded))85 Text("KA")86 .font(.system(size: size * 0.9, weight: .bold, design: .rounded))87 .foregroundStyle(KATheme.lime)88 .padding(.horizontal, size * 0.3).padding(.vertical, size * 0.1)89 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.28, style: .continuous))90 .rotationEffect(.degrees(-2))91 }92 .fixedSize()93 .foregroundStyle(KATheme.ink(scheme))94 .accessibilityLabel("Groupe KA")95 }96}9798// MARK: - Puce mono (klabel)99100struct KAChip: View {101 let text: String102 var accent: Color? = nil103 @Environment(\.colorScheme) private var scheme104 var body: some View {105 Text(text)106 .font(.system(.caption2, design: .monospaced).weight(.bold))107 .textCase(.uppercase)108 .padding(.horizontal, 9).padding(.vertical, 4)109 .background((accent ?? KATheme.ink(scheme)).opacity(0.12), in: Capsule())110 .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.4), lineWidth: 1))111 }112}113114// MARK: - Pastille de statut (vert/rouge/en cours)115116struct KAStatusDot: View {117 let up: Bool?118 var body: some View {119 Circle()120 .fill(up == nil ? Color.gray.opacity(0.4) : (up! ? Color(hex: "#2f9e44") : Color(hex: "#e03131")))121 .frame(width: 9, height: 9)122 .accessibilityLabel(up == nil ? "Vérification" : (up! ? "En ligne" : "Hors ligne"))123 }124}125126// MARK: - Rangée d'item universelle127128struct KAItemRow: View {129 let item: KAItem130 var compact = false131 @Environment(\.colorScheme) private var scheme132 @EnvironmentObject private var favorites: FavoritesStore133134 private var universe: Universe? { Ecosystem.universe(item.universeID) }135136 var body: some View {137 HStack(alignment: .top, spacing: 12) {138 if let img = item.imageURL {139 KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo")140 .frame(width: compact ? 42 : 66, height: compact ? 42 : 66)141 .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))142 .overlay(RoundedRectangle(cornerRadius: 9, style: .continuous)143 .strokeBorder(.primary.opacity(0.25), lineWidth: 1))144 .accessibilityHidden(true)145 } else {146 RoundedRectangle(cornerRadius: 8, style: .continuous)147 .fill(universe?.accent.opacity(0.9) ?? .gray)148 .frame(width: 5)149 .padding(.vertical, 2)150 }151 VStack(alignment: .leading, spacing: 3) {152 Text(item.title)153 .font(compact ? .subheadline.weight(.semibold) : .headline)154 .lineLimit(compact ? 1 : 2)155 if let sub = item.subtitle, !sub.isEmpty {156 Text(sub).font(compact ? .caption : .subheadline)157 .foregroundStyle(KATheme.ink2(scheme)).lineLimit(compact ? 1 : 2)158 }159 HStack(spacing: 8) {160 if let price = item.priceLabel {161 Text(price).font(.system(compact ? .caption : .subheadline, design: .rounded).weight(.bold))162 .foregroundStyle(universe?.accent ?? .primary)163 .lineLimit(1)164 }165 if let city = item.city, !city.isEmpty {166 Text(city).font(.caption).foregroundStyle(KATheme.ink2(scheme)).lineLimit(1)167 }168 Spacer(minLength: 0)169 if let u = universe, !compact {170 KAChip(text: u.wordmark, accent: u.accent)171 }172 }173 }174 if favorites.isFavorite(item) {175 Image(systemName: "heart.fill")176 .foregroundStyle(.red).font(.caption)177 .accessibilityLabel("Dans vos favoris")178 }179 }180 .padding(compact ? 8 : 12)181 .frame(maxWidth: .infinity, alignment: .leading)182 .kaCard(accent: universe?.accent)183 }184}185186// MARK: - Carte d'item (grille photo, grandes fenêtres)187188struct KAItemCard: View {189 let item: KAItem190 var selected = false191 @Environment(\.colorScheme) private var scheme192 @EnvironmentObject private var favorites: FavoritesStore193194 private var universe: Universe? { Ecosystem.universe(item.universeID) }195196 var body: some View {197 VStack(alignment: .leading, spacing: 0) {198 ZStack(alignment: .topTrailing) {199 KAImage(url: item.imageURL, accent: universe?.accent ?? .gray,200 symbol: universe?.symbol ?? "photo")201 .frame(height: 130)202 .frame(maxWidth: .infinity)203 .clipped()204 if favorites.isFavorite(item) {205 Image(systemName: "heart.fill")206 .font(.caption).foregroundStyle(.red)207 .padding(6)208 .background(.ultraThinMaterial, in: Circle())209 .padding(6)210 .accessibilityLabel("Dans vos favoris")211 }212 if item.imageURLs.count > 1 {213 Text("\(item.imageURLs.count) 📷")214 .font(.system(size: 9, weight: .bold, design: .monospaced))215 .padding(.horizontal, 6).padding(.vertical, 3)216 .background(.ultraThinMaterial, in: Capsule())217 .padding(6)218 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)219 }220 }221 VStack(alignment: .leading, spacing: 4) {222 Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1)223 if let sub = item.subtitle, !sub.isEmpty {224 Text(sub).font(.caption).foregroundStyle(KATheme.ink2(scheme)).lineLimit(1)225 }226 HStack(spacing: 6) {227 if let p = item.priceLabel {228 Text(p).font(.system(.caption, design: .rounded).weight(.bold))229 .foregroundStyle(universe?.accent ?? .primary)230 .lineLimit(1)231 }232 Spacer(minLength: 0)233 if let c = item.city, !c.isEmpty {234 Text(c).font(.system(size: 9, weight: .semibold, design: .monospaced))235 .foregroundStyle(.secondary).lineLimit(1)236 }237 }238 }239 .padding(10)240 }241 .background(KATheme.surface(scheme))242 .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))243 .overlay(244 RoundedRectangle(cornerRadius: 12, style: .continuous)245 .strokeBorder(selected ? (universe?.accent ?? KATheme.ink(scheme))246 : KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85),247 lineWidth: selected ? 2.6 : 1.3)248 )249 .background(250 RoundedRectangle(cornerRadius: 12, style: .continuous)251 .fill((universe?.accent ?? KATheme.ink(scheme)).opacity(scheme == .dark ? 0.25 : 0.16))252 .offset(x: 4, y: 4)253 )254 .padding(.trailing, 4).padding(.bottom, 4)255 }256}257258// MARK: - Image réseau fluide (fondu à l'arrivée, cache URLCache partagé)259260struct KAImage: View {261 let url: URL?262 var accent: Color = .gray263 var symbol: String = "photo"264265 var body: some View {266 AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.25))) { phase in267 switch phase {268 case .success(let image):269 image.resizable().aspectRatio(contentMode: .fill)270 .transition(.opacity)271 case .failure:272 accent.opacity(0.1)273 .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.6)))274 default:275 accent.opacity(0.08)276 .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.35)))277 }278 }279 }280}281282// MARK: - Pression (échelle + réactivité, sans haptique sur Mac)283284struct KAPressStyle: ButtonStyle {285 func makeBody(configuration: Configuration) -> some View {286 configuration.label287 .contentShape(Rectangle())288 .scaleEffect(configuration.isPressed ? 0.98 : 1)289 .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)290 }291}292293// MARK: - Squelette de chargement (liste)294295struct KASkeletonRow: View {296 var body: some View {297 HStack(alignment: .top, spacing: 12) {298 RoundedRectangle(cornerRadius: 9).fill(.quaternary).frame(width: 66, height: 66)299 VStack(alignment: .leading, spacing: 8) {300 RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(height: 14)301 RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 180, height: 11)302 RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 90, height: 11)303 }304 }305 .padding(12).frame(maxWidth: .infinity, alignment: .leading).kaCard()306 .redacted(reason: .placeholder)307 .shimmering()308 }309}310311struct Shimmer: ViewModifier {312 @State private var on = false313 func body(content: Content) -> some View {314 content315 .opacity(on ? 0.5 : 0.9)316 .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: on)317 .onAppear { on = true }318 }319}320extension View { func shimmering() -> some View { modifier(Shimmer()) } }321322// MARK: - États323324struct KAEmptyState: View {325 let symbol: String326 let title: String327 var message: String? = nil328 var body: some View {329 VStack(spacing: 10) {330 Image(systemName: symbol).font(.system(size: 40)).foregroundStyle(.secondary)331 Text(title).font(.headline)332 if let m = message {333 Text(m).font(.subheadline).foregroundStyle(.secondary)334 .multilineTextAlignment(.center)335 }336 }337 .padding(30)338 .frame(maxWidth: .infinity)339 }340}341342// MARK: - Champ de recherche signature (bordure encre)343344struct KASearchField: View {345 let prompt: String346 @Binding var text: String347 var onSubmit: () -> Void348 @Environment(\.colorScheme) private var scheme349350 var body: some View {351 HStack(spacing: 8) {352 Image(systemName: "magnifyingglass").foregroundStyle(.secondary)353 TextField(prompt, text: $text)354 .textFieldStyle(.plain)355 .onSubmit(onSubmit)356 if !text.isEmpty {357 Button {358 text = ""359 } label: {360 Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)361 }362 .buttonStyle(.plain)363 .accessibilityLabel("Effacer")364 }365 }366 .padding(.horizontal, 11).padding(.vertical, 8)367 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 10, style: .continuous))368 .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)369 .strokeBorder(KATheme.ink(scheme).opacity(0.5), lineWidth: 1.2))370 }371}372