MyApp/RetroTheme.swift
11 KB · 231 lines · swift · Download from the repository page
import SwiftUI enum RetroTheme { static let canvas = Color(red: 243/255, green: 240/255, blue: 231/255) static let surface = Color(red: 1, green: 253/255, blue: 245/255) static let ink = Color(red: 20/255, green: 20/255, blue: 20/255) static let yellow = Color(red: 1, green: 229/255, blue: 0) static let green = Color(red: 183/255, green: 1, blue: 0) static let pink = Color(red: 1, green: 46/255, blue: 155/255) static let blue = Color(red: 37/255, green: 75/255, blue: 1) static let error = Color(red: 194/255, green: 31/255, blue: 31/255) static let border: CGFloat = 2 static let shadow: CGFloat = 4 static let radius: CGFloat = 4 // MARK: Spacing scale — every screen uses these instead of ad-hoc numbers, // so margins and gaps read as one consistent system rather than per-view guesses. static let spaceXS: CGFloat = 6 static let spaceS: CGFloat = 12 static let spaceM: CGFloat = 18 static let spaceL: CGFloat = 24 static let spaceXL: CGFloat = 32 /// Content never grows wider than this, so a page of controls stays a /// readable column instead of stretching edge-to-edge on an iPad. static let contentMaxWidth: CGFloat = 760 static let screenPaddingH: CGFloat = RetroTheme.spaceL static let screenPaddingTop: CGFloat = RetroTheme.spaceL static let screenPaddingBottom: CGFloat = RetroTheme.spaceXL static func heading(_ style: Font.TextStyle = .title) -> Font { .system(style, design: .default).weight(.black).width(.condensed) } static func mono(_ style: Font.TextStyle = .caption) -> Font { .system(style, design: .monospaced).weight(.bold) }} /// The scrollable shell every top-level screen uses: a centered column capped/// at `contentMaxWidth` (so wide iPad windows don't leave content stranded/// against one edge with a dead zone on the other), consistent outer margins/// on every side, and the canvas texture behind it.struct RetroScreen<Content: View>: View { var spacing: CGFloat = RetroTheme.spaceL var alignment: HorizontalAlignment = .leading @ViewBuilder var content: Content var body: some View { ScrollView { VStack(alignment: alignment, spacing: spacing) { content } .padding(.horizontal, RetroTheme.screenPaddingH) .padding(.top, RetroTheme.screenPaddingTop) .padding(.bottom, RetroTheme.screenPaddingBottom) .frame(maxWidth: RetroTheme.contentMaxWidth) .frame(maxWidth: .infinity) // re-centers the capped column; without this a ScrollView leaves it pinned to the leading edge } .background { RetroGridBackground().ignoresSafeArea() } .scrollDismissesKeyboard(.interactively) }} /// A fixed action bar pinned to the bottom of a screen, styled the same way/// everywhere it appears (ink top rule, canvas fill, centered capped column)/// instead of each screen hand-rolling its own padding and background.struct RetroBottomBar<Content: View>: View { @ViewBuilder var content: Content var body: some View { VStack(spacing: RetroTheme.spaceS) { content } .frame(maxWidth: RetroTheme.contentMaxWidth) .frame(maxWidth: .infinity) .padding(.horizontal, RetroTheme.screenPaddingH) .padding(.vertical, RetroTheme.spaceM) .background(RetroTheme.canvas) .overlay(alignment: .top) { Rectangle().fill(RetroTheme.ink).frame(height: RetroTheme.border) } }} /// Faint graph-paper grid behind every screen — a quiet nod to schematics and/// terminal displays. Kept to ~3% opacity so it never competes with content.struct RetroGridBackground: View { var body: some View { RetroTheme.canvas.overlay { Canvas { context, size in let step: CGFloat = 32 var path = Path() var x: CGFloat = 0 while x <= size.width { path.move(to: CGPoint(x: x, y: 0)); path.addLine(to: CGPoint(x: x, y: size.height)); x += step } var y: CGFloat = 0 while y <= size.height { path.move(to: CGPoint(x: 0, y: y)); path.addLine(to: CGPoint(x: size.width, y: y)); y += step } context.stroke(path, with: .color(RetroTheme.ink.opacity(0.035)), lineWidth: 1) } } .allowsHitTesting(false) }} struct RetroPanel<Content: View>: View { var color: Color = RetroTheme.surface @ViewBuilder var content: Content var body: some View { content.padding(RetroTheme.spaceM).frame(maxWidth: .infinity, alignment: .leading) .background { RoundedRectangle(cornerRadius: RetroTheme.radius).fill(color) .shadow(color: RetroTheme.ink, radius: 0, x: RetroTheme.shadow, y: RetroTheme.shadow) } .overlay(RoundedRectangle(cornerRadius: RetroTheme.radius).stroke(RetroTheme.ink, lineWidth: RetroTheme.border)) .foregroundStyle(RetroTheme.ink) }} struct RetroButtonStyle: ButtonStyle { var color: Color = RetroTheme.green @Environment(\.isEnabled) private var enabled func makeBody(configuration: Configuration) -> some View { configuration.label.font(RetroTheme.heading(.headline)) .padding(.horizontal, RetroTheme.spaceM).padding(.vertical, RetroTheme.spaceS).frame(minWidth: 44, minHeight: 44) .foregroundStyle(RetroTheme.ink) .background { RoundedRectangle(cornerRadius: RetroTheme.radius).fill(enabled ? color : RetroTheme.canvas) .shadow(color: RetroTheme.ink, radius: 0, x: configuration.isPressed ? 1 : RetroTheme.shadow, y: configuration.isPressed ? 1 : RetroTheme.shadow) } .overlay(RoundedRectangle(cornerRadius: RetroTheme.radius).stroke(RetroTheme.ink, style: StrokeStyle(lineWidth: RetroTheme.border, dash: enabled ? [] : [4, 2]))) .offset(x: configuration.isPressed ? 3 : 0, y: configuration.isPressed ? 3 : 0) .animation(.easeOut(duration: 0.08), value: configuration.isPressed) }} struct RetroTag: View { let title: String var color: Color = RetroTheme.blue var body: some View { Text(title).font(RetroTheme.mono(.caption)).tracking(0.5) .padding(.horizontal, RetroTheme.spaceS).padding(.vertical, RetroTheme.spaceXS) .foregroundStyle(color == RetroTheme.blue || color == RetroTheme.error ? Color.white : RetroTheme.ink).background(color) .accessibilityLabel(title) }} /// A section title with a blinking terminal-style cursor — the one deliberate/// bit of motion in the theme, and it respects Reduce Motion.struct RetroSectionHeader: View { let title: String @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var cursorOn = true var body: some View { HStack(spacing: RetroTheme.spaceXS) { // Left as a plain, unwrapped Text (not combined into a custom // accessibility element) so it stays a normal staticText, exactly // as UI tests locate it by its visible, uppercased label. Text(title.uppercased()).font(RetroTheme.heading(.title2)) if !reduceMotion { Text("▮").font(RetroTheme.heading(.title2)).opacity(cursorOn ? 1 : 0) .accessibilityHidden(true) .onAppear { withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) { cursorOn.toggle() } } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, RetroTheme.spaceM).padding(.vertical, RetroTheme.spaceS + 2) .foregroundStyle(RetroTheme.ink).background(RetroTheme.yellow) .overlay(Rectangle().stroke(RetroTheme.ink, lineWidth: RetroTheme.border)) }} /// Small uppercase monospaced readout, used for the handful of places (nav/// titles, dashboard labels) that want the "terminal display" voice without/// the full yellow banner treatment of `RetroSectionHeader`.struct RetroTitleLabel: View { let title: String var body: some View { Text(title.uppercased()).font(RetroTheme.heading(.title3)) }} struct RetroStepIndicator: View { let step: Int var body: some View { HStack(spacing: RetroTheme.spaceS) { ForEach(1...3, id: \.self) { number in Text("\(number) " + ["CONSENT", "SETUP", "PLAY"][number - 1]) .font(RetroTheme.mono(.caption)).padding(RetroTheme.spaceS) .frame(maxWidth: .infinity) .background(step == number ? RetroTheme.yellow : RetroTheme.surface) .overlay(Rectangle().stroke(RetroTheme.ink, lineWidth: step == number ? 3 : 1)) .accessibilityLabel("Step \(number), " + ["consent", "setup", "game"][number - 1] + (step == number ? ", current" : "")) } } }} /// Consistent empty-state styling (used wherever `ContentUnavailableView`/// would otherwise render in the system's default gray, which clashes with/// the high-contrast retro palette).struct RetroEmptyState: View { let title: String let systemImage: String let message: String var body: some View { VStack(spacing: RetroTheme.spaceS) { Image(systemName: systemImage).font(.system(size: 40)).foregroundStyle(RetroTheme.ink.opacity(0.35)) Text(title).font(RetroTheme.heading(.title3)) Text(message).font(.subheadline).foregroundStyle(RetroTheme.ink.opacity(0.65)).multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(RetroTheme.spaceXL) .accessibilityElement(children: .combine) }} struct RetroAppearance: ViewModifier { func body(content: Content) -> some View { content.foregroundStyle(RetroTheme.ink).tint(RetroTheme.blue) .background { RetroGridBackground().ignoresSafeArea() } .preferredColorScheme(.light) .buttonStyle(RetroButtonStyle()) }}extension View { func retroAppearance() -> some View { modifier(RetroAppearance()) } /// Styles the navigation bar's title with the retro display font while /// keeping the plain string as the real `navigationTitle` (back-button /// label, accessibility, Spotlight) — one call site for shared chrome. func retroTitle(_ title: String) -> some View { self.navigationTitle(title).navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { RetroTitleLabel(title: title) } } }}