Draw the real icon, and give the menu bar its own glyph
Replaces the placeholder coat silhouette with the app's actual subject: Claude's asterisk above a rack of overcoats, on Claude's own coral. The wardrobe is the app in one picture — several coats to pick between, one currently worn. The menu bar stops scaling the app icon down and draws its own glyph instead. At 18pt the icon's three coats, lapels and pockets collapse into a smudge, and its colours ignore the menu bar entirely. The glyph is a template image, so macOS tints it for light and dark and for the open-menu inversion, and re-tints it itself when the theme changes. That also drove its shape: six rays rather than eight, because at that size eight have under a pixel between arms and merge into a blob. The coloured dots beside it don't get that for free — ProfileColor picks a brightness for the current appearance at the moment it's asked, and the title is only rebuilt when profiles start or stop. Observing effectiveAppearance rebuilds it, so a theme switch doesn't leave dots mixed for the old appearance sitting in the menu bar. Everything is still drawn from vector paths rather than exported from a design tool, so each size can be tuned — small sizes need fatter strokes than a straight downscale gives — and nothing binary but the derived assets is committed. The README gains a hero banner, composed from the generated icon so it can't drift out of step with it. About gains the tagline under the icon, names itself in the description rather than opening with a bare verb, and drops the author line.
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,83 @@
|
||||
// Generates docs/hero.png — the banner at the top of the README.
|
||||
//
|
||||
// Composes the already-generated app icon with the wordmark and tagline rather
|
||||
// than redrawing the coats, so the banner can never drift out of step with the
|
||||
// icon itself. Run generate-icon.swift first.
|
||||
//
|
||||
// Usage:
|
||||
// swift icon/generate-icon.swift icon/AppIcon.iconset
|
||||
// cp icon/AppIcon.iconset/icon_512x512.png icon/shannoncoat.png
|
||||
// iconutil -c icns icon/AppIcon.iconset -o icon/AppIcon.icns
|
||||
// rm -rf icon/AppIcon.iconset
|
||||
// swift icon/generate-hero.swift icon/shannoncoat.png docs/hero.png
|
||||
import Cocoa
|
||||
|
||||
private func rgb(_ hex: UInt32) -> NSColor {
|
||||
NSColor(srgbRed: CGFloat((hex >> 16) & 255) / 255, green: CGFloat((hex >> 8) & 255) / 255,
|
||||
blue: CGFloat(hex & 255) / 255, alpha: 1)
|
||||
}
|
||||
|
||||
// Slate ground rather than cloud: GitHub renders READMEs on both a light and a
|
||||
// dark page, and a dark banner sits comfortably on either, where a near-white
|
||||
// one glares against a dark page. It also lets the coral icon carry the colour.
|
||||
private let ground = rgb(0x26_26_25)
|
||||
private let wordmark = rgb(0xF0_EE_E6)
|
||||
private let subtitle = rgb(0xA8_A2_98)
|
||||
|
||||
private let width = 1200
|
||||
private let height = 360
|
||||
|
||||
let args = CommandLine.arguments
|
||||
let iconPath = args.count > 1 ? args[1] : "icon/shannoncoat.png"
|
||||
let outPath = args.count > 2 ? args[2] : "docs/hero.png"
|
||||
|
||||
guard let icon = NSImage(contentsOfFile: iconPath) else {
|
||||
fatalError("couldn't read \(iconPath) — run generate-icon.swift first")
|
||||
}
|
||||
|
||||
guard let rep = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height,
|
||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
|
||||
) else { fatalError("could not create bitmap rep") }
|
||||
rep.size = NSSize(width: width, height: height)
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
|
||||
ground.setFill()
|
||||
NSBezierPath(rect: NSRect(x: 0, y: 0, width: width, height: height)).fill()
|
||||
|
||||
let iconSide: CGFloat = 184
|
||||
icon.draw(in: NSRect(x: 96, y: (CGFloat(height) - iconSide) / 2, width: iconSide, height: iconSide))
|
||||
|
||||
let textX: CGFloat = 96 + iconSide + 56
|
||||
let name = NSAttributedString(string: "shannoncoat", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 76, weight: .semibold),
|
||||
.foregroundColor: wordmark,
|
||||
])
|
||||
let tagline = NSAttributedString(string: "which coat will Claude wear today?", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 30, weight: .regular),
|
||||
.foregroundColor: subtitle,
|
||||
])
|
||||
|
||||
// Stacked around the vertical centre, using each string's own measured height
|
||||
// so the pair sits optically centred against the icon rather than by guesswork.
|
||||
let nameSize = name.size(), taglineSize = tagline.size()
|
||||
let gap: CGFloat = 14
|
||||
let blockHeight = nameSize.height + gap + taglineSize.height
|
||||
let top = (CGFloat(height) + blockHeight) / 2
|
||||
name.draw(at: NSPoint(x: textX, y: top - nameSize.height))
|
||||
tagline.draw(at: NSPoint(x: textX + 3, y: top - nameSize.height - gap - taglineSize.height))
|
||||
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
let outURL = URL(fileURLWithPath: outPath)
|
||||
try? FileManager.default.createDirectory(
|
||||
at: outURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
guard let png = rep.representation(using: .png, properties: [:]) else {
|
||||
fatalError("failed to encode hero PNG")
|
||||
}
|
||||
try! png.write(to: outURL)
|
||||
print("wrote \(outPath)")
|
||||
+174
-75
@@ -1,8 +1,11 @@
|
||||
// Generates a placeholder AppIcon.iconset — a plain rounded-square
|
||||
// background with the coat glyph on top. Deliberately not derived from
|
||||
// Claude's own icon assets (unlike the old version of this file, which
|
||||
// composited on top of Claude.app's icon) — this is a stand-in until a
|
||||
// real icon is designed, so it draws everything itself from vector shapes.
|
||||
// Generates AppIcon.iconset — Claude's asterisk above a rack of overcoats, on
|
||||
// Claude's own coral. The wardrobe is the app in one picture: several coats to
|
||||
// pick between, one currently worn.
|
||||
//
|
||||
// Everything is drawn from vector paths here rather than exported from a
|
||||
// design tool, so a size can be tuned independently (small sizes need fatter
|
||||
// strokes than a straight downscale gives) and nothing binary but the derived
|
||||
// .icns is committed. Deliberately not derived from Claude's own icon assets.
|
||||
//
|
||||
// Usage:
|
||||
// swift icon/generate-icon.swift icon/AppIcon.iconset
|
||||
@@ -10,85 +13,182 @@
|
||||
// rm -rf icon/AppIcon.iconset
|
||||
import Cocoa
|
||||
|
||||
// Claude's coral and cloud, plus tints of each for depth. The lapel is a
|
||||
// lighter grey than the coat and the back coats are darker corals — tone
|
||||
// separation survives downscaling, where outlines don't.
|
||||
private enum Palette {
|
||||
static func rgb(_ hex: UInt32) -> NSColor {
|
||||
NSColor(srgbRed: CGFloat((hex >> 16) & 255) / 255, green: CGFloat((hex >> 8) & 255) / 255,
|
||||
blue: CGFloat(hex & 255) / 255, alpha: 1)
|
||||
}
|
||||
static let tile = rgb(0xD9_77_57)
|
||||
static let coat = rgb(0xF0_EE_E6)
|
||||
static let lapel = rgb(0xB3_AA_97)
|
||||
static let pocket = rgb(0xB8_AF_9C)
|
||||
static let backLeft = rgb(0x8A_3E_2A)
|
||||
static let backLeftPocket = rgb(0x6E_35_24)
|
||||
static let backRight = rgb(0xB4_61_4A)
|
||||
static let backRightPocket = rgb(0x94_4A_34)
|
||||
}
|
||||
|
||||
private func p(_ x: CGFloat, _ y: CGFloat) -> NSPoint { NSPoint(x: x, y: y) }
|
||||
|
||||
// MARK: - Shapes, drawn in a 100x100 space with y pointing down
|
||||
|
||||
// Eight rays of slightly uneven length, a few degrees off the compass points —
|
||||
// a mechanically regular asterisk reads as a snowflake. Wider than tall, so it
|
||||
// doesn't look like it's standing on end.
|
||||
private func asterisk() -> NSBezierPath {
|
||||
let centre = p(50, 24)
|
||||
let tips = [p(65, 24), p(59, 33.3), p(49.5, 38.5), p(40.8, 32.6),
|
||||
p(35, 23.7), p(40.8, 14.5), p(50.2, 9.8), p(58.6, 15.1)]
|
||||
let path = NSBezierPath()
|
||||
for tip in tips { path.move(to: centre); path.line(to: tip) }
|
||||
path.lineWidth = 5.6
|
||||
path.lineCapStyle = .round
|
||||
return path
|
||||
}
|
||||
|
||||
// One coat in its own 40x44 box, so the three can be placed at different
|
||||
// scales and angles. The centre split is a true hole, so the tile shows through.
|
||||
private func coatBody() -> NSBezierPath {
|
||||
let b = NSBezierPath()
|
||||
b.move(to: p(16, 2))
|
||||
b.curve(to: p(3, 6.5), controlPoint1: p(12, 2.5), controlPoint2: p(6, 4))
|
||||
b.curve(to: p(2.5, 42), controlPoint1: p(1.5, 16), controlPoint2: p(1.5, 30))
|
||||
b.line(to: p(37.5, 42))
|
||||
b.curve(to: p(37, 6.5), controlPoint1: p(38.5, 30), controlPoint2: p(38.5, 16))
|
||||
b.curve(to: p(24, 2), controlPoint1: p(34, 4), controlPoint2: p(28, 2.5))
|
||||
b.line(to: p(20, 26))
|
||||
b.close()
|
||||
b.appendRect(NSRect(x: 19.15, y: 26, width: 1.7, height: 16))
|
||||
b.windingRule = .evenOdd
|
||||
return b
|
||||
}
|
||||
|
||||
// One connected band round the neck rather than two tabs on the shoulders,
|
||||
// dipping at centre where the lapel opening begins. Its top corners sit at
|
||||
// x15/x25 so the lapels cover them — any further out and they poke through as
|
||||
// spikes either side of the neck.
|
||||
private func coatCollar() -> NSBezierPath {
|
||||
let c = NSBezierPath()
|
||||
c.move(to: p(15, -1.6)); c.line(to: p(25, -1.6))
|
||||
c.line(to: p(29, 2.6)); c.line(to: p(21.6, 2.6))
|
||||
c.line(to: p(20, 1)); c.line(to: p(18.4, 2.6))
|
||||
c.line(to: p(11, 2.6))
|
||||
c.close()
|
||||
return c
|
||||
}
|
||||
|
||||
// Notch lapels, each one shape running from the collar's outer corner down to
|
||||
// the button point. The notch sits at (12.5, 8) — pulled down and outward
|
||||
// deliberately, because nearer the inner edge it pinches the polygon to a
|
||||
// hairline and the lapel renders as two separate triangles.
|
||||
private func coatLapels() -> NSBezierPath {
|
||||
let l = NSBezierPath()
|
||||
l.move(to: p(15, -1.6)); l.line(to: p(10.5, 3.5)); l.line(to: p(12.5, 8))
|
||||
l.line(to: p(9, 14)); l.line(to: p(20, 26)); l.close()
|
||||
l.move(to: p(25, -1.6)); l.line(to: p(29.5, 3.5)); l.line(to: p(27.5, 8))
|
||||
l.line(to: p(31, 14)); l.line(to: p(20, 26)); l.close()
|
||||
return l
|
||||
}
|
||||
|
||||
// Filled marks, not holes — a hole would show whichever coat is behind it.
|
||||
private func coatPockets() -> NSBezierPath {
|
||||
let path = NSBezierPath()
|
||||
path.appendRect(NSRect(x: 6.5, y: 22, width: 9, height: 1.7))
|
||||
path.appendRect(NSRect(x: 24.5, y: 22, width: 9, height: 1.7))
|
||||
return path
|
||||
}
|
||||
|
||||
// A hairline along the lapel edges separating the grey lapel from the tile
|
||||
// showing through the opening. Deliberately fine: a detail for 128px and up.
|
||||
// Thickening it to survive smaller sizes only blurs the lapel edge, because at
|
||||
// this diagonal a one-pixel stroke covers a fraction of each pixel it crosses.
|
||||
private func coatOpeningBorder() -> NSBezierPath {
|
||||
let path = NSBezierPath()
|
||||
path.move(to: p(15, -1.6)); path.line(to: p(20, 26)); path.line(to: p(25, -1.6))
|
||||
path.lineWidth = 0.7
|
||||
path.lineJoinStyle = .round
|
||||
path.lineCapStyle = .round
|
||||
return path
|
||||
}
|
||||
|
||||
private func placed(_ path: NSBezierPath, x: CGFloat, y: CGFloat,
|
||||
rotation: CGFloat, scale: CGFloat) -> NSBezierPath {
|
||||
let transform = NSAffineTransform()
|
||||
transform.translateX(by: x, yBy: y)
|
||||
if rotation != 0 { transform.rotate(byDegrees: rotation) }
|
||||
transform.scale(by: scale)
|
||||
let copy = path.copy() as! NSBezierPath
|
||||
copy.transform(using: transform as AffineTransform)
|
||||
return copy
|
||||
}
|
||||
|
||||
// Each coat is finished before the next is laid over it. Drawing all three
|
||||
// bodies and then all three sets of details would put the back coats' collars
|
||||
// and pockets on top of the front coat.
|
||||
private func drawCoat(x: CGFloat, y: CGFloat, rotation: CGFloat, scale: CGFloat,
|
||||
body: NSColor, pocket: NSColor, lapel: NSColor? = nil) {
|
||||
body.setFill()
|
||||
placed(coatBody(), x: x, y: y, rotation: rotation, scale: scale).fill()
|
||||
placed(coatCollar(), x: x, y: y, rotation: rotation, scale: scale).fill()
|
||||
if let lapel {
|
||||
lapel.setFill()
|
||||
placed(coatLapels(), x: x, y: y, rotation: rotation, scale: scale).fill()
|
||||
body.setStroke()
|
||||
let border = placed(coatOpeningBorder(), x: x, y: y, rotation: rotation, scale: scale)
|
||||
border.lineWidth = 0.7 * scale
|
||||
border.stroke()
|
||||
}
|
||||
pocket.setFill()
|
||||
placed(coatPockets(), x: x, y: y, rotation: rotation, scale: scale).fill()
|
||||
}
|
||||
|
||||
private func drawIcon() {
|
||||
Palette.tile.setFill()
|
||||
NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: 100, height: 100),
|
||||
xRadius: 22, yRadius: 22).fill()
|
||||
|
||||
Palette.coat.setStroke()
|
||||
asterisk().stroke()
|
||||
|
||||
drawCoat(x: 11, y: 50, rotation: -8, scale: 0.78,
|
||||
body: Palette.backLeft, pocket: Palette.backLeftPocket)
|
||||
drawCoat(x: 60, y: 47, rotation: 8, scale: 0.78,
|
||||
body: Palette.backRight, pocket: Palette.backRightPocket)
|
||||
drawCoat(x: 31.5, y: 44, rotation: 0, scale: 0.92,
|
||||
body: Palette.coat, pocket: Palette.pocket, lapel: Palette.lapel)
|
||||
}
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
// Renders at exact pixel dimensions via an explicit NSBitmapImageRep rather
|
||||
// than NSImage.lockFocus() — lockFocus rasterizes at the *main screen's*
|
||||
// backing scale factor, which would silently double every size on a Retina
|
||||
// display instead of producing the exact px asked for.
|
||||
func renderIcon(pixels: Int) -> Data {
|
||||
let s = CGFloat(pixels)
|
||||
private func renderIcon(pixels: Int) -> Data {
|
||||
let side = CGFloat(pixels)
|
||||
guard let rep = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: pixels,
|
||||
pixelsHigh: pixels,
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0
|
||||
bitmapDataPlanes: nil, pixelsWide: pixels, pixelsHigh: pixels,
|
||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
|
||||
) else { fatalError("could not create bitmap rep") }
|
||||
rep.size = NSSize(width: s, height: s)
|
||||
rep.size = NSSize(width: side, height: side)
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
let ctx = NSGraphicsContext(bitmapImageRep: rep)
|
||||
NSGraphicsContext.current = ctx
|
||||
ctx?.imageInterpolation = .high
|
||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
|
||||
// Base: a plain rounded-square background (macOS "squircle"-ish corner
|
||||
// radius), a neutral slate color — no borrowed assets.
|
||||
let bg = NSColor(calibratedRed: 0.30, green: 0.32, blue: 0.36, alpha: 1.0)
|
||||
let bgPath = NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: s, height: s),
|
||||
xRadius: s * 0.22, yRadius: s * 0.22)
|
||||
bg.setFill()
|
||||
bgPath.fill()
|
||||
// The shapes are authored y-down, the way the drawing reads; flip once
|
||||
// here rather than mirroring every coordinate.
|
||||
let flip = NSAffineTransform()
|
||||
flip.translateX(by: 0, yBy: side)
|
||||
flip.scaleX(by: side / 100, yBy: -side / 100)
|
||||
flip.concat()
|
||||
|
||||
// Foreground: the same coat glyph as before — an original shape, not
|
||||
// derived from any borrowed icon.
|
||||
let coat = NSColor(calibratedRed: 0.62, green: 0.44, blue: 0.24, alpha: 1.0)
|
||||
let coatShadow = NSColor(calibratedRed: 0.48, green: 0.33, blue: 0.16, alpha: 1.0)
|
||||
let midX = s * 0.5
|
||||
let topY = s * 0.62
|
||||
let collarOutX = s * 0.34
|
||||
let collarInX = s * 0.11
|
||||
let shoulderY = s * 0.53
|
||||
let waistY = s * 0.30
|
||||
let waistHalf = s * 0.30
|
||||
let hemY = s * 0.06
|
||||
let hemHalf = s * 0.40
|
||||
drawIcon()
|
||||
|
||||
let path = NSBezierPath()
|
||||
path.move(to: NSPoint(x: midX, y: topY))
|
||||
path.line(to: NSPoint(x: midX - collarOutX, y: shoulderY))
|
||||
path.line(to: NSPoint(x: midX - collarInX, y: shoulderY - s * 0.07))
|
||||
path.line(to: NSPoint(x: midX - waistHalf, y: waistY))
|
||||
path.line(to: NSPoint(x: midX - hemHalf, y: hemY))
|
||||
path.line(to: NSPoint(x: midX + hemHalf, y: hemY))
|
||||
path.line(to: NSPoint(x: midX + waistHalf, y: waistY))
|
||||
path.line(to: NSPoint(x: midX + collarInX, y: shoulderY - s * 0.07))
|
||||
path.line(to: NSPoint(x: midX + collarOutX, y: shoulderY))
|
||||
path.close()
|
||||
coat.setFill()
|
||||
path.fill()
|
||||
|
||||
// A thin darker strip down the centerline reads as the coat's front
|
||||
// seam/placket, breaking up the flat fill at larger sizes.
|
||||
let seam = NSBezierPath()
|
||||
seam.move(to: NSPoint(x: midX, y: topY))
|
||||
seam.line(to: NSPoint(x: midX, y: hemY))
|
||||
seam.lineWidth = max(s * 0.012, 1)
|
||||
coatShadow.setStroke()
|
||||
seam.stroke()
|
||||
|
||||
let buttonRadius = max(s * 0.02, 0.75)
|
||||
for t: CGFloat in [0.20, 0.42, 0.64] {
|
||||
let y = hemY + (shoulderY - hemY) * t
|
||||
let r = NSRect(x: midX - buttonRadius, y: y - buttonRadius, width: buttonRadius * 2, height: buttonRadius * 2)
|
||||
coatShadow.setFill()
|
||||
NSBezierPath(ovalIn: r).fill()
|
||||
}
|
||||
|
||||
ctx?.flushGraphics()
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
guard let png = rep.representation(using: .png, properties: [:]) else {
|
||||
@@ -108,8 +208,7 @@ let sizes: [(name: String, px: Int)] = [
|
||||
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "AppIcon.iconset"
|
||||
try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
|
||||
for (name, px) in sizes {
|
||||
let data = renderIcon(pixels: px)
|
||||
let path = "\(outDir)/\(name).png"
|
||||
try? data.write(to: URL(fileURLWithPath: path))
|
||||
try? renderIcon(pixels: px).write(to: URL(fileURLWithPath: path))
|
||||
print("wrote \(path)")
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Reference in New Issue
Block a user