Brand mark: scallop ribbon shape, app icon in light / dark / tinted (#11)
CI / build-and-deploy (push) Successful in 18s
CI / build-and-deploy (push) Successful in 18s
This commit was merged in pull request #11.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 14 KiB |
@@ -5,6 +5,30 @@
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon-Dark.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon-Tinted.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user