Brand mark: scallop ribbon shape, app icon in light / dark / tinted
CI / build-and-deploy (push) Successful in 37s
CI / build-and-deploy (pull_request) Successful in 20s

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:
Krishna Kumar
2026-08-03 11:55:39 -05:00
co-authored by Claude Opus 5
parent b3d8719874
commit bc65bb586c
10 changed files with 1073 additions and 0 deletions
+166
View File
@@ -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)
}