import SwiftUI // MARK: - The mark // // One path, four tails. // // The brand mark is a single closed shape rather than a stack of separate // ribbons, and that is the whole design decision. A logo gets tested in two // places that have nothing to do with how it looks on a slide: at 16pt in a // Spotlight result, and in iOS 26's tinted and monochrome icon modes, where the // palette is thrown away and only the silhouette survives. Every arrangement of // N separate ribbons collapses into a blob under both tests, because the shapes // were only ever told apart by colour. A scalloped baseline is told apart by // its outline, so it reads the same tinted, monochrome, and 16pt tall. // // Geometry is defined in a canonical 88 x 98 box and fitted to whatever rect it // is handed, so the proportions can't drift with the container. struct ScallopMark: Shape { /// Downward points along the baseline. Four is the drawn default: three /// reads as a plain banner, and five starts to look like bunting. var tails: Int = 4 /// How far the notches cut up from the baseline, as a fraction of height. /// Shallower than this and the tails read as damage rather than intent. var notchDepth: CGFloat = 16.0 / 98.0 /// Top-corner radius, as a fraction of width. var capRadius: CGFloat = 10.0 / 88.0 /// Canonical proportions of the drawn mark. Everything else derives from /// this, including the app icon artwork — see scripts/appicon. static let aspectRatio: CGFloat = 88.0 / 98.0 func path(in rect: CGRect) -> Path { // Fit the canonical box into `rect` without distorting it, so callers // can hand this any frame and still get the drawn proportions. let height = min(rect.height, rect.width / Self.aspectRatio) let width = height * Self.aspectRatio let minX = rect.midX - width / 2 let minY = rect.midY - height / 2 let maxX = minX + width let maxY = minY + height let radius = min(capRadius * width, min(width, height) / 2) let depth = notchDepth * height let points = max(2, tails) let step = width / CGFloat(points - 1) var path = Path() path.move(to: CGPoint(x: minX + radius, y: minY)) // Top edge, right cap, then straight down the right side. path.addArc( tangent1End: CGPoint(x: maxX, y: minY), tangent2End: CGPoint(x: maxX, y: minY + radius), radius: radius ) path.addLine(to: CGPoint(x: maxX, y: maxY)) // The tails, cut right to left so the winding stays consistent with // the caps above. for i in 1.. MarksMark { MarksMark(bands: [color]) } var body: some View { ScallopMark(tails: tails) .fill(bandFill) .aspectRatio(ScallopMark.aspectRatio, contentMode: .fit) } /// Hard-stopped, not blended: the bands are three inks laid side by side, /// not a gradient. Doubling the stop at each boundary is what kills the /// ramp SwiftUI would otherwise interpolate. private var bandFill: LinearGradient { let stops = bands.enumerated().flatMap { index, color in [ Gradient.Stop(color: color, location: Double(index) / Double(bands.count)), Gradient.Stop(color: color, location: Double(index + 1) / Double(bands.count)), ] } return LinearGradient(stops: stops, startPoint: .leading, endPoint: .trailing) } } // MARK: - Previews #Preview("Size ladder") { VStack(spacing: 28) { HStack(alignment: .bottom, spacing: 22) { ForEach([128, 64, 40, 24, 16], id: \.self) { size in MarksMark().frame(height: CGFloat(size)) } } // The test the shape was chosen to pass: no colour, same mark. HStack(alignment: .bottom, spacing: 22) { ForEach([128, 64, 40, 24, 16], id: \.self) { size in MarksMark.monochrome().frame(height: CGFloat(size)) } } HStack(spacing: 22) { ForEach(3..<6) { tails in MarksMark(tails: tails).frame(height: 72) } } } .padding(40) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Paper.sheet) } #Preview("Lockup") { HStack(spacing: 18) { MarksMark().frame(height: 64) VStack(alignment: .leading, spacing: 6) { Text("Marks").font(.system(size: 46, design: .serif)) Text("EVERYTHING YOU SAVED") .font(PaperType.micro) .tracking(3) .foregroundStyle(Paper.tertiary) } } .foregroundStyle(Paper.ink) .padding(40) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Paper.sheet) }