Authoritative for UI work (T12, T13, T15 UX surfaces). Pairs with
SPEC.md. The §Tahoe reconciliation appendix at the bottom of this file overrides items that conflict with the Tahoe-only architecture inSPEC.mdv2.
Build AerialWall as a first-class macOS citizen. Use native AppKit/SwiftUI controls, system colors, and standard layouts throughout. The goal is an app that:
- Looks correct on macOS today and automatically looks correct after future OS redesigns: no manual tweaks needed.
- Borrows structural simplicity from GNOME HIG (single primary window, progressive disclosure, clear primary action, no modal clutter).
- Has zero custom chrome except minor polish tweaks (thumbnail radius, row sizing).
References to feel like: Photos, Pastel, Sequel Pro, GNOME's Loupe or Solanum: utilitarian and considered, not "designed".
DO NOT:
- Custom title bar or hidden traffic lights
- Hardcoded hex colors anywhere: use semantic
NSColor/ SwiftUI.primary,.secondary,Color(.windowBackgroundColor)etc. - Custom animations beyond what SwiftUI/AppKit provides by default
- Custom progress indicators, toasts, or status badges: use system equivalents
- Reinvent NSAlert, NSOpenPanel, NSProgressIndicator
Standard NSWindow:
- Normal title bar, traffic lights, app name in title
- Style: .titled, .closable, .miniaturizable, .resizable
- Min size: 760 × 520pt
- Default size: 960 × 620pt
- No custom titlebar, no hidden title bar
┌──────────────────────────────────────────────────────────┐
│ ● ○ ● AerialWall [toolbar] │ ← standard NSToolbar
├─────────────────┬────────────────────────────────────────┤
│ │ │
│ SIDEBAR │ CONTENT │
│ (sidebar │ (main view — swaps based on │
│ list style) │ sidebar selection) │
│ │ │
└─────────────────┴────────────────────────────────────────┘
Implement with SwiftUI NavigationSplitView:
- sidebar: fixed-width, system sidebar appearance
- detail: content area
Use SwiftUI List with .listStyle(.sidebar). Standard SF Symbol icons with .font(.body).
// Sections and items:
Section("Library") {
Label("All Wallpapers", systemImage: "photo.on.rectangle.angled")
Label("Recently Added", systemImage: "clock")
}
Section("System") {
Label("Settings", systemImage: "gearshape")
Label("Logs", systemImage: "terminal")
}- Selection drives the content area.
- Badge on "All Wallpapers" shows count using
.badge(count)modifier. - No custom colors, no custom fonts: pure system sidebar rendering.
Declare an NSToolbarDelegate or use SwiftUI .toolbar {}. Items:
Left side:
[Scope bar — NSSegmentedControl]
Segments: "All" | "Applied" | "Errors"
Tracks selection, filters the content view
Center (flexible space):
NSSearchField — placeholder "Search wallpapers"
keyboardShortcut: Cmd+F
Right side:
[Import…] NSToolbarItem, standard bordered button
action: opens NSOpenPanel
keyboardShortcut: Cmd+O
Place flexible spaces around the search field so it stays centered on wide windows.
Toolbar items use .toolbarItemIdentifier and standard toolbar item sizing.
No custom drawn items.
ScrollView {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 180, maximum: 240))],
spacing: 12
) {
ForEach(wallpapers) { wp in
WallpaperThumbnailCell(wallpaper: wp)
}
ImportDropCell() // always last
}
.padding(16)
}┌──────────────────┐
│ │ ← NSImageView, aspect 16:9
│ thumbnail │ corner radius: 8pt (only non-system tweak)
│ │ clips to bounds, no border
├──────────────────┤
│ Name │ ← .font(.headline), .lineLimit(1), truncatingTail
│ 2:34 · 4K │ ← .font(.caption), .foregroundStyle(.secondary)
└──────────────────┘
Interaction:
- Single click: selects cell (standard selection highlight)
- Double click: triggers "Apply as Wallpaper"
- Right click: system context menu (see §Context Menus)
Selection style:
Use List/selection binding or NSCollectionView's built-in selection.
Applied wallpaper: show SF Symbol "checkmark.circle.fill" overlaid
top-right corner of thumbnail, using .tint(.green) — system green,
not hardcoded hex.
Table(wallpapers, selection: $selectedID, sortOrder: $sortOrder) {
TableColumn("Name", value: \.name)
TableColumn("Duration", value: \.durationString) { Text($0.durationString).monospacedDigit() }
TableColumn("Resolution", value: \.resolution)
TableColumn("Status") { wp in
StatusBadge(status: wp.status) // simple Text with system color, see below
}
TableColumn("Added", value: \.importedAt) { Text($0.importedAt, style: .relative) }
}
.tableStyle(.inset(alternatesRowBackgrounds: true))Table gives you free: column resizing, sort by column header, keyboard navigation, accessibility, and row selection.
StatusBadge: just a Text with .foregroundStyle(color): no custom capsule, no background:
// Applied: "● Applied" .foregroundStyle(.green)
// Pending: "Not applied" .foregroundStyle(.secondary)
// Error: "⚠ Error" .foregroundStyle(.red)
// Encoding: "Encoding…" .foregroundStyle(.secondary) + progress indicatorPrimary action: double-click row or press Return when row selected → Apply.
// Append as a special last row in table (or last cell in grid):
// In table: a clearly labeled empty-state placeholder below the table
// In grid: a dashed-border cell
ImportDropCell:
Dashed border: RoundedRectangle using .stroke(style: StrokeStyle(dash: [5]))
color: Color(.separatorColor) ← system color, adapts automatically
Content:
Image(systemName: "arrow.down.circle")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("Drop video here")
.font(.headline)
.foregroundStyle(.secondary)
Text("or use Import… (⌘O)")
.font(.caption)
.foregroundStyle(.tertiary)
Drag-over state:
.border color → Color.accentColor ← system accent (blue by default, user-overridable)
background → Color.accentColor.opacity(0.05)
No custom animations — just the color change.Replace content area with centered ContentUnavailableView (macOS 14+):
ContentUnavailableView(
"No Wallpapers Yet",
systemImage: "photo.on.rectangle.angled",
description: Text("Import a video to use it as a native aerial wallpaper.")
) {
Button("Import Video…") { openImportPanel() }
}This is a fully native component: correct spacing, correct typography, correct icon sizing, localization-ready.
While encoding, the row/cell shows progress inline:
In table view:
TableColumn("Status") { wp in
if wp.isTranscoding {
HStack(spacing: 6) {
ProgressView(value: wp.transcodeProgress)
.frame(width: 80)
.controlSize(.small)
Text("\(Int(wp.transcodeProgress * 100))%")
.monospacedDigit()
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
StatusBadge(status: wp.status)
}
}In grid view:
// Overlay on thumbnail area only:
ZStack(alignment: .bottom) {
thumbnailImage
if wallpaper.isTranscoding {
VStack(spacing: 4) {
ProgressView(value: wallpaper.transcodeProgress)
.progressViewStyle(.linear)
.controlSize(.small)
.padding(.horizontal, 8)
Text("Encoding \(Int(wallpaper.transcodeProgress * 100))%")
.font(.caption2)
.foregroundStyle(.white)
}
.padding(.bottom, 6)
.background(
LinearGradient(colors: [.clear, Color.black.opacity(0.5)],
startPoint: .top, endPoint: .bottom)
)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}No custom progress bar styling. .progressViewStyle(.linear) with .controlSize(.small) is exactly right.
Keep the happy path dead simple:
- User double-clicks row / cell, OR selects and presses Return.
- If privileged helper not installed yet →
NSAlert(§Alerts). - Otherwise, XPC call fires. Show
.progressViewStyle(.circular)in the status column during the ~2s daemon restart. - On success: status updates to "● Applied", no fanfare.
- On failure: status updates to "⚠ Error", user can hover for tooltip with error detail, or click "View Logs" from context menu.
No full-screen overlays. No custom sheets. One action, one outcome.
Right-click on any wallpaper cell/row:
.contextMenu {
Button("Apply as Wallpaper") { apply(wallpaper) }
Divider()
Button("Rename…") { beginRename(wallpaper) }
Button("Show in Finder") { reveal(wallpaper) }
Button("Copy UUID") { copyUUID(wallpaper) }
Divider()
Button("Remove", role: .destructive) { confirmRemove(wallpaper) }
}Rename: inline edit (double-click the name label) or via context menu → standard text field focus.
Remove confirmation:
.alert("Remove \"\(wallpaper.name)\"?",
isPresented: $showRemoveAlert) {
Button("Remove", role: .destructive) { remove(wallpaper) }
Button("Cancel", role: .cancel) {}
} message: {
Text("The video file will remain in /Users/Shared/AerialWall/Library. The wallpaper will be removed from the macOS wallpaper system.")
}Privileged helper install (first launch only):
.alert("AerialWall needs permission",
isPresented: $needsHelperInstall) {
Button("Install Helper…") { installHelper() }
Button("Not Now", role: .cancel) {}
} message: {
Text("AerialWall requires a privileged helper to write to the macOS wallpaper database. You'll be asked for your password once.")
}Import error:
.alert("Import Failed",
isPresented: $showImportError) {
Button("OK", role: .cancel) {}
} message: {
Text(importErrorMessage)
}Standard NSAlert maps to these automatically. Stick to .alert: no custom error sheets.
Separate window opened via Settings {} scene or NSWindowController.
Use .formStyle(.grouped) (macOS 13+) for clean grouped preference layout:
Form {
Section("General") {
Toggle("Start helper at login", isOn: $launchAtLogin)
Toggle("Apply wallpaper to all displays", isOn: $applyToAllDisplays)
}
Section("Import") {
Picker("Video quality", selection: $quality) {
Text("Fast").tag(Quality.fast)
Text("Balanced").tag(Quality.balanced)
Text("Maximum").tag(Quality.maximum)
}
Picker("Target resolution", selection: $resolution) {
Text("4K (3840×2160)").tag(Resolution.uhd)
Text("1080p (1920×1080)").tag(Resolution.fhd)
Text("Match source").tag(Resolution.source)
}
Toggle("Keep original files", isOn: $keepOriginals)
}
Section("Advanced") {
Picker("Base Apple asset", selection: $baseAsset) {
ForEach(appleAssets) { asset in
Text(asset.name).tag(asset.id)
}
}
.pickerStyle(.menu)
}
Section {
Button("Remove All Injected Wallpapers…", role: .destructive) {
showRemoveAllAlert = true
}
Button("Uninstall AerialWall…", role: .destructive) {
showUninstallAlert = true
}
} header: {
Text("Danger Zone")
}
}
.formStyle(.grouped)Window size: ~480×520pt, not resizable. Standard preferences window proportions.
Replace content area with a simple scrollable monospaced log view:
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(logLines) { line in
Text(line.formatted)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(line.level.color) // system colors only:
// .primary for INFO
// .green for SUCCESS
// .orange for WARN
// .red for ERROR
.textSelection(.enabled)
.id(line.id)
}
}
.padding(12)
}
.onChange(of: logLines.count) {
proxy.scrollTo(logLines.last?.id, anchor: .bottom)
}
}
.background(Color(.textBackgroundColor)) // system text editor background
.toolbar {
ToolbarItem(placement: .automatic) {
Button("Clear") { logLines.removeAll() }
}
}No custom terminal styling. .textBackgroundColor + SF Mono + system semantic colors = looks correct everywhere.
One-time sheet presented on first launch over the main window:
.sheet(isPresented: $showOnboarding) {
VStack(alignment: .leading, spacing: 20) {
Text("Before you start")
.font(.title2).bold()
// Step list using native LabeledContent or plain VStack rows
OnboardingStep(
number: 1,
title: "Install privileged helper",
detail: "Required to write to the macOS wallpaper database.",
isDone: helperInstalled,
action: { installHelper() },
actionLabel: "Install Helper…"
)
OnboardingStep(
number: 2,
title: "Download an Apple wallpaper video",
detail: "Open System Settings → Wallpaper and download any Apple video (e.g. \"Dubai Skyline\").",
isDone: aerialDBExists,
action: { openSystemSettings() },
actionLabel: "Open Wallpaper Settings"
)
OnboardingStep(
number: 3,
title: "Set that Apple video as your wallpaper",
detail: "AerialWall will hook into that asset slot.",
isDone: aerialVideoSelected
)
Spacer()
HStack {
Spacer()
Button("Continue") { showOnboarding = false }
.buttonStyle(.borderedProminent)
.disabled(!allStepsDone)
.keyboardShortcut(.return)
}
}
.padding(24)
.frame(width: 480, height: 360)
}OnboardingStep is a plain HStack: step number in a circle (using .background(Circle()...)), title + detail as VStack, checkmark when done (SF Symbol checkmark.circle.fill, .tint(.green)), optional button.
Standard SwiftUI: no custom components beyond layout.
If desired, a small NSStatusItem:
MenuBarExtra("AerialWall", systemImage: "photo.on.rectangle.angled") {
if let current = appliedWallpaper {
Text("Playing: \(current.name)")
.font(.headline)
} else {
Text("No wallpaper applied")
.foregroundStyle(.secondary)
}
Divider()
Button("Open AerialWall…") { openMainWindow() }
Divider()
Label(daemonStatusLabel, systemImage: daemonStatusIcon)
.foregroundStyle(daemonStatusColor) // .green / .orange / .red
Divider()
Button("Quit AerialWall") { NSApp.terminate(nil) }
}
.menuBarExtraStyle(.menu)All declared via .keyboardShortcut or CommandGroup:
Cmd+O Import…
Cmd+F Focus search
Return Apply selected wallpaper
Delete Remove selected (with confirmation alert)
Cmd+, Settings
Cmd+L Go to Logs
Cmd+R Restart daemon
Cmd+Shift+F Show in Finder
Thumbnail corner radius: system NSImageView defaults to 0. Set cornerRadius = 8 and wantsLayer = true. That's it. Everything else system-default.
- Use
Color(.windowBackgroundColor),Color(.controlBackgroundColor),Color(.separatorColor),.primary,.secondary,.tertiary: never hardcoded hex. - Use
.tint(Color.accentColor)for interactive highlights. System accent is user-configurable in System Settings. - Use
.controlSize(.small)on progress indicators and compact UI elements. - Use
Tableover custom grids wherever possible: free accessibility, sorting, and keyboard nav. - Use
ContentUnavailableViewfor empty states (macOS 14+),@availablefallback for older. - App target: macOS 14+ minimum. No backward compat hacks needed for any of the above.
- Sandbox: disabled (required for
/Librarywrites: document this clearly in the README).
The base spec above was written before the live entries.json probe revealed
the Tahoe user-level model. The following overrides apply. Everything else
stands.
Writes go to ~/Library/... as the login user. No SMAppService helper, no XPC,
no password prompt. Affected:
- Onboarding sheet: drop step 1 ("Install privileged helper"). Renumber:
- step 1 (was 2): "Download an Apple wallpaper video"
- step 2 (was 3): "Set that Apple video as your wallpaper"
- (and even those become optional:
entries.jsonis shipped pre-populated bymanifest.taron first login, so a fresh install can usually skip both. Show the onboarding only ifConstants.entriesJSONPathis absent.)
- Apply wallpaper §step 2: drop "If privileged helper not installed yet → NSAlert". No alert. Just call the local injection path.
- "AerialWall needs permission" alert: remove entirely.
- Settings → General → "Start helper at login": replace with "Start watcher at login" (the AerialWallAgent LaunchAgent, user-domain).
User-facing text mentions /Users/Shared/AerialWall/Library. Replace ∀ with
~/Library/Application Support/AerialWall/library/ (SPEC §I, V22).
Obsolete model (slot-hooking an existing Apple asset). On Tahoe we append a
fresh entry to entries.json directly. Drop the entire Advanced section or
repurpose it for FFmpeg path override (AERIALWALL_FFMPEG): see SPEC T5.
GUI spec says "macOS 14+ minimum". SPEC §C is macOS 26 (Tahoe) ≥ 26.4 only.
All GUI components used here (NavigationSplitView, Table,
ContentUnavailableView, .formStyle(.grouped), MenuBarExtra,
@Observable) are available on 26+.
README must say: sandbox is disabled because the wallpaper aerials directory
(~/Library/Application Support/com.apple.wallpaper/aerials/) is outside the
sandbox container even though it lives under ~/Library/. There is no /Library/
write requirement: that was the macOS-15 model.
GUI spec references "daemonStatusLabel" / "daemonStatusIcon" in the MenuBarExtra.
The only relevant daemon is com.apple.wallpaper.agent (SPEC §I). The label
should reflect whether AgentRestart.findAgentPID() returns a PID:
- found: green dot, "WallpaperAgent running"
- missing: red dot, "WallpaperAgent not running"
There's also a second daemon to surface: the AerialWall LaunchAgent
(com.aerialwall.agent): .green when loaded, .orange when not.