// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Theme.swift — design system Groupe-KA « éditorial sharp » adapté macOS : // papier #f5f3ee / encre #141814, cartes bordure encre + ombre décalée, // wordmark boîte accent, thème CLAIR par défaut. // Adapté de l'app iOS KA (~/Desktop/KA/KA/Design/Theme.swift), sans UIKit. import SwiftUI // MARK: - Palette enum KATheme { static let paperLight = Color(hex: "#f5f3ee") static let paperDark = Color(hex: "#101410") static let inkLight = Color(hex: "#141814") static let inkDark = Color(hex: "#f0efe8") static let lime = Color(hex: "#d9f26b") static let green = Color(hex: "#1c5c41") static func paper(_ scheme: ColorScheme) -> Color { scheme == .dark ? paperDark : paperLight } static func ink(_ scheme: ColorScheme) -> Color { scheme == .dark ? inkDark : inkLight } static func surface(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: "#1a1f1a") : .white } static func ink2(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: "#a9b0a9") : Color(hex: "#4d5551") } } // MARK: - Carte « éditorial sharp » — tokens EXACTS des sites (tokens.css) : // rayon 10 px, bordure encre 1,5 px, ombre décalée DURE 6×6 pleine encre. struct KACard: ViewModifier { @Environment(\.colorScheme) private var scheme var accent: Color? = nil func body(content: Content) -> some View { content .background(KATheme.surface(scheme)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5) ) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(scheme == .dark ? (accent ?? KATheme.inkDark).opacity(0.25) : KATheme.inkLight) .offset(x: 6, y: 6) ) .padding(.trailing, 6).padding(.bottom, 6) } } extension View { func kaCard(accent: Color? = nil) -> some View { modifier(KACard(accent: accent)) } } // MARK: - Wordmark (« Lou » + boîte [Ka] accent) struct KAWordmark: View { let universe: Universe var size: CGFloat = 20 @Environment(\.colorScheme) private var scheme var body: some View { // fidèle à l'en-tête des sites : « Lou- » encre + boîte ACCENT au texte blanc let parts = universe.wordmark.split(separator: "·", maxSplits: 1) HStack(alignment: .firstTextBaseline, spacing: 3) { Text(parts.count > 1 ? String(parts[0]) + "-" : universe.wordmark) .font(KAFont.display(size)) if parts.count > 1 { Text(String(parts[1])) .font(KAFont.display(size * 0.86)) .foregroundStyle(.white) .padding(.horizontal, size * 0.28) .padding(.vertical, size * 0.08) .background(universe.accent, in: RoundedRectangle(cornerRadius: size * 0.26, style: .continuous)) } } .foregroundStyle(KATheme.ink(scheme)) .fixedSize() .accessibilityLabel(universe.name) } } struct GroupeKAMark: View { var size: CGFloat = 24 @Environment(\.colorScheme) private var scheme var body: some View { HStack(alignment: .firstTextBaseline, spacing: 4) { Text("Groupe").font(KAFont.display(size)) Text("KA") .font(KAFont.display(size * 0.9)) .foregroundStyle(KATheme.lime) .padding(.horizontal, size * 0.3).padding(.vertical, size * 0.1) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: size * 0.28, style: .continuous)) .rotationEffect(.degrees(-2)) } .fixedSize() .foregroundStyle(KATheme.ink(scheme)) .accessibilityLabel("Groupe KA") } } // MARK: - Logo officiel d'une marque (PNG ka-ui embarqué, repli symbole SF) /// Vrai logo de la marque (tuile encre + « Ka » accent, rendu depuis ka-ui). /// `id` = id d'univers (« lou-ka »…) ou « groupe-ka » pour le hub. struct KALogo: View { let id: String var size: CGFloat = 20 /// Repli si le PNG manque (ex. id inconnu) — symbole SF teinté accent. var fallbackSymbol: String = "square.grid.2x2" var fallbackAccent: Color = .gray private static var cache: [String: NSImage] = [:] static func image(_ id: String) -> NSImage? { if let hit = cache[id] { return hit } guard let url = Bundle.module.url(forResource: "logo-\(id)", withExtension: "png", subdirectory: "Logos"), let img = NSImage(contentsOf: url) else { return nil } cache[id] = img return img } var body: some View { if let img = Self.image(id) { Image(nsImage: img) .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: size * 0.22, style: .continuous)) .accessibilityHidden(true) } else { Image(systemName: fallbackSymbol) .font(.system(size: size * 0.6, weight: .semibold)) .foregroundStyle(fallbackAccent) .frame(width: size, height: size) .accessibilityHidden(true) } } } // MARK: - Puce mono (klabel) struct KAChip: View { let text: String var accent: Color? = nil @Environment(\.colorScheme) private var scheme var body: some View { Text(text) .font(KAFont.mono(10)) .textCase(.uppercase) .padding(.horizontal, 9).padding(.vertical, 4) .background((accent ?? KATheme.ink(scheme)).opacity(0.12), in: Capsule()) .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.4), lineWidth: 1)) } } // MARK: - Pastille de statut (vert/rouge/en cours) struct KAStatusDot: View { let up: Bool? var body: some View { Circle() .fill(up == nil ? Color.gray.opacity(0.4) : (up! ? Color(hex: "#2f9e44") : Color(hex: "#e03131"))) .frame(width: 9, height: 9) .accessibilityLabel(up == nil ? "Vérification" : (up! ? "En ligne" : "Hors ligne")) } } // MARK: - Rangée d'item universelle struct KAItemRow: View { let item: KAItem var compact = false @Environment(\.colorScheme) private var scheme @EnvironmentObject private var favorites: FavoritesStore private var universe: Universe? { Ecosystem.universe(item.universeID) } var body: some View { HStack(alignment: .top, spacing: 12) { if let img = item.imageURL { KAImage(url: img, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") .frame(width: compact ? 42 : 66, height: compact ? 42 : 66) .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 9, style: .continuous) .strokeBorder(.primary.opacity(0.25), lineWidth: 1)) .accessibilityHidden(true) } else { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(universe?.accent.opacity(0.9) ?? .gray) .frame(width: 5) .padding(.vertical, 2) } VStack(alignment: .leading, spacing: 3) { Text(item.title) .font(compact ? .subheadline.weight(.semibold) : .headline) .lineLimit(compact ? 1 : 2) if let sub = item.subtitle, !sub.isEmpty { Text(sub).font(compact ? .caption : .subheadline) .foregroundStyle(KATheme.ink2(scheme)).lineLimit(compact ? 1 : 2) } HStack(spacing: 8) { if let price = item.priceLabel { Text(price).font(.system(compact ? .caption : .subheadline, design: .rounded).weight(.bold)) .foregroundStyle(universe?.accent ?? .primary) .lineLimit(1) } if let city = item.city, !city.isEmpty { Text(city).font(.caption).foregroundStyle(KATheme.ink2(scheme)).lineLimit(1) } Spacer(minLength: 0) if let u = universe, !compact { KAChip(text: u.wordmark, accent: u.accent) } } } if favorites.isFavorite(item) { Image(systemName: "heart.fill") .foregroundStyle(.red).font(.caption) .accessibilityLabel("Dans vos favoris") } } .padding(compact ? 8 : 12) .frame(maxWidth: .infinity, alignment: .leading) .kaCard(accent: universe?.accent) } } // MARK: - Carte d'item (grille photo, grandes fenêtres) struct KAItemCard: View { let item: KAItem var selected = false @Environment(\.colorScheme) private var scheme @EnvironmentObject private var favorites: FavoritesStore private var universe: Universe? { Ecosystem.universe(item.universeID) } var body: some View { VStack(alignment: .leading, spacing: 0) { ZStack(alignment: .topTrailing) { KAImage(url: item.imageURL, accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") .frame(height: 130) .frame(maxWidth: .infinity) .clipped() if favorites.isFavorite(item) { Image(systemName: "heart.fill") .font(.caption).foregroundStyle(.red) .padding(6) .background(.ultraThinMaterial, in: Circle()) .padding(6) .accessibilityLabel("Dans vos favoris") } if item.imageURLs.count > 1 { Text("\(item.imageURLs.count) 📷") .font(.system(size: 9, weight: .bold, design: .monospaced)) .padding(.horizontal, 6).padding(.vertical, 3) .background(.ultraThinMaterial, in: Capsule()) .padding(6) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) } } VStack(alignment: .leading, spacing: 4) { Text(item.title).font(.subheadline.weight(.bold)).lineLimit(1) if let sub = item.subtitle, !sub.isEmpty { Text(sub).font(.caption).foregroundStyle(KATheme.ink2(scheme)).lineLimit(1) } HStack(spacing: 6) { if let p = item.priceLabel { Text(p).font(.system(.caption, design: .rounded).weight(.bold)) .foregroundStyle(universe?.accent ?? .primary) .lineLimit(1) } Spacer(minLength: 0) if let c = item.city, !c.isEmpty { Text(c).font(.system(size: 9, weight: .semibold, design: .monospaced)) .foregroundStyle(.secondary).lineLimit(1) } } } .padding(10) } .background(KATheme.surface(scheme)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(selected ? (universe?.accent ?? KATheme.ink(scheme)) : KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: selected ? 2.6 : 1.5) ) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(scheme == .dark ? (universe?.accent ?? KATheme.inkDark).opacity(0.25) : KATheme.inkLight) .offset(x: 6, y: 6) ) .padding(.trailing, 6).padding(.bottom, 6) } } // MARK: - Image réseau fluide (fondu à l'arrivée, cache URLCache partagé) struct KAImage: View { let url: URL? var accent: Color = .gray var symbol: String = "photo" var body: some View { AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.25))) { phase in switch phase { case .success(let image): image.resizable().aspectRatio(contentMode: .fill) .transition(.opacity) case .failure: accent.opacity(0.1) .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.6))) default: accent.opacity(0.08) .overlay(Image(systemName: symbol).foregroundStyle(accent.opacity(0.35))) } } } } // MARK: - Pression (échelle + réactivité, sans haptique sur Mac) struct KAPressStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .contentShape(Rectangle()) .scaleEffect(configuration.isPressed ? 0.98 : 1) .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed) } } // MARK: - Squelette de chargement (liste) struct KASkeletonRow: View { var body: some View { HStack(alignment: .top, spacing: 12) { RoundedRectangle(cornerRadius: 9).fill(.quaternary).frame(width: 66, height: 66) VStack(alignment: .leading, spacing: 8) { RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(height: 14) RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 180, height: 11) RoundedRectangle(cornerRadius: 4).fill(.quaternary).frame(width: 90, height: 11) } } .padding(12).frame(maxWidth: .infinity, alignment: .leading).kaCard() .redacted(reason: .placeholder) .shimmering() } } struct Shimmer: ViewModifier { @State private var on = false func body(content: Content) -> some View { content .opacity(on ? 0.5 : 0.9) .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: on) .onAppear { on = true } } } extension View { func shimmering() -> some View { modifier(Shimmer()) } } // MARK: - États struct KAEmptyState: View { let symbol: String let title: String var message: String? = nil var body: some View { VStack(spacing: 10) { Image(systemName: symbol).font(.system(size: 40)).foregroundStyle(.secondary) Text(title).font(.headline) if let m = message { Text(m).font(.subheadline).foregroundStyle(.secondary) .multilineTextAlignment(.center) } } .padding(30) .frame(maxWidth: .infinity) } } // MARK: - Champ de recherche signature (bordure encre) struct KASearchField: View { let prompt: String @Binding var text: String var onSubmit: () -> Void @Environment(\.colorScheme) private var scheme var body: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) TextField(prompt, text: $text) .textFieldStyle(.plain) .onSubmit(onSubmit) if !text.isEmpty { Button { text = "" } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } .buttonStyle(.plain) .accessibilityLabel("Effacer") } } .padding(.horizontal, 11).padding(.vertical, 8) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(0.5), lineWidth: 1.2)) } }