Brand mark: scallop ribbon shape, app icon in light / dark / tinted
The mark is one closed path with a scalloped baseline — four tails, rounded cap — rather than a stack of separate ribbons. That is the whole design decision. A logo gets tested at 16pt in a Spotlight result and in iOS 26's tinted and monochrome icon modes, where the palette is discarded and only the silhouette survives. Arrangements of N separate ribbons collapse into a blob under both, because the shapes were only ever told apart by colour; a scalloped outline is told apart by its own edge, so it reads the same tinted, monochrome, and 16pt tall. ScallopMark defines geometry in a canonical 88x98 box and fits it to whatever rect it is handed, so proportions cannot drift with the container. MarksMark adds the three bands as a hard-stopped gradient, keeping it a single fillable shape. The bands are Paper.swatchPairs entries rather than bespoke brand colours: the app already hashes every bookmark into that palette, so the mark is made of the same ink as the content. App icon ships all three variants per Apple's iOS 18+ model — light flattened (iOS rejects an alpha channel there), dark and tinted on transparency for the system to composite. The tinted variant carries three greys ordered by the colour version's relative luminance rather than one flat grey, so the band reading survives the one mode that has nothing else left. The PNGs are build output: edit scripts/appicon/appicon.html and re-run generate.sh. docs/logo-variants.html and docs/logo-c1-variants.html are the exploration the choice came out of. Verified: SwiftUI shape rendered through ImageRenderer matches the SVG at every size from 160pt to 16pt; assetutil confirms all three renditions in the compiled catalog with the correct appearance tags; installed on a simulator and checked against the home screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b3d8719874
commit
bc65bb586c
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