Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ab83583
docs: Yeti design pass for every surface, Tim's rulings, stage (c) de…
Nov 7, 2026
9792b1b
feat: Yeti as theme #6 — tokens and plumbing
Nov 7, 2026
3940124
feat: Yeti stickers — stock, play, price, row, label and plain tag
Nov 7, 2026
139918a
feat: Yeti blister pack and foil sweep
Nov 7, 2026
41215ee
feat: Yeti now-playing card
Nov 7, 2026
0cdd915
feat: Yeti panel, dashboard, Mac miniplayer and transport bar
Nov 7, 2026
334a237
fix: keep the Yeti header's menu slot when the queue is empty
Nov 7, 2026
8ba698e
feat: Yeti Home — iPhone hero, flat Mac resume card, the nice list
Nov 7, 2026
2487af2
feat: Yeti browse — artists, artist page, show page, search, library
Nov 8, 2026
32d6ab2
feat: Yeti video — posters, stickers, live edge, PPV and errors
Nov 8, 2026
d6cbfaf
feat: Yeti front door and small print, plus app-wide copy fixes
Nov 8, 2026
232ce7f
docs: Yeti becomes the only design; the Duo pill's shelf mockup
Nov 8, 2026
25c1f9b
fix: Yeti artist title, show-page meta stack, and segmented control
Nov 8, 2026
8cec8e9
fix: Mac Clear Queue asks with an AppKit alert; hero and login a11y
Nov 8, 2026
50ee7b9
docs: Yeti stage (c) built — state, next steps, collapse hazards, unv…
Nov 8, 2026
17f0f12
feat: the Duo pill gets its own shelf
Nov 8, 2026
3ccfb93
fix: the closed shelf's hang slot sits above the home indicator
Nov 8, 2026
9c879d9
docs: Yeti-only spec approved; blister-pack icon concepts
Nov 8, 2026
f1503dc
feat: Yeti is the only design; macOS foil focus outline
Nov 8, 2026
694db70
docs: Tim picked the bubble icon concept
Nov 8, 2026
8969457
feat: the Yeti app icon, "The bubble", as an Icon Composer document
Nov 8, 2026
a114586
fix(ios): the Duo's pill is the system accessory again
Nov 8, 2026
9621bb4
docs: project.yml icon comments point at AppIcon.icon; keep the rail …
Nov 8, 2026
653769c
refactor: delete the transport and miniplayer variants (stage e1)
Nov 8, 2026
cb040e4
refactor: delete the art-color pipeline, wash and grain (stage e2)
Nov 8, 2026
8bf351d
refactor: collapse every capability branch to Yeti's side (stage e3)
Nov 8, 2026
67bc23c
refactor: strip Theme to the single Yeti token struct (stage e4)
Nov 8, 2026
4fc003d
fix(ios): the soft bottom edge effect covers pushed screens too
Nov 8, 2026
22ecfc0
fix(ios): the expanded panel says its quality quietly
Nov 8, 2026
8ed1f93
fix: audio and video never play over each other
Nov 9, 2026
f77d049
fix: every video claim silences audio; auto-advance never evicts a video
Nov 9, 2026
ef97eb2
fix: the bubble's halo clears the hang slot
Nov 9, 2026
ff840a0
docs: yeti-design lands on main, with the arbiter and hang-slot fixes
Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AppleNugs/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,10 @@ final class AppModel {

/// Sign in on nugs.net's own page in a system browser sheet — the app's
/// only sign-in (it never sees a password). A user-cancelled sheet is not
/// surfaced as an error.
/// surfaced as an error. A second call while one is in flight is a no-op:
/// the views guard the double tap too, but the model owns the rule.
func loginWithBrowser() async {
guard !isLoggingIn else { return }
isLoggingIn = true
loginError = nil
defer { isLoggingIn = false }
Expand Down
14 changes: 11 additions & 3 deletions AppleNugs/App/UIState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,19 @@ final class UIState {

// --- toasts (queue-op confirmations, same as the web layout) ---------------

private(set) var toast: String?
/// A transient message. `kind` only changes the capsule's mark (a check or
/// a warning mark).
struct Toast: Equatable {
enum Kind: Equatable { case success, failure }
let text: String
let kind: Kind
}

private(set) var toast: Toast?
private var toastTask: Task<Void, Never>?

func showToast(_ text: String) {
toast = text
func showToast(_ text: String, kind: Toast.Kind = .success) {
toast = Toast(text: text, kind: kind)
toastTask?.cancel()
toastTask = Task {
try? await Task.sleep(for: .seconds(1.8))
Expand Down
1 change: 1 addition & 0 deletions AppleNugs/AppIcon.icon/Assets/bars.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions AppleNugs/AppIcon.icon/Assets/disc.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions AppleNugs/AppIcon.icon/Assets/film.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions AppleNugs/AppIcon.icon/Assets/flange.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions AppleNugs/AppIcon.icon/Assets/foil.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions AppleNugs/AppIcon.icon/Assets/glow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions AppleNugs/AppIcon.icon/Assets/interior.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions AppleNugs/AppIcon.icon/Assets/rim.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
149 changes: 149 additions & 0 deletions AppleNugs/AppIcon.icon/icon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
{
"fill": {
"linear-gradient": [
"srgb:0.08235,0.10980,0.20000,1.00000",
"srgb:0.04314,0.05882,0.10980,1.00000"
]
},
"fill-specializations": [
{
"value": {
"linear-gradient": [
"srgb:0.08235,0.10980,0.20000,1.00000",
"srgb:0.04314,0.05882,0.10980,1.00000"
]
}
},
{
"appearance": "dark",
"value": "automatic"
}
],
"groups": [
{
"layers": [
{
"glass": false,
"hidden": false,
"image-name": "foil.svg",
"name": "Foil glint"
}
],
"name": "Foil",
"shadow": {
"kind": "none",
"opacity": 0.5
},
"specular": false,
"translucency": {
"enabled": false,
"value": 0.5
}
},
{
"layers": [
{
"glass": true,
"hidden": false,
"image-name": "rim.svg",
"name": "Rim"
},
{
"glass": false,
"hidden": false,
"image-name": "film.svg",
"name": "Film"
}
],
"name": "Bubble",
"shadow": {
"kind": "none",
"opacity": 0.5
},
"specular": true,
"translucency": {
"enabled": false,
"value": 0.5
}
},
{
"layers": [
{
"glass": false,
"hidden": false,
"image-name": "bars.svg",
"name": "Bars"
},
{
"fill-specializations": [
{
"appearance": "tinted",
"value": {
"solid": "srgb:1.00000,1.00000,1.00000,1.00000"
}
}
],
"glass": false,
"hidden": false,
"image-name": "disc.svg",
"name": "Disc"
}
],
"name": "Sticker",
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"specular": false,
"translucency": {
"enabled": false,
"value": 0.5
}
},
{
"layers": [
{
"glass": false,
"hidden": false,
"image-name": "interior.svg",
"name": "Interior"
},
{
"glass": false,
"hidden": false,
"image-name": "flange.svg",
"name": "Flange"
},
{
"glass": false,
"hidden": false,
"hidden-specializations": [
{
"appearance": "dark",
"value": true
},
{
"appearance": "tinted",
"value": true
}
],
"image-name": "glow.svg",
"name": "Glow"
}
],
"name": "Card",
"shadow": {
"kind": "neutral",
"opacity": 0.6
},
"specular": false,
"translucency": {
"enabled": false,
"value": 0.5
}
}
],
"supported-platforms": {
"squares": "shared"
}
}
16 changes: 0 additions & 16 deletions AppleNugs/Assets.xcassets/AppIcon.appiconset/Contents.json

This file was deleted.

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
8 changes: 4 additions & 4 deletions AppleNugs/Core/DeepLinkRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ enum DeepLinkRouter {
case .video: try await openVideo(link, app: app, ui: ui)
}
} catch {
ui.showToast("Couldn't open that show on nugs")
ui.showToast("Couldn't open that show on nugs", kind: .failure)
}
}

// MARK: - audio

private static func openAudio(_ link: DeepLink, app: AppModel, ui: UIState) async throws {
guard let containerId = try await resolveContainerId(link, app: app) else {
ui.showToast("That show isn't on nugs")
ui.showToast("That show isn't on nugs", kind: .failure)
return
}
// Do the fallible fetch + queue build BEFORE touching navigation, so a
Expand Down Expand Up @@ -101,7 +101,7 @@ enum DeepLinkRouter {
/// would miss. Page through (bounded) until the date is found or the list ends.
private static func openVideo(_ link: DeepLink, app: AppModel, ui: UIState) async throws {
guard let artistId = try await artistId(matching: link.artist, app: app) else {
ui.showToast("That show isn't on nugs"); return
ui.showToast("That show isn't on nugs", kind: .failure); return
}
let pageSize = 100
let maxPages = 10 // safety cap (≤1000 videos)
Expand All @@ -116,7 +116,7 @@ enum DeepLinkRouter {
if page.count < pageSize { break } // last page
offset += page.count
}
ui.showToast("No video for that show on nugs")
ui.showToast("No video for that show on nugs", kind: .failure)
}

// MARK: - helpers
Expand Down
51 changes: 51 additions & 0 deletions AppleNugs/Core/FoilSweepTrigger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

/// When Yeti's one flourish — the foil band crossing the now-playing title —
/// should run. Pure Foundation so the four rules are pinned host-free in
/// `AppleNugsTests`; the `foilSweep(identity:isPlaying:)` modifier holds one
/// of these and runs the band when `observe` says so.
///
/// The whole state is the identity that last swept. The rule: **fire when
/// playback is running for an identity that isn't the one that last swept**,
/// then remember it. That single rule gives every behavior the design asks for:
///
/// - a new track while playing fires once;
/// - pause → resume does not (same identity, already swept), so a three-hour
/// show with ten pauses sees it once per track;
/// - a launch-restored queue (current set, NOT playing) fires nothing until
/// its first play, then fires;
/// - the same track coming back later (re-queued, or skipped away from and
/// back to) fires again only if a DIFFERENT track swept in between. A track
/// that was current but never played doesn't count as "in between", and
/// neither does an empty queue (`nil` never overwrites the memory).
///
/// The identity is the catalog track id (`QueueTrack.trackId`), not the
/// per-entry `QueueTrack.id` UUID: keyed on the UUID, re-queueing a track
/// would always look new. It reads **no playback time** — identity and
/// `isPlaying` only — so it can never put the sweep on the 4 Hz tick.
struct FoilSweepTrigger: Equatable, Sendable {
/// The identity the band last crossed (or was marked as seen), if any.
private(set) var lastSwept: String?

init(lastSwept: String? = nil) {
self.lastSwept = lastSwept
}

/// Feed the latest playback state (on every identity or `isPlaying`
/// change). Returns `true` exactly when the sweep should run now.
mutating func observe(identity: String?, isPlaying: Bool) -> Bool {
guard isPlaying, let identity, identity != lastSwept else { return false }
lastSwept = identity
return true
}

/// The title's view appeared. A track that is ALREADY playing when the
/// view shows up (opening the player mid-song, the Duo rebuilding the
/// player on rotation) is marked as seen without a sweep — the band marks
/// a track change, not a screen opening. A track that is loaded but not
/// playing is left unmarked, so a launch-restored queue still sweeps on
/// its first play.
mutating func appear(identity: String?, isPlaying: Bool) {
_ = observe(identity: identity, isPlaying: isPlaying)
}
}
52 changes: 50 additions & 2 deletions AppleNugs/Core/LedgerSummary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,24 @@ enum LedgerSummary {
var seconds: Double
}

/// A number and its unit, kept apart so a ledger can set them in two
/// weights (the ledger's "**41** plays", "**62** h", "**112** hours
/// listened"). Read together they say exactly what the joined string says.
struct Figure: Equatable {
let value: String
let unit: String
}

struct Row: Identifiable, Equatable {
let rank: Int
let key: String
let displayName: String
let playsText: String
let hoursText: String
/// The same two numbers, split for the column layout ("41" "plays",
/// "62" "h"; under an hour, "41" "m").
let plays: Figure
let hours: Figure

var id: String { key }
}
Expand All @@ -34,6 +46,9 @@ enum LedgerSummary {
let hoursText: String
let artistCountText: String
let artistCount: Int
/// The one-line totals ("**112** hours listened · **9** artists").
let hours: Figure
let artists: Figure
}

/// The ledger lives inline on Home, so it is capped at what fits there.
Expand All @@ -55,7 +70,9 @@ enum LedgerSummary {
.reduce(0, +)
return Totals(hoursText: totalHoursText(seconds: seconds),
artistCountText: String(tallies.count),
artistCount: tallies.count)
artistCount: tallies.count,
hours: totalHoursFigure(seconds: seconds),
artists: artistsFigure(tallies.count))
}

/// Top artists by seconds. Ties break by plays, then by name, then by key,
Expand All @@ -76,7 +93,9 @@ enum LedgerSummary {
key: tally.key,
displayName: tally.displayName,
playsText: playsText(tally.plays),
hoursText: hoursText(seconds: tally.seconds))
hoursText: hoursText(seconds: tally.seconds),
plays: playsFigure(tally.plays),
hours: hoursFigure(seconds: tally.seconds))
}
}

Expand All @@ -101,4 +120,33 @@ enum LedgerSummary {
static func playsText(_ plays: Int) -> String {
plays == 1 ? "1 play" : "\(plays) plays"
}

// --- split figures (the ledger's columns and totals line) ----------------

/// The totals line's hours: the same number as `totalHoursText`, with the
/// unit spelled out. "1 hour listened", and "<1 hour listened" below one —
/// less than one hour is still singular.
static func totalHoursFigure(seconds: Double) -> Figure {
let value = totalHoursText(seconds: seconds)
let unit = (value == "1" || value == "<1") ? "hour listened" : "hours listened"
return Figure(value: value, unit: unit)
}

static func artistsFigure(_ count: Int) -> Figure {
Figure(value: String(count), unit: count == 1 ? "artist" : "artists")
}

/// A row's plays column: "41" "plays", "1" "play".
static func playsFigure(_ plays: Int) -> Figure {
Figure(value: String(plays), unit: plays == 1 ? "play" : "plays")
}

/// A row's hours column: the same rule as `hoursText` (whole hours once
/// there is one, else whole minutes), split into "62" "h" / "41" "m".
static func hoursFigure(seconds: Double) -> Figure {
guard seconds.isFinite, seconds > 0 else { return Figure(value: "0", unit: "m") }
let hours = Int(seconds / 3600)
if hours > 0 { return Figure(value: String(hours), unit: "h") }
return Figure(value: String(max(1, Int(seconds / 60))), unit: "m")
}
}
Loading
Loading