diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh
index 4816878..31c0ffc 100755
--- a/apps/headless/Tests/macos-e2e.sh
+++ b/apps/headless/Tests/macos-e2e.sh
@@ -2,7 +2,7 @@
set -euo pipefail
cd "${0:a:h}/.."
-for tool in node curl defaults lsof osascript perl; do
+for tool in node curl defaults lsof osascript perl swift; do
command -v "$tool" >/dev/null 2>&1 || { echo "macOS E2E tests require $tool" >&2; exit 69; }
done
@@ -197,6 +197,75 @@ end run
APPLESCRIPT
}
+ax_address_field_count() {
+ local pid="$1"
+ osascript_with_timeout - "$pid" <<'APPLESCRIPT'
+on run argv
+ set targetPID to item 1 of argv as integer
+ tell application "System Events"
+ set targetProcesses to every application process whose unix id is targetPID
+ if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found"
+ tell item 1 of targetProcesses
+ if (count of windows) is 0 then error "Headless has no accessible windows"
+ return count of text fields of front window
+ end tell
+ end tell
+end run
+APPLESCRIPT
+}
+
+ax_click_front_window_content() {
+ local pid="$1"
+ local coordinates x y
+ coordinates="$(osascript_with_timeout - "$pid" <<'APPLESCRIPT'
+on run argv
+ set targetPID to item 1 of argv as integer
+ tell application "System Events"
+ set targetProcesses to every application process whose unix id is targetPID
+ if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found"
+ tell item 1 of targetProcesses
+ set frontmost to true
+ if (count of windows) is 0 then error "Headless has no accessible windows"
+ set windowPosition to position of front window
+ set windowSize to size of front window
+ return {(item 1 of windowPosition) + 10, (item 2 of windowPosition) + (item 2 of windowSize) - 10}
+ end tell
+ end tell
+end run
+APPLESCRIPT
+ )"
+ x="$(printf '%s' "$coordinates" | cut -d, -f1 | tr -d ' ')"
+ y="$(printf '%s' "$coordinates" | cut -d, -f2 | tr -d ' ')"
+ swift - "$x" "$y" <<'SWIFT'
+import CoreGraphics
+import Foundation
+
+guard CommandLine.arguments.count == 3,
+ let x = Double(CommandLine.arguments[1]),
+ let y = Double(CommandLine.arguments[2]) else {
+ fputs("invalid click coordinates\n", stderr)
+ exit(64)
+}
+let point = CGPoint(
+ x: x,
+ y: y
+)
+CGEvent(
+ mouseEventSource: nil,
+ mouseType: .leftMouseDown,
+ mouseCursorPosition: point,
+ mouseButton: .left
+)?.post(tap: .cghidEventTap)
+usleep(50_000)
+CGEvent(
+ mouseEventSource: nil,
+ mouseType: .leftMouseUp,
+ mouseCursorPosition: point,
+ mouseButton: .left
+)?.post(tap: .cghidEventTap)
+SWIFT
+}
+
ax_named_window_count() {
local pid="$1" window_title="$2"
osascript_with_timeout - "$pid" "$window_title" <<'APPLESCRIPT'
@@ -735,6 +804,99 @@ if [[ "$(frontmost_pid)" == "$HOST_PID" ]]; then
fail
fi
+STEP="start-page-themes"
+for _ in {1..100}; do
+ NORMAL_START_PAGE="$("$CLI" inspect --text 2>/dev/null || true)"
+ echo "$NORMAL_START_PAGE" | grep -q "QUICK CONTROLS" && break
+ sleep 0.05
+done
+echo "$NORMAL_START_PAGE" | grep -q "QUICK CONTROLS"
+if echo "$NORMAL_START_PAGE" | grep -q "the browser that isn"; then
+ echo "normal start page retained the removed tagline" >&2
+ fail
+fi
+if echo "$NORMAL_START_PAGE" | grep -q "PRIVATE SESSION"; then
+ echo "normal start page was marked private" >&2
+ fail
+fi
+"$CLI" session create start-page-private --isolated | grep -q '"isolated":true'
+for _ in {1..100}; do
+ PRIVATE_START_PAGE="$("$CLI" --session start-page-private inspect --text 2>/dev/null || true)"
+ echo "$PRIVATE_START_PAGE" | grep -q "PRIVATE SESSION" && break
+ sleep 0.05
+done
+echo "$PRIVATE_START_PAGE" | grep -q "Private browsing data is erased when this session closes."
+if echo "$PRIVATE_START_PAGE" | grep -q "the browser that isn"; then
+ echo "private start page inherited the normal tagline" >&2
+ fail
+fi
+"$CLI" session close start-page-private | grep -q '"closed":"start-page-private"'
+
+STEP="start-page-address-hud"
+ax_press_menu_item "$HOST_PID" File "Open Location…"
+for _ in {1..100}; do
+ [[ "$(ax_address_field_count "$HOST_PID" 2>/dev/null)" == 1 ]] && break
+ sleep 0.05
+done
+if [[ "$(ax_address_field_count "$HOST_PID")" != 1 ]]; then
+ echo "start page address field did not open" >&2
+ fail
+fi
+ax_click_front_window_content "$HOST_PID"
+sleep 0.2
+if [[ "$(ax_address_field_count "$HOST_PID")" != 1 ]]; then
+ echo "start page click dismissed the address field" >&2
+ fail
+fi
+ax_keystroke "$HOST_PID" l command
+ax_escape "$HOST_PID"
+for _ in {1..100}; do
+ [[ "$(ax_address_field_count "$HOST_PID" 2>/dev/null)" == 0 ]] && break
+ sleep 0.05
+done
+if [[ "$(ax_address_field_count "$HOST_PID")" != 0 ]]; then
+ echo "Escape did not dismiss the focused start page address field" >&2
+ fail
+fi
+
+STEP="website-address-hud"
+"$CLI" visit "http://127.0.0.1:$PORT/designers/dashboard" | grep -q 'Designers Dashboard'
+ax_press_menu_item "$HOST_PID" File "Open Location…"
+for _ in {1..100}; do
+ [[ "$(ax_address_field_count "$HOST_PID" 2>/dev/null)" == 1 ]] && break
+ sleep 0.05
+done
+if [[ "$(ax_address_field_count "$HOST_PID")" != 1 ]]; then
+ echo "website address field did not open" >&2
+ fail
+fi
+ax_escape "$HOST_PID"
+ax_press_menu_item "$HOST_PID" File "Open Location…"
+for _ in {1..100}; do
+ [[ "$(ax_address_field_count "$HOST_PID" 2>/dev/null)" == 1 ]] && break
+ sleep 0.05
+done
+if [[ "$(ax_address_field_count "$HOST_PID")" != 1 ]]; then
+ echo "website address field did not reopen after dismissal" >&2
+ fail
+fi
+ax_click_front_window_content "$HOST_PID"
+for _ in {1..100}; do
+ [[ "$(ax_address_field_count "$HOST_PID" 2>/dev/null)" == 0 ]] && break
+ sleep 0.05
+done
+if [[ "$(ax_address_field_count "$HOST_PID")" != 0 ]]; then
+ echo "website click did not dismiss the address field" >&2
+ fail
+fi
+ax_escape "$HOST_PID"
+for _ in {1..100}; do
+ NORMAL_START_PAGE="$("$CLI" inspect --text 2>/dev/null || true)"
+ echo "$NORMAL_START_PAGE" | grep -q "QUICK CONTROLS" && break
+ sleep 0.05
+done
+echo "$NORMAL_START_PAGE" | grep -q "QUICK CONTROLS"
+
STEP="settings-window-workflow"
ax_press_menu_item "$HOST_PID" Headless "Settings…"
for _ in {1..100}; do
diff --git a/apps/headless/build.sh b/apps/headless/build.sh
index e1182ea..14a2395 100755
--- a/apps/headless/build.sh
+++ b/apps/headless/build.sh
@@ -81,7 +81,7 @@ fi
export CLANG_MODULE_CACHE_PATH="$PWD/build/module-cache"
export SWIFTPM_MODULECACHE_OVERRIDE="$PWD/build/swiftpm-module-cache"
-if [[ ! -f "$ICON" ]]; then
+if [[ ! -f "$ICON" || tools/make-icon.swift -nt "$ICON" ]]; then
echo "▸ rendering icon"
rm -rf build/AppIcon.iconset
mkdir -p build
diff --git a/apps/headless/main.swift b/apps/headless/main.swift
index 69bfa70..c16e3a5 100644
--- a/apps/headless/main.swift
+++ b/apps/headless/main.swift
@@ -144,43 +144,86 @@ let launchOptions = parseLaunchOptions()
// MARK: - Start page
-let startPageHTML = """
-
-
headless
-
-
- headless
- the browser that isn’t there
-
-
⌘ L
search or enter a url
-
⌘ drag
move the window
-
⌃⌘ F
fullscreen
-
⇧⌘ S
snapshot the page → desktop
-
⌥⌘ P
pin on top of every window
-
⌘ [ ⌘ ]
back / forward
-
esc
bail out — back to this page
-
⌘ = ⌘ − ⌘ 0
zoom
-
⇧⌘ C
copy current url
-
-
-
-"""
+func startPageHTML(isIsolated: Bool) -> String {
+ let top = isIsolated ? "#2C2C2C" : "#2C5745"
+ let middle = isIsolated ? "#A3A2A2" : "#0F3040"
+ let bottom = isIsolated ? "#2C2C2C" : "#853953"
+ let panel = isIsolated ? "rgba(26, 26, 26, .82)" : "rgba(8, 33, 43, .78)"
+ let mode = isIsolated
+ ? "PRIVATE SESSION
"
+ : ""
+ let privacyNote = isIsolated
+ ? "Private browsing data is erased when this session closes.
"
+ : ""
+
+ return """
+
+ headless
+
+
+
+
+
+ \(mode)
+ QUICK CONTROLS
+ \(privacyNote)
+
+
⌘ L
search or enter a url
+
⌘ drag
move the window
+
⌃⌘ F
fullscreen
+
⇧⌘ S
snapshot the page → desktop
+
⌥⌘ P
pin on top of every window
+
⌘ [ ⌘ ]
back / forward
+
esc
bail out, back to this page
+
⌘ = ⌘ − ⌘ 0
zoom
+
⇧⌘ C
copy current url
+
+
+
+
+ """
+}
// MARK: - Views
@@ -232,8 +275,10 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate,
let qaBridge: WebKitQABridge
let isIsolatedSession: Bool
private let progressBar = NSView()
+ private let hudBackdrop = NSVisualEffectView()
private let hud = NSVisualEffectView()
private let hudField = NSTextField()
+ private var hudTransitionGeneration: UInt = 0
private let toastView = NSVisualEffectView()
private let toastLabel = NSTextField(labelWithString: "")
private var observations: [NSKeyValueObservation] = []
@@ -415,15 +460,19 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate,
private func installMouseMonitor() {
mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDown]) { [weak self] event in
- guard let self, event.window === self.window else { return event }
+ guard let self, let window = self.window else { return event }
if event.type == .mouseMoved {
- // Reveal the traffic lights only when hovering the top-left corner.
- guard let contentView = self.window?.contentView else { return event }
+ guard event.window === window, let contentView = window.contentView else { return event }
let p = event.locationInWindow
let nearCorner = p.y > contentView.bounds.height - 44 && p.x < 96
self.setTrafficLights(visible: self.isFullScreen || nearCorner)
- } else if !self.hud.isHidden {
- let p = self.window!.contentView!.convert(event.locationInWindow, from: nil)
+ } else if !self.hud.isHidden, !self.onStartPage, window.isKeyWindow,
+ let contentView = window.contentView {
+ // Accessibility-generated clicks can arrive without an attached window.
+ let windowPoint = event.window === window
+ ? event.locationInWindow
+ : window.convertPoint(fromScreen: NSEvent.mouseLocation)
+ let p = contentView.convert(windowPoint, from: nil)
if !self.hud.frame.contains(p) { self.hideHUD() }
}
return event
@@ -436,6 +485,15 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate,
progressBar.alphaValue = 0
container.addSubview(progressBar)
+ hudBackdrop.material = .hudWindow
+ hudBackdrop.blendingMode = .withinWindow
+ hudBackdrop.state = .active
+ hudBackdrop.wantsLayer = true
+ hudBackdrop.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.20).cgColor
+ hudBackdrop.isHidden = true
+ hudBackdrop.alphaValue = 0
+ container.addSubview(hudBackdrop)
+
hud.material = .hudWindow
hud.blendingMode = .withinWindow
hud.state = .active
@@ -452,8 +510,12 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate,
hudField.drawsBackground = false
hudField.focusRingType = .none
hudField.font = .systemFont(ofSize: 16)
- hudField.textColor = .labelColor
- hudField.placeholderString = "Search or enter address"
+ let addressTextColor = NSColor(calibratedWhite: 0.98, alpha: 1)
+ hudField.textColor = addressTextColor
+ hudField.placeholderAttributedString = NSAttributedString(
+ string: "Search or enter address",
+ attributes: [.foregroundColor: addressTextColor]
+ )
hudField.usesSingleLineMode = true
hudField.cell?.isScrollable = true
hudField.cell?.wraps = false
@@ -479,6 +541,7 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate,
private func layoutOverlays() {
guard let contentView = window?.contentView else { return }
let b = contentView.bounds
+ hudBackdrop.frame = b
let hudW = min(620, max(280, b.width - 48))
let hudH: CGFloat = 52
hud.frame = NSRect(x: (b.width - hudW) / 2, y: b.height - hudH - 84, width: hudW, height: hudH)
@@ -574,7 +637,9 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate,
func loadStartPage() {
pendingRestoredStartupURL = nil
onStartPage = true
- webView.loadHTMLString(startPageHTML, baseURL: nil)
+ hudBackdrop.alphaValue = 0
+ hudBackdrop.isHidden = true
+ webView.loadHTMLString(startPageHTML(isIsolated: isIsolatedSession), baseURL: nil)
}
private func escapeToStart() -> Bool {
@@ -586,26 +651,36 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate,
// MARK: HUD (the ⌘L address bar)
func showHUD() {
+ hudTransitionGeneration &+= 1
if let u = webView.url, !onStartPage, u.absoluteString != "about:blank" {
hudField.stringValue = u.absoluteString
} else {
hudField.stringValue = ""
}
+ let showsBackdrop = !onStartPage
+ hudBackdrop.isHidden = !showsBackdrop
+ hudBackdrop.alphaValue = 0
hud.isHidden = false
layoutOverlays()
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.15
+ if showsBackdrop { hudBackdrop.animator().alphaValue = 0.40 }
hud.animator().alphaValue = 1
}
hudField.selectText(nil)
}
func hideHUD() {
+ hudTransitionGeneration &+= 1
+ let transitionGeneration = hudTransitionGeneration
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.15
+ self.hudBackdrop.animator().alphaValue = 0
self.hud.animator().alphaValue = 0
}, completionHandler: { [weak self] in
guard let self else { return }
+ guard self.hudTransitionGeneration == transitionGeneration else { return }
+ self.hudBackdrop.isHidden = true
self.hud.isHidden = true
self.window?.makeFirstResponder(self.webView)
})
diff --git a/apps/headless/tools/make-icon.swift b/apps/headless/tools/make-icon.swift
index d0ec59f..cc8eae7 100644
--- a/apps/headless/tools/make-icon.swift
+++ b/apps/headless/tools/make-icon.swift
@@ -1,4 +1,4 @@
-// Renders the Headless app icon: viewfinder corners on a dark squircle.
+// Renders the full-bleed Headless app icon: a cream H over the product's wave palette.
// Usage: swift tools/make-icon.swift
import Cocoa
@@ -6,54 +6,204 @@ import Cocoa
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "AppIcon.iconset"
try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
+private let artFrame = NSRect(x: 0, y: 0, width: 1024, height: 1024)
+private let sourceSize: CGFloat = 512
+
+private func color(_ hex: UInt32) -> NSColor {
+ NSColor(
+ srgbRed: CGFloat((hex >> 16) & 0xff) / 255,
+ green: CGFloat((hex >> 8) & 0xff) / 255,
+ blue: CGFloat(hex & 0xff) / 255,
+ alpha: 1
+ )
+}
+
+private func point(_ x: CGFloat, _ y: CGFloat) -> NSPoint {
+ let scale = artFrame.width / sourceSize
+ return NSPoint(x: artFrame.minX + x * scale, y: artFrame.maxY - y * scale)
+}
+
+private func drawWaveArtwork() {
+ NSGraphicsContext.saveGraphicsState()
+ defer { NSGraphicsContext.restoreGraphicsState() }
+
+ color(0x853953).setFill()
+ artFrame.fill()
+
+ let middle = NSBezierPath()
+ middle.move(to: point(0, 184))
+ middle.line(to: point(67, 199))
+ middle.curve(
+ to: point(108, 190),
+ controlPoint1: point(84, 202),
+ controlPoint2: point(92, 199)
+ )
+ middle.line(to: point(233, 116))
+ middle.curve(
+ to: point(279, 120),
+ controlPoint1: point(249, 108),
+ controlPoint2: point(263, 111)
+ )
+ middle.line(to: point(410, 199))
+ middle.curve(
+ to: point(440, 196),
+ controlPoint1: point(420, 203),
+ controlPoint2: point(428, 200)
+ )
+ middle.line(to: point(512, 182))
+ middle.line(to: point(512, 365))
+ middle.line(to: point(439, 377))
+ middle.curve(
+ to: point(407, 373),
+ controlPoint1: point(425, 380),
+ controlPoint2: point(417, 378)
+ )
+ middle.line(to: point(279, 326))
+ middle.curve(
+ to: point(234, 326),
+ controlPoint1: point(260, 318),
+ controlPoint2: point(251, 318)
+ )
+ middle.line(to: point(105, 374))
+ middle.curve(
+ to: point(68, 377),
+ controlPoint1: point(92, 379),
+ controlPoint2: point(83, 380)
+ )
+ middle.line(to: point(0, 365))
+ middle.close()
+ color(0x0F3040).setFill()
+ middle.fill()
+
+ let top = NSBezierPath()
+ top.move(to: point(0, 0))
+ top.line(to: point(512, 0))
+ top.line(to: point(512, 182))
+ top.line(to: point(440, 196))
+ top.curve(
+ to: point(410, 199),
+ controlPoint1: point(428, 200),
+ controlPoint2: point(420, 203)
+ )
+ top.line(to: point(279, 120))
+ top.curve(
+ to: point(233, 116),
+ controlPoint1: point(263, 111),
+ controlPoint2: point(249, 108)
+ )
+ top.line(to: point(108, 190))
+ top.curve(
+ to: point(67, 199),
+ controlPoint1: point(92, 199),
+ controlPoint2: point(84, 202)
+ )
+ top.line(to: point(0, 184))
+ top.close()
+ color(0x2C5745).setFill()
+ top.fill()
+
+ let monogram = NSBezierPath()
+ monogram.move(to: point(81, 63))
+ monogram.curve(
+ to: point(168, 95),
+ controlPoint1: point(114, 63),
+ controlPoint2: point(145, 72)
+ )
+ monogram.curve(
+ to: point(181, 158),
+ controlPoint1: point(180, 105),
+ controlPoint2: point(178, 131)
+ )
+ monogram.curve(
+ to: point(244, 228),
+ controlPoint1: point(185, 199),
+ controlPoint2: point(207, 226)
+ )
+ monogram.curve(
+ to: point(318, 157),
+ controlPoint1: point(282, 230),
+ controlPoint2: point(309, 198)
+ )
+ monogram.line(to: point(319, 63))
+ monogram.curve(
+ to: point(409, 94),
+ controlPoint1: point(357, 62),
+ controlPoint2: point(388, 71)
+ )
+ monogram.curve(
+ to: point(418, 145),
+ controlPoint1: point(418, 104),
+ controlPoint2: point(418, 124)
+ )
+ monogram.line(to: point(418, 382))
+ monogram.curve(
+ to: point(426, 455),
+ controlPoint1: point(418, 410),
+ controlPoint2: point(420, 438)
+ )
+ monogram.curve(
+ to: point(329, 460),
+ controlPoint1: point(391, 474),
+ controlPoint2: point(350, 479)
+ )
+ monogram.curve(
+ to: point(320, 389),
+ controlPoint1: point(317, 449),
+ controlPoint2: point(320, 420)
+ )
+ monogram.curve(
+ to: point(251, 321),
+ controlPoint1: point(320, 351),
+ controlPoint2: point(291, 321)
+ )
+ monogram.curve(
+ to: point(180, 389),
+ controlPoint1: point(211, 321),
+ controlPoint2: point(180, 350)
+ )
+ monogram.curve(
+ to: point(181, 454),
+ controlPoint1: point(180, 420),
+ controlPoint2: point(185, 448)
+ )
+ monogram.curve(
+ to: point(120, 473),
+ controlPoint1: point(159, 468),
+ controlPoint2: point(139, 476)
+ )
+ monogram.curve(
+ to: point(82, 415),
+ controlPoint1: point(94, 469),
+ controlPoint2: point(82, 445)
+ )
+ monogram.line(to: point(81, 63))
+ monogram.close()
+ color(0xFBF7EF).setFill()
+ monogram.fill()
+}
+
func render(_ px: Int) -> Data {
- let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: px, pixelsHigh: px,
- bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true,
- isPlanar: false, colorSpaceName: .deviceRGB,
- bytesPerRow: 0, bitsPerPixel: 0)!
- let ctx = NSGraphicsContext(bitmapImageRep: rep)!
+ let rep = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: px,
+ pixelsHigh: px,
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ )!
+ let context = NSGraphicsContext(bitmapImageRep: rep)!
NSGraphicsContext.saveGraphicsState()
- NSGraphicsContext.current = ctx
+ NSGraphicsContext.current = context
+ context.shouldAntialias = true
let scale = NSAffineTransform()
- scale.scale(by: CGFloat(px) / 1024.0)
+ scale.scale(by: CGFloat(px) / 1024)
scale.concat()
-
- // Dark squircle canvas (standard macOS icon grid: 824pt centered on 1024).
- let squircle = NSBezierPath(roundedRect: NSRect(x: 100, y: 100, width: 824, height: 824),
- xRadius: 186, yRadius: 186)
- NSGradient(starting: NSColor(calibratedRed: 0.11, green: 0.11, blue: 0.14, alpha: 1),
- ending: NSColor(calibratedRed: 0.02, green: 0.02, blue: 0.04, alpha: 1))!
- .draw(in: squircle, angle: -90)
- NSColor.white.withAlphaComponent(0.07).setStroke()
- squircle.lineWidth = 6
- squircle.stroke()
-
- // Viewfinder corners — a frame with no chrome.
- let box = NSRect(x: 302, y: 302, width: 420, height: 420)
- let arm: CGFloat = 126
- let p = NSBezierPath()
- p.lineWidth = 46
- p.lineCapStyle = .round
- p.lineJoinStyle = .round
- // top-left
- p.move(to: NSPoint(x: box.minX, y: box.maxY - arm))
- p.line(to: NSPoint(x: box.minX, y: box.maxY))
- p.line(to: NSPoint(x: box.minX + arm, y: box.maxY))
- // top-right
- p.move(to: NSPoint(x: box.maxX - arm, y: box.maxY))
- p.line(to: NSPoint(x: box.maxX, y: box.maxY))
- p.line(to: NSPoint(x: box.maxX, y: box.maxY - arm))
- // bottom-right
- p.move(to: NSPoint(x: box.maxX, y: box.minY + arm))
- p.line(to: NSPoint(x: box.maxX, y: box.minY))
- p.line(to: NSPoint(x: box.maxX - arm, y: box.minY))
- // bottom-left
- p.move(to: NSPoint(x: box.minX + arm, y: box.minY))
- p.line(to: NSPoint(x: box.minX, y: box.minY))
- p.line(to: NSPoint(x: box.minX, y: box.minY + arm))
- NSColor(calibratedWhite: 0.96, alpha: 1).setStroke()
- p.stroke()
+ drawWaveArtwork()
NSGraphicsContext.restoreGraphicsState()
return rep.representation(using: .png, properties: [:])!
diff --git a/docs/brand.md b/docs/brand.md
index 1fab46e..2534755 100644
--- a/docs/brand.md
+++ b/docs/brand.md
@@ -1,8 +1,9 @@
# Brand assets
-The mark is a **viewfinder** — four corner brackets around empty space. It says
-what the product is: a frame with no chrome, pointed at a page. Everything below
-is the same geometry at different sizes, so there is one shape to keep right.
+The core mark is a **viewfinder**: four corner brackets around empty space. It
+says what the product is: a frame with no chrome, pointed at a page. The web
+assets below use the same geometry at different sizes. The macOS app icon is an
+intentional alternate treatment built from the cream H and product wave palette.
Canonical path, on a 64-unit grid:
@@ -11,8 +12,7 @@ M18.875 26.75V18.875H26.75 M37.25 18.875H45.125V26.75
M45.125 37.25V45.125H37.25 M26.75 45.125H18.875V37.25
```
-Stroke width `2.875`, round caps and joins. On the 1024-unit macOS icon grid the
-same shape is a 420pt box inset at 302pt with 126pt arms and a 46pt stroke.
+Stroke width `2.875`, round caps and joins.
## Where each asset lives
@@ -23,20 +23,31 @@ same shape is a 420pt box inset at 302pt with 126pt arms and a 46pt stroke.
| Apple touch icon | `apps/web/app/apple-icon.tsx` | Generated at build time. iOS applies its own corner mask, so the canvas is square. |
| Social card | `apps/web/app/opengraph-image.tsx` | 1200×630, used for `og:image` and Twitter cards. |
| GitHub avatar and preview | `apps/web/scripts/render-brand.mjs` | `pnpm --filter @headless/web brand` → `apps/web/build/brand/` (gitignored). |
-| macOS app icon | `apps/headless/tools/make-icon.swift` | Renders the `.icns` during `build.sh`. |
+| macOS app icon | `apps/headless/tools/make-icon.swift` | Renders the cream H and wave artwork into the `.icns` during `build.sh`. |
Raster images are **generated, never committed** — the repository media policy
allows binaries only under `docs/qa/evidence/`. Every raster asset above comes
from code, so it can be regenerated at any size without a design tool.
+## App artwork
+
+The macOS app icon uses a cream H over the same three wave bands as the normal
+start page. The artwork fills the complete square canvas without transparent
+padding, and the renderer generates every required iconset size. `build.sh`
+regenerates the `.icns` whenever the renderer changes.
+
+The normal start page palette is `#2C5745`, `#0F3040`, and `#853953`, from top
+to bottom. Isolated sessions use `#2C2C2C`, `#A3A2A2`, and `#2C2C2C` and carry
+an explicit private-session label. Both backgrounds are inline vector artwork;
+no raster or externally loaded page asset is committed.
+
## Why the tab icon is not the bare mark
`public/headless-mark.svg` is stroke-only and picks its colour from a
`prefers-color-scheme` block inside the file. Browsers do not reliably
re-evaluate that for tab icons, so the near-black stroke can disappear against a
-dark tab strip. `app/icon.svg` puts the same mark on the dark squircle the macOS
-app icon already uses, which reads on any surface and keeps the two platforms
-consistent.
+dark tab strip. `app/icon.svg` puts the same mark on a dark squircle, which reads
+on any surface while preserving the core viewfinder mark.
## Drawing the brackets in generated images