diff --git a/.gitignore b/.gitignore index d1b5921..f80ed02 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ build/ .DS_Store Thumbs.db +# Claude Code auto-memory (user-local) +.claude/ + # Testing .pytest_cache/ .coverage diff --git a/CrisisViz/.gitignore b/CrisisViz/.gitignore index 7cb512f..6b5ed04 100644 --- a/CrisisViz/.gitignore +++ b/CrisisViz/.gitignore @@ -4,3 +4,8 @@ Package.resolved *.xcodeproj DerivedData/ xcuserdata/ + +# Build artifacts produced by bundle.sh +CrisisViz.app/ +AppIcon.iconset/ +AppIcon.icns diff --git a/CrisisViz/Sources/CrisisViz/App/CrisisApp.swift b/CrisisViz/Sources/CrisisViz/App/CrisisApp.swift index 4954c24..c29da1c 100644 --- a/CrisisViz/Sources/CrisisViz/App/CrisisApp.swift +++ b/CrisisViz/Sources/CrisisViz/App/CrisisApp.swift @@ -1,12 +1,31 @@ import SwiftUI +import AppKit + +/// AppDelegate exists for one reason: when CrisisViz is launched unbundled +/// (e.g. via `swift run CrisisViz` during development) macOS defaults the +/// activation policy to `.accessory`, which means no Dock tile, no menu-bar +/// presence, and no Cmd-Tab visibility. Forcing `.regular` here makes the +/// running app behave like a real native app even without a `.app` bundle. +final class CrisisAppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } +} @main struct CrisisApp: App { + @NSApplicationDelegateAdaptor(CrisisAppDelegate.self) private var appDelegate + @State private var captureRequested = CommandLine.arguments.contains("--capture") || CommandLine.arguments.contains("--testbed") var body: some Scene { - WindowGroup { + WindowGroup("CrisisViz") { ImmersiveView() .task { if captureRequested { diff --git a/CrisisViz/Tools/MakeAppIcon.swift b/CrisisViz/Tools/MakeAppIcon.swift new file mode 100644 index 0000000..4067415 --- /dev/null +++ b/CrisisViz/Tools/MakeAppIcon.swift @@ -0,0 +1,216 @@ +#!/usr/bin/env swift +// Renders a CrisisViz app icon at every macOS-required size and writes them +// into ./AppIcon.iconset, ready for `iconutil -c icns AppIcon.iconset`. +// +// Run with: swift Tools/MakeAppIcon.swift +// Or via the bundle.sh script (which then calls iconutil for you). + +import Cocoa +import CoreGraphics +import ImageIO +import UniformTypeIdentifiers + +let outDir = "AppIcon.iconset" +try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true) + +// (filename suffix, pixel size) — Apple's required iconset contents. +let entries: [(name: String, px: Int)] = [ + ("16x16", 16), + ("16x16@2x", 32), + ("32x32", 32), + ("32x32@2x", 64), + ("128x128", 128), + ("128x128@2x", 256), + ("256x256", 256), + ("256x256@2x", 512), + ("512x512", 512), + ("512x512@2x", 1024), +] + +// Palette — matches the live app's node colours. +let palette: [CGColor] = [ + CGColor(red: 0.30, green: 0.69, blue: 0.94, alpha: 1.0), // cyan + CGColor(red: 0.35, green: 0.85, blue: 0.55, alpha: 1.0), // green + CGColor(red: 0.95, green: 0.60, blue: 0.20, alpha: 1.0), // orange + CGColor(red: 0.80, green: 0.40, blue: 0.90, alpha: 1.0), // purple + CGColor(red: 0.95, green: 0.45, blue: 0.45, alpha: 1.0), // pink + CGColor(red: 0.90, green: 0.75, blue: 0.30, alpha: 1.0), // gold +] + +func renderIcon(px: Int) -> CGImage { + let cs = CGColorSpaceCreateDeviceRGB() + let info = CGImageAlphaInfo.premultipliedLast.rawValue + guard let ctx = CGContext( + data: nil, width: px, height: px, + bitsPerComponent: 8, bytesPerRow: px * 4, + space: cs, bitmapInfo: info + ) else { fatalError("CGContext failed") } + + let s = CGFloat(px) + // macOS Big-Sur-and-later icon mask: rounded square, ~22.5% corner radius + let inset = s * 0.10 + let body = CGRect(x: inset, y: inset, width: s - 2 * inset, height: s - 2 * inset) + let radius: CGFloat = body.width * 0.225 + let mask = CGPath(roundedRect: body, cornerWidth: radius, cornerHeight: radius, transform: nil) + + ctx.saveGState() + ctx.addPath(mask) + ctx.clip() + + // Background — dark indigo gradient (top→bottom). + let bgColors = [ + CGColor(red: 0.06, green: 0.08, blue: 0.16, alpha: 1.0), + CGColor(red: 0.02, green: 0.03, blue: 0.08, alpha: 1.0), + ] + if let grad = CGGradient( + colorsSpace: cs, colors: bgColors as CFArray, + locations: [0, 1] + ) { + ctx.drawLinearGradient( + grad, + start: CGPoint(x: 0, y: body.maxY), + end: CGPoint(x: 0, y: body.minY), + options: [] + ) + } + + // Subtle round-separator lines (echo of the chapters' "round zones"). + ctx.setStrokeColor(CGColor(red: 1, green: 1, blue: 1, alpha: 0.05)) + ctx.setLineWidth(max(0.5, s * 0.003)) + let sep = body.width / 4 + for i in 1..<4 { + let x = body.minX + sep * CGFloat(i) + ctx.move(to: CGPoint(x: x, y: body.minY + sep * 0.4)) + ctx.addLine(to: CGPoint(x: x, y: body.maxY - sep * 0.4)) + } + ctx.strokePath() + + // Layout: a 3-round mini-DAG. Three columns of nodes with edges between them. + let cx = body.midX + let cy = body.midY + let span = body.width * 0.62 + let colDx = span / 2 + let nodeR = max(s * 0.045, 2.0) + let edgeWidth = max(s * 0.012, 1.0) + + struct Node { + let pos: CGPoint + let color: CGColor + let radiusScale: CGFloat + } + + // Three columns: left (round 0, 2 nodes), middle (round 1, 3 nodes), right (round 2, 2 nodes). + let leftX = cx - colDx + let midX = cx + let rightX = cx + colDx + let yStep = body.height * 0.18 + + let nodes: [Node] = [ + Node(pos: CGPoint(x: leftX, y: cy + yStep * 0.7), color: palette[0], radiusScale: 1.0), + Node(pos: CGPoint(x: leftX, y: cy - yStep * 0.7), color: palette[3], radiusScale: 1.0), + + Node(pos: CGPoint(x: midX, y: cy + yStep), color: palette[1], radiusScale: 1.05), + Node(pos: CGPoint(x: midX, y: cy), color: palette[5], radiusScale: 1.4), // emphasized centre + Node(pos: CGPoint(x: midX, y: cy - yStep), color: palette[4], radiusScale: 1.05), + + Node(pos: CGPoint(x: rightX, y: cy + yStep * 0.7), color: palette[2], radiusScale: 1.0), + Node(pos: CGPoint(x: rightX, y: cy - yStep * 0.7), color: palette[0], radiusScale: 1.0), + ] + + // Edges (parent references): each later-column node points to a couple of earlier-column nodes. + let edges: [(Int, Int)] = [ + (2, 0), (2, 1), + (3, 0), (3, 1), + (4, 0), (4, 1), + (5, 2), (5, 3), + (6, 3), (6, 4), + ] + + ctx.setLineCap(.round) + ctx.setStrokeColor(CGColor(red: 1, green: 1, blue: 1, alpha: 0.55)) + ctx.setLineWidth(edgeWidth) + for (a, b) in edges { + ctx.move(to: nodes[a].pos) + ctx.addLine(to: nodes[b].pos) + } + ctx.strokePath() + + // Glow pass under each node. + for n in nodes { + let r = nodeR * n.radiusScale * 2.2 + let rect = CGRect(x: n.pos.x - r, y: n.pos.y - r, width: r * 2, height: r * 2) + ctx.saveGState() + ctx.setShadow(offset: .zero, blur: r * 1.4, color: n.color) + ctx.setFillColor(n.color) + ctx.setAlpha(0.28) + ctx.fillEllipse(in: rect) + ctx.restoreGState() + } + + // Filled nodes. + for n in nodes { + let r = nodeR * n.radiusScale + let rect = CGRect(x: n.pos.x - r, y: n.pos.y - r, width: r * 2, height: r * 2) + ctx.setFillColor(n.color) + ctx.fillEllipse(in: rect) + // Inner highlight + ctx.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 0.45)) + let hr = r * 0.4 + let highlight = CGRect(x: n.pos.x - hr * 0.6, y: n.pos.y + r * 0.2 - hr * 0.6, width: hr, height: hr) + ctx.fillEllipse(in: highlight) + } + + // Centre vertex emphasis ring. + let centre = nodes[3] + let cr = nodeR * centre.radiusScale + s * 0.012 + let centerRect = CGRect(x: centre.pos.x - cr, y: centre.pos.y - cr, width: cr * 2, height: cr * 2) + ctx.setStrokeColor(CGColor(red: 1, green: 1, blue: 1, alpha: 0.85)) + ctx.setLineWidth(max(s * 0.006, 0.8)) + ctx.strokeEllipse(in: centerRect) + + // Wordmark "C" only at large sizes (bottom-right corner). + if px >= 128 { + let label = "Δ" // suggestive of consensus / change + let fontSize = s * 0.16 + let font = NSFont(name: "Menlo-Bold", size: fontSize) ?? NSFont.systemFont(ofSize: fontSize, weight: .heavy) + let para = NSMutableParagraphStyle() + para.alignment = .center + let attrs: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.55), + .paragraphStyle: para, + ] + let str = NSAttributedString(string: label, attributes: attrs) + let line = CTLineCreateWithAttributedString(str) + let bounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds) + let dx = body.maxX - bounds.width - s * 0.06 + let dy = body.minY + s * 0.06 + ctx.textPosition = CGPoint(x: dx, y: dy) + CTLineDraw(line, ctx) + } + + ctx.restoreGState() + + return ctx.makeImage()! +} + +func writePNG(_ image: CGImage, to path: String) { + let url = URL(fileURLWithPath: path) + guard let dest = CGImageDestinationCreateWithURL( + url as CFURL, + UTType.png.identifier as CFString, + 1, nil + ) else { + fputs("✘ Failed to create dest for \(path)\n", stderr); exit(1) + } + CGImageDestinationAddImage(dest, image, nil) + CGImageDestinationFinalize(dest) +} + +for entry in entries { + let img = renderIcon(px: entry.px) + let path = "\(outDir)/icon_\(entry.name).png" + writePNG(img, to: path) + print("✓ \(path) (\(entry.px)×\(entry.px))") +} +print("Done. Now: iconutil -c icns \(outDir) -o AppIcon.icns") diff --git a/CrisisViz/bundle.sh b/CrisisViz/bundle.sh new file mode 100755 index 0000000..6642a47 --- /dev/null +++ b/CrisisViz/bundle.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Build CrisisViz.app — a proper macOS application bundle. +# +# ./bundle.sh build, package, and launch via `open` +# ./bundle.sh --no-launch build only +# +# The bundle has a real Info.plist + AppIcon.icns + crisis_data.json, so when +# launched with `open CrisisViz.app` it appears in the Dock with its icon and +# behaves like a native macOS application. + +set -euo pipefail +cd "$(dirname "$0")" + +APP=CrisisViz.app +BIN_NAME=CrisisViz +BUNDLE_ID=org.crisis.CrisisViz +VERSION=1.0 +BUILD=1 +LAUNCH=1 + +for arg in "$@"; do + case "$arg" in + --no-launch) LAUNCH=0 ;; + esac +done + +echo "▸ Generating AppIcon.icns…" +swift Tools/MakeAppIcon.swift > /dev/null +iconutil -c icns AppIcon.iconset -o AppIcon.icns + +echo "▸ Building release binary…" +swift build -c release + +echo "▸ Assembling ${APP}…" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" +mkdir -p "$APP/Contents/Resources" + +cp ".build/release/$BIN_NAME" "$APP/Contents/MacOS/$BIN_NAME" +cp AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" +cp Sources/CrisisViz/crisis_data.json "$APP/Contents/Resources/crisis_data.json" + +# Copy any other Bundle.module resources that SwiftPM placed alongside the binary +# so they are still findable from Bundle.main when running from the .app. +if [ -d ".build/release/${BIN_NAME}_${BIN_NAME}.bundle" ]; then + cp -R ".build/release/${BIN_NAME}_${BIN_NAME}.bundle" "$APP/Contents/Resources/" +fi + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleDevelopmentRegion en + CFBundleExecutable ${BIN_NAME} + CFBundleIconFile AppIcon + CFBundleIdentifier ${BUNDLE_ID} + CFBundleInfoDictionaryVersion 6.0 + CFBundleName CrisisViz + CFBundleDisplayName CrisisViz + CFBundlePackageType APPL + CFBundleShortVersionString ${VERSION} + CFBundleVersion ${BUILD} + LSMinimumSystemVersion 14.0 + LSApplicationCategoryType public.app-category.education + NSPrincipalClass NSApplication + NSHighResolutionCapable + NSSupportsAutomaticTermination + NSSupportsSuddenTermination + + +PLIST + +# Ad-hoc codesign so macOS treats it as a signed app bundle. +codesign --force --deep --sign - "$APP" 2>/dev/null || true + +# Refresh the icon cache so Finder & Dock pick up the new icon immediately. +touch "$APP" + +echo "✓ Built $APP" + +if [ "$LAUNCH" -eq 1 ]; then + echo "▸ Launching ${APP}…" + open "$APP" +fi