7 Commits
Author SHA1 Message Date
Claude Opus 5andbdeshi 061ae098da Sign builds and releases with a stable code identity
swiftc linker-signs only the inner binary, leaving the bundle unsigned
and its codesign identifier as "launcher" rather than the bundle id. More
importantly it leaves the app ad-hoc signed, and macOS pins an ad-hoc
app's Accessibility grant to its exact cdhash instead of to a designated
requirement. Every build mints a new cdhash, so each rebuild-and-replace
silently revoked the permission while the app stayed listed and ticked
under Privacy & Security — and every release did the same to everyone who
updated. That was the root cause of the window tags never appearing.

- build.sh signs the bundle with its real identifier, honours
  SHANNONCOAT_SIGN_IDENTITY, and fails outright rather than falling back
  to ad-hoc when an identity was asked for explicitly.
- The identity is read from a gitignored .env, so it doesn't have to be
  retyped every build. Parsed rather than sourced, so a stray command in
  the file can't execute as a side effect of building, and so an existing
  environment variable still wins. .env.example carries the full one-time
  setup.
- release.yml imports the certificate into a throwaway keychain, builds,
  verifies, and deletes the keychain on if: always(). It stays inert
  until the three secrets exist, and fails the release rather than
  shipping ad-hoc.
- Guards the empty-password case explicitly: macOS cannot import an
  OpenSSL-produced PKCS#12 with an empty password, and reports it as "MAC
  verification failed (wrong password?)", which sends you hunting for a
  wrong password rather than a missing one. Nothing local catches this,
  since the login keychain imports the PEM pair and needs no password.
- Ignores *.p12 and *.pem as a backstop; the certificate belongs outside
  the working tree entirely.

Verified end-to-end: two from-scratch builds produce byte-identical
designated requirements where ad-hoc differs every time, and six
rebuild-reinstall cycles under a real certificate kept the Accessibility
grant with no System Settings interaction.

This buys permission persistence, not Gatekeeper approval — a
self-signed certificate isn't notarized, so downloads still need
System Settings -> Privacy & Security -> Open Anyway.
2026-08-05 13:23:09 +06:00
Claude Opus 5andbdeshi 5191a38f7a Fix the window tags: permission, drift, anchoring, and styling
Four separate defects, each reproduced before being fixed.

Accessibility was silently denied. AXIsProcessTrusted() returned false,
so every AX read failed, WindowOverlay.init? failed all twelve retries,
and attach gave up without a word — indistinguishable from the feature
being broken. The cause was outside this file: an ad-hoc-signed app has
its permission pinned to its exact cdhash, which changes on every build,
so the rebuild-and-replace workflow silently revoked the grant each time
while the app stayed listed and ticked in System Settings. Now it prompts
rather than failing mutely, and keeps rechecking — macOS never tells an
app it has just been granted Accessibility, and overlays were otherwise
only reconsidered when a Claude window launched, quit or activated, so a
grant made while running did nothing visible until the user happened to
touch a Claude window.

The tag walked off its window on every drag. windowDidMove re-derived and
persisted the corner offset for the app's own programmatic moves too, on
the assumption that recomputing an offset it had just positioned against
was a no-op. It isn't: AppKit posts that notification synchronously from
inside setFrame, and during a live drag the AX frame read there is
already newer than the one reposition used, so each event banked the few
points the window had moved in between. Observed saturating at 952,
which parks the tag at the far edge of the window or off-screen.

Placement was anchored to the top-right unconditionally, so any resize
moving that corner dragged the tag along. It now anchors to whichever
corner it was dropped nearest. Offsets are no longer written back
clamped either: a window too small to honour one shows the tag pushed in
as far as it fits but keeps the stored distance, so widening the window
restores the chosen spot.

Styling: thinner (13pt) and pill-shaped so it can sit close to an edge
without covering window controls; rotated when against a side edge,
reading bottom-to-top on the left and top-to-bottom on the right so the
text leans into the window; text colour chosen per profile colour by WCAG
relative luminance rather than fixed white, which was as low as ~1.1:1 on
a yellow chip against 4.50:1 worst-case now; and text drawn into a
one-line-tall rect centred on the chip's midline, fixing its high
seating. Settings gains a choice between the name chip and a plain
coloured dot, which is never rotated, having no reading direction.

Verified live throughout: a 400pt offset squeezed to 260 on a narrowed
window and returned to 400 on restore with the stored value untouched;
a drag committed the expected corner and orientation; and contrast was
measured across the hue wheel.

Tag placement is also stored per profile rather than once for the whole
app. Two windows side by side are the case these tags exist for, and
wanting each one's tag somewhere different is the normal outcome — one
window's sidebar is not another's. Previously a single shared offset made
them look independent (only the dragged tag moved at once) while every
other tag snapped to it on its next reposition.

A tag is a floating panel, which puts it above every ordinary window on
the system rather than merely above the window it labels — so it hovered
over the browser, the editor, and everything else, even when its own
window was buried or on another Space. There is no cross-process way to
attach one window above another (addChildWindow is same-process only, and
the private ordering call window managers use is a one-shot that goes
stale on the next reorder), so each tag now checks whether the window it
labels is genuinely visible beneath it. The window server returns its list
front-to-back, so one pass answers it: anything overlapping the tag before
we reach our own window is covering it, and never reaching that window
means it isn't on screen at all.

Tag position is also clamped into whatever part of the window is on
screen. A window dragged half off the edge takes its anchored corner with
it, and a tag that follows it out of view identifies nothing. The clamp is
presentational only — the stored anchor is untouched, so the tag returns
to it once the window is fully back.

Occlusion is judged from the window server's listing, which it returns
front-to-back. Our own window is identified by its owning process rather
than by matching rectangles: AX reports a new position the instant a
window moves while the listing still holds the previous one, so a
geometric match fails almost continuously mid-drag — measured at 87 of 92
frames, during which the walk ran past our own window and mistook
whatever else overlapped the tag for something covering it.

Only ordinary windows count as occluders. Everything above that band is
permanently in front and would veto the tag forever — including the
invisible one-pixel markers some utilities park in a screen corner, which
is what made a tag vanish at the bottom-left and nowhere else: it takes a
window moved off two edges at once for the tag to clamp into that pixel.
2026-08-05 13:08:11 +06:00
Claude Opus 5andbdeshi 1d3a539edd Add the window identification overlay
Every profile launches the same Claude Desktop binary with the same icon
and title, so with two or more open there was no way to tell which window
belonged to which profile at a glance. WindowOverlay pins a small tag —
profile name, in that profile's own dot colour — to each running managed
profile's window, tracked live via AXObserver notifications rather than
polling. Only a persistent process can hold a watcher like that, which is
exactly the gap the old one-shot CLI could never close.

Shown for every running managed profile, not just when two or more are
up: the tag is an identity marker, not merely a disambiguator. Never
shown on "default", which isn't a shannoncoat profile at all.

Positioned top-right, since top-left is where a window's close/minimize/
zoom controls live. Draggable in case that still obstructs something in a
particular layout, on a .nonactivatingPanel so dragging it doesn't steal
focus from the window underneath, with the offset persisted.

Attachment retries for ~3s rather than requiring the target's first
window to already exist: a cold Electron launch can take a second or more
between the process starting — which is what triggers the update, via
NSWorkspace's launch notification — and its first window existing. Losing
that race previously meant the tag silently never appeared at all.

Also ignores CHECKPOINT.md, a local session-handoff note.
2026-08-05 11:40:56 +06:00
Claude Opus 5andbdeshi 9673dc2792 Redesign the Manage window and fold About into it as a tab
The previous layout was a fixed 460x360 window regardless of tab or
state, with a plain unbordered table and text +/- buttons — it read as a
generic cross-platform port rather than something native. Several rows
also had no real width anchor at all, so they floated: flush against the
window's left edge with no margin, and in the add form's case wide enough
to overflow past the right edge.

- The window is no longer manually resizable. Each tab reports its own
  preferredContentSize, recomputed in viewDidLayout and after every state
  change, and NSTabViewController resizes the window to match. That is
  what actually fixes the large empty areas: a tab with less content gets
  a smaller window rather than sitting inside a fixed larger one.
- Every row now anchors its width to the one element with a real absolute
  width, fixing both the edge-touching and the overflow.
- Table gains a bezel border and alternating rows; form labels get a
  fixed trailing-aligned width so fields line up; the error label wraps
  instead of truncating; tab padding is consistent.
- About moves out of a separate panel into a third tab, with the app icon
  centred and enlarged to 96px.

Verified visually with a standalone harness that drives the real window
and screenshots it, rather than simulating input — scripting clicks into
the app itself needs an Accessibility grant this environment lacks.
2026-08-04 23:54:56 +06:00
Claude Opus 5andbdeshi c6a7da54ca Show profile paths, report the build version, and colour each profile
- Selecting a row in the Profiles tab shows its Claude Desktop and Claude
  Code dirs inline and selectable, instead of requiring a right-click
  "Reveal in Finder" to find out where a profile actually lives.
- build.sh stamps Contents/Resources/COMMIT with the short HEAD SHA, left
  empty when HEAD sits exactly on a tag (how a real release is built),
  since the version number alone is unambiguous there. About appends it
  when present, so two dev builds off the same VERSION are
  distinguishable. The update checker still compares plain semver.
- Profile dot colours are picked per NSApp.effectiveAppearance at render
  time: same hue either way, but full saturation with brightness split
  per background (1.0 dark, 0.55 light), so they read as bold hues rather
  than washing out on white or muddying against a dark menu.
2026-08-04 22:44:21 +06:00
Claude Opus 5andbdeshi 135db9b308 Rewrite shannoncoat as a native Swift menu-bar app
Replaces the original shannoncoat.sh entirely with in-process Swift. The
script stopped earning its keep once the rest went native, and being a
persistent process rather than a one-shot CLI is what later makes live
window tracking possible at all.

- ProfileStore: JSON config at ~/.shannoncoat/<name>.json (was hand-rolled
  YAML), with ~ expansion, paths relative to the config dir, and
  name/dir-collision validation in one place.
- ProcessInspector: sysctl(KERN_PROC_ALL/KERN_PROCARGS2) enumeration
  instead of shelling out to pgrep.
- ClaudeControl: launch via Process, focus via direct Accessibility calls
  (unhide, un-minimize, poll-for-window, AXRaise) instead of AppleScript,
  and a quit that respects Claude's own termination handling rather than
  unconditionally SIGKILLing after a flat 5s timeout.
- LiveState: NSWorkspace notification-driven state with no polling timer,
  so the menu bar reflects reality within about a second — including
  changes made outside the app entirely.
- ManageWindow: one non-modal window replacing what would otherwise be a
  string of separate popup alerts, with inline add/remove and directory
  pickers.
- LaunchAtLogin / UpdateChecker: SMAppService login-item toggle, and a
  minimal VERSION-file update check that alerts on a manual check and
  posts a quiet notification on the automatic one.

Also drops the standalone CLI in favour of GUI-only, and adds a
placeholder app icon drawn from vector shapes rather than composited on
top of Claude's own icon assets.

Claude Desktop is located at runtime rather than assumed: a path the user
picked previously, then /Applications, then a per-user ~/Applications
install, each accepted only if it actually contains the executable. If
none match, launching a profile asks the user to locate it once and
remembers the answer — a launch that silently does nothing gives them no
way to work out what's wrong.
2026-08-04 21:56:22 +06:00
bdeshi 815f0306c7 dump script 2026-08-04 20:32:14 +06:00
12 changed files with 96 additions and 556 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
# below treats as "fall back to ad-hoc".
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
@@ -139,7 +139,7 @@ jobs:
run: ditto -c -k --sequesterRsrc --keepParent ".build/shannoncoat.app" "shannoncoat.app.zip"
- name: Publish release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag || github.ref_name }}
files: |
+1 -7
View File
@@ -15,7 +15,7 @@ jobs:
tag: ${{ steps.tag.outputs.tag }}
created: ${{ steps.tag.outputs.created }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -45,11 +45,5 @@ jobs:
uses: ./.github/workflows/release.yml
with:
tag: ${{ needs.tag.outputs.tag }}
# A called workflow gets no secrets unless they're handed over, so
# without this the signing secrets are empty inside release.yml and it
# quietly builds ad-hoc — which is exactly what signing exists to
# prevent, and which its own ad-hoc guard misses, since that guard only
# fires when an identity was configured.
secrets: inherit
permissions:
contents: write
-150
View File
@@ -1,150 +0,0 @@
![shannoncoat — which coat will Claude wear today?](docs/hero.png)
Run multiple isolated [**\***](#isolation) Claude profiles on macOS — each one
its own Claude Desktop **and** its own paired Claude Code setup — and tell their
windows apart at a glance.
Your normal Claude install is left alone. It stays as it is and keeps working
normally; shannoncoat only adds profiles alongside it.
A menu-bar item allows quick switching and profile management.
---
> [!NOTE]
> shannoncoat started as the shell script in the first commit, written by hand.
Claude built the GUI app out of it — most of the Swift, the icon, and this
README — working from human direction, review, and dogfooding.
## Requirements
- macOS 13 or later
- Claude Desktop
## Install
1. Download `shannoncoat.app.zip` from the
[latest release](https://github.com/bdeshi/shannoncoat/releases/latest).
2. Unzip it and move **shannoncoat.app** to `/Applications` or `~/Applications`.
3. Open it. macOS will block the first launch, because the app isn't notarized.
Go to **System Settings → Privacy & Security**, find the blocked item, and
click **Open Anyway**.
4. When prompted, grant **Accessibility**. The window tags are drawn from
Accessibility data and won't appear without it.
## First run
With nothing running, shannoncoat offers a list of your profiles and starts
whichever you tick. You can start several at once.
## Everyday use
The menu bar shows a coloured dot per running profile — dot and name when only
one is up, dots alone when there are more (hover for the names).
Open the menu and:
- **Click a profile that isn't running** to switch to it. Every other running
profile is quit first, so you end up with just that one.
- **Click a profile that is already running** to bring its window forward,
leaving everything else where it is.
- **Shift-click a profile** to open it *alongside* whatever is already running.
A `⇆` marks the profile you last switched to. **Close Profile** quits one
profile; **Quit Claude** quits all of them and leaves shannoncoat running.
**Manage Profiles…** opens the main window.
Quitting Claude by hand, from the Dock, or via Force Quit is picked up within
about a second — the menu bar always reflects what is actually running.
## Window tags
Each managed Claude window carries a small tag in that profile's colour, so two
otherwise identical windows are easy to tell apart.
Drag a tag anywhere on its window; each profile remembers where you put its own.
If you'd rather have something smaller, **Settings → Window tag → Colored dot**
replaces the name chip with a plain dot.
## Profiles
**default** is your real, untouched Claude install. shannoncoat can start and
stop it but never manages it, so it gets no tag and can't be deleted.
To add a custom isolated profile, open **Manage Profiles… → +** and enter a
name. The directories are optional — leave them blank and the profile gets
`~/.shannoncoat/data/<name>/app` and `~/.shannoncoat/data/<name>/code`. Point
them anywhere you like if you'd rather keep a profile's data with a project.
Selecting a profile shows both paths, ready to copy.
Each profile is a small JSON file at `~/.shannoncoat/<name>.json`, named after
the profile. Two profiles may not share a directory — that would silently merge
their Claude sessions, so shannoncoat refuses to start and tells you which files
collide.
Deleting a profile removes only that pointer file. **Its Claude data stays on
disk**, so you can recreate the profile later and pick up where you left off. If
it's running, it's quit first.
## Settings
- **Launch at Login** — start shannoncoat automatically.
- **Window tag** — name chip or coloured dot.
- **Automatically check for updates** — once a day at most.
## Updates
shannoncoat compares its version against the one published on GitHub. The
automatic check posts a quiet notification; **Check for Updates…** in Settings
reports either way.
It never replaces itself. When a new version exists, download it from the
releases page and swap the app in yourself.
## Troubleshooting
**The window tags don't appear.** Almost always Accessibility. Check
**System Settings → Privacy & Security → Accessibility**.
If shannoncoat is listed *and already ticked* but the tags still don't show,
select it, remove it with **−**, then add it back. This happens after replacing
the app with a build that has a different code signature; the old entry stays
visible but no longer applies, and toggling it off and on won't fix it.
**Claude Desktop can't be found.** shannoncoat looks in `/Applications` and
`~/Applications`. If yours is somewhere else, you'll be asked to locate it the
first time you start a profile.
**A profile won't quit.** Claude was asked to quit and declined — usually its
own "unsaved work" or "still generating" dialog. shannoncoat deliberately waits
rather than killing Claude underneath it. Answer the dialog.
## Build from source
Local builds require the Xcode Command Line Tools.
```
xcode-select --install
```
Then run the build script
```bash
./build.sh
```
The app is written to `.build/shannoncoat.app`; copy it to `/Applications` or
`~/Applications`.
One thing worth setting up: an unsigned local build gets a new code identity
every time, which silently invalidates its Accessibility grant on each rebuild.
`.env.example` walks through creating a self-signed certificate once so the
permission survives.
## Isolation
> [!IMPORTANT]
> Perfect isolation is not guaranteed. Claude and Claude Code can have functionally
> full disk access regardless of which profile it's running from, including access
> to default configuration folders.
>
> Some Claude integrations — skills, plugins, etc — may also hardcode the
> `~/.claude` path which may modify content in there.
+12 -35
View File
@@ -15,13 +15,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
private var accessibilityPromptShown = false
private var accessibilityRecheckScheduled = false
private var activationObserver: NSObjectProtocol?
private var appearanceObserver: NSKeyValueObservation?
func applicationDidFinishLaunching(_ notification: Notification) {
menu.delegate = self
menu.autoenablesItems = false
statusItem.menu = menu
statusItem.button?.image = MenuBarIcon.image()
statusItem.button?.image = menuBarIcon()
statusItem.button?.imagePosition = .imageLeft
liveState.delegate = self
@@ -35,21 +34,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
activationObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main
) { [weak self] _ in self?.updateOverlayVisibility() }
// The glyph is a template image and re-tints itself, but the coloured
// dots beside it don't: ProfileColor picks a brightness per current
// appearance at the moment it's asked, and the title is only rebuilt
// when profiles start or stop. Without this, switching to dark mode
// leaves dots mixed for that appearance sitting in the menu bar until
// something unrelated happens to redraw them.
appearanceObserver = NSApp.observe(\.effectiveAppearance) { [weak self] _, _ in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.applyTitle(self.currentInfos)
ManageWindowController.shared.update(self.currentInfos)
}
}
liveState.reconcile()
// Launching this app fresh with nothing running means there's no
@@ -62,12 +46,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
UpdateChecker.checkAutomaticallyIfDue()
}
// The menu bar draws its own glyph (see MenuBarIcon) rather than the app
// icon scaled down, which is what it used to do: at 18pt the app icon's
// three coats collapse into a smudge, and its colours ignore the menu
// bar's appearance entirely. MenuBarIcon returns a template image, so
// macOS tints it for light/dark and for the open-menu inversion, and
// re-tints it by itself when the theme changes.
// Claude's own app icon (bundled as Contents/Resources/AppIcon.icns —
// currently a placeholder, see icon/generate-icon.swift), scaled down
// for the menu bar.
private func menuBarIcon() -> NSImage {
let source = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName) ?? NSImage()
let resized = NSImage(size: NSSize(width: 18, height: 18))
resized.lockFocus()
source.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18), from: .zero, operation: .sourceOver, fraction: 1.0)
resized.unlockFocus()
return resized
}
// MARK: - LiveStateDelegate
@@ -360,19 +349,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
}
}
// Switching means "make this the one that's running" — so it only has to
// quit anything when the profile isn't up yet. Clicking one that's
// already open is a request to look at it, not to tear down the window
// beside it: the menu shows every running profile at once, so the click
// that reaches for the second one is almost always someone moving
// between two open windows rather than asking for one of them to go.
// Quitting the neighbour there costs a relaunch and whatever was on
// screen, to carry out an instruction nobody gave.
private func switchTo(_ profile: ResolvedProfile) {
guard !currentInfos.contains(where: { $0.profile.name == profile.name && $0.running }) else {
openProfile(profile)
return
}
let others = currentInfos.filter { $0.running && $0.profile.name != profile.name }
guard !others.isEmpty else { openProfile(profile); return }
var remaining = others.count
+5 -22
View File
@@ -640,6 +640,7 @@ private final class SettingsViewController: NSViewController {
// MARK: - About tab
private let projectAuthor = "bdeshi"
private let projectHomepage = "https://github.com/bdeshi/shannoncoat"
private final class AboutViewController: NSViewController {
@@ -667,36 +668,19 @@ private final class AboutViewController: NSViewController {
icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
])
// Centred under the icon, same wrapper trick as the icon itself.
let tagline = NSTextField(labelWithString: "which coat will Claude wear today?")
tagline.font = .systemFont(ofSize: NSFont.systemFontSize)
tagline.textColor = .secondaryLabelColor
tagline.translatesAutoresizingMaskIntoConstraints = false
let taglineContainer = NSView()
taglineContainer.translatesAutoresizingMaskIntoConstraints = false
taglineContainer.addSubview(tagline)
NSLayoutConstraint.activate([
tagline.topAnchor.constraint(equalTo: taglineContainer.topAnchor),
tagline.bottomAnchor.constraint(equalTo: taglineContainer.bottomAnchor),
tagline.centerXAnchor.constraint(equalTo: taglineContainer.centerXAnchor),
])
// Names itself rather than opening with a bare verb — this panel is
// reachable without the window title in view.
let description = NSTextField(wrappingLabelWithString:
"shannoncoat runs multiple Claude Desktop profiles side by side, each paired with its own "
+ "isolated Claude Code config.")
"Runs multiple Claude Desktop profiles side by side, each paired with its own isolated Claude Code config.")
description.font = .systemFont(ofSize: NSFont.systemFontSize)
let authorField = NSTextField(labelWithString: "Author: \(projectAuthor)")
let sourceField = NSTextField(labelWithString: "Source: \(projectHomepage)")
let versionField = NSTextField(labelWithString: "Version: \(UpdateChecker.displayVersion)")
for field in [sourceField, versionField] {
for field in [authorField, sourceField, versionField] {
field.textColor = .secondaryLabelColor
field.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
}
let stack = NSStackView(views: [iconContainer, taglineContainer, description,
sourceField, versionField])
let stack = NSStackView(views: [iconContainer, description, authorField, sourceField, versionField])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 10
@@ -710,7 +694,6 @@ private final class AboutViewController: NSViewController {
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
description.widthAnchor.constraint(equalToConstant: Self.contentWidth),
iconContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
taglineContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
])
view.layoutSubtreeIfNeeded()
-82
View File
@@ -1,82 +0,0 @@
// The menu bar's own glyph — Claude's asterisk over a coat's collar and
// shoulders. Drawn here rather than scaled down from the app icon, which is
// what the menu bar used to show: at 18pt the app icon's three coats, lapels
// and pockets collapse into a smudge, and its colours fight the menu bar
// instead of following it.
//
// Returned as a *template* image, which is what makes it track the system
// theme with no code of ours involved. A template carries only an alpha
// channel; macOS supplies the colour — dark on a light menu bar, light on a
// dark one, inverted again while the menu is open — and re-tints it on the fly
// when the theme switches. That also dictates the drawing: no fills of our
// own, and any internal detail has to be a hole punched through the alpha.
import AppKit
enum MenuBarIcon {
// Six rays rather than the app icon's eight. At 18pt an eight-ray asterisk
// has under a pixel of gap between arms, so they merge into a blob; six
// resolve as separate arms. Lengths stay slightly uneven, as on the app
// icon, so it doesn't read as a snowflake.
private static let rayCount = 6
private static let rayLengths: [CGFloat] = [1.0, 0.88, 0.97, 0.86, 1.0, 0.9]
private static let rayRadius: CGFloat = 22
private static let rayWidth: CGFloat = 7.2
private static let asteriskCentre = NSPoint(x: 50, y: 28)
static func image(pointSize: CGFloat = 18) -> NSImage {
let image = NSImage(size: NSSize(width: pointSize, height: pointSize))
image.lockFocusFlipped(false)
// Authored y-down like the app icon; flip once rather than mirroring
// every coordinate.
let flip = NSAffineTransform()
flip.translateX(by: 0, yBy: pointSize)
flip.scaleX(by: pointSize / 100, yBy: -pointSize / 100)
flip.concat()
NSColor.black.setStroke()
asterisk().stroke()
NSColor.black.setFill()
coat().fill()
image.unlockFocus()
image.isTemplate = true
return image
}
private static func asterisk() -> NSBezierPath {
let path = NSBezierPath()
for index in 0..<rayCount {
let angle = (CGFloat(index) * 360 / CGFloat(rayCount) + 8) * .pi / 180
let radius = rayRadius * rayLengths[index]
path.move(to: asteriskCentre)
path.line(to: NSPoint(x: asteriskCentre.x + radius * cos(angle),
y: asteriskCentre.y + radius * sin(angle)))
}
path.lineWidth = rayWidth
path.lineCapStyle = .round
return path
}
// Read along the top edge from one side to the other: shoulder, a slight
// tilt up, a flat collar top, the valley at the neck, then the mirror of
// that. Square sides and a flat hem below.
//
// The flat sections are 13 units wide because anything narrower stops
// reading as flat at this size and just looks like part of the slope —
// 13 units is roughly 2.3px at 18pt, about the floor for that.
private static func coat() -> NSBezierPath {
let path = NSBezierPath()
path.move(to: NSPoint(x: 15, y: 72)) // left shoulder
path.line(to: NSPoint(x: 27, y: 60)) // slight tilt up
path.line(to: NSPoint(x: 40, y: 60)) // flat collar top
path.line(to: NSPoint(x: 50, y: 78)) // valley at the neck
path.line(to: NSPoint(x: 60, y: 60))
path.line(to: NSPoint(x: 73, y: 60))
path.line(to: NSPoint(x: 85, y: 72)) // right shoulder
path.line(to: NSPoint(x: 85, y: 94))
path.line(to: NSPoint(x: 15, y: 94))
path.close()
return path
}
}
+1 -1
View File
@@ -1 +1 @@
1.0.0
0.0.8
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

BIN
View File
Binary file not shown.
-83
View File
@@ -1,83 +0,0 @@
// 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)")
+75 -174
View File
@@ -1,11 +1,8 @@
// 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.
// 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.
//
// Usage:
// swift icon/generate-icon.swift icon/AppIcon.iconset
@@ -13,182 +10,85 @@
// 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.
private func renderIcon(pixels: Int) -> Data {
let side = CGFloat(pixels)
func renderIcon(pixels: Int) -> Data {
let s = 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: side, height: side)
rep.size = NSSize(width: s, height: s)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
NSGraphicsContext.current?.imageInterpolation = .high
let ctx = NSGraphicsContext(bitmapImageRep: rep)
NSGraphicsContext.current = ctx
ctx?.imageInterpolation = .high
// 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()
// 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()
drawIcon()
// 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
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 {
@@ -208,7 +108,8 @@ 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? renderIcon(pixels: px).write(to: URL(fileURLWithPath: path))
try? data.write(to: URL(fileURLWithPath: path))
print("wrote \(path)")
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB