Files
2026-08-03 17:00:52 +00:00

167 lines
6.3 KiB
Swift

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..<points {
let apex = maxX - step * (CGFloat(i) - 0.5)
path.addLine(to: CGPoint(x: apex, y: maxY - depth))
path.addLine(to: CGPoint(x: maxX - step * CGFloat(i), y: maxY))
}
// Up the left side and through the left cap. The loop above already
// left the pen on the bottom-left point.
path.addArc(
tangent1End: CGPoint(x: minX, y: minY),
tangent2End: CGPoint(x: minX + radius, y: minY),
radius: radius
)
path.closeSubpath()
return path
}
}
// MARK: - Brand colour
extension Paper {
/// The mark's bands, left to right: navy, vermilion, yellow.
///
/// These are palette swatches rather than bespoke brand colours on
/// purpose. The app already hashes every bookmark into `swatchPairs`, so a
/// mark built from three of them is made of the same ink as the content —
/// which is also the only part of this design a competitor can't lift
/// without lifting the system underneath it.
static let markBands: [Color] = [swatch(5), swatch(1), swatch(0)]
}
// MARK: - The mark, coloured
/// The mark as it should normally be used. Sizes itself to the drawn aspect
/// ratio, so `.frame(height:)` is the natural way to scale it.
struct MarksMark: View {
var bands: [Color] = Paper.markBands
var tails: Int = 4
/// A single-ink version, for anywhere the bands would be noise: monochrome
/// contexts, the share sheet, a nav bar, print.
static func monochrome(_ color: Color = Paper.ink) -> 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)
}