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