Skip to content

24 — iOS UX Refinement Backlog

Active. Governed by 00-ROADMAP.md. A running list of UX/polish details noticed during review, captured so they're not lost in chat. These are not blockers — the MVP review loop and Phase 2 browse work today. Pull items into slices as phases allow.

Tags: [quick] cheap, high-polish — pull in whenever a slice touches that screen · [P2]/[P3] waits for that phase (usually needs a dependency like image upload).

Player & PlayerBar

Player-as-product envisioning → 57-PLAYER-AS-PRODUCT.md (2026-07-05): handoff behavior (browse ≠ play) · favorites queue · lock-screen/motion artwork · synced lyrics · full-screen "listening mode". The waveform-centric lock below (no big cover frame on the player) stands — doc-57 §6 is a separate, explicitly-entered surface, not a reversal.

  • Play button over the cover [quick] ✅ shipped — cover thumb in PlayerBar is now the play/pause control (artwork Button + .ultraThinMaterial disc + glyph).
  • Portals button icon [quick] ✅ shipped — now rectangle.righthalf.inset.filled.
  • "V#" as a bubble [quick] ✅ shipped — accent-tinted capsule pill, separated from the filename, on both PlayerView current-version line and VersionCard.
  • Version switching via the track-name line [P2] ✅ shipped — the "V# {name} ⌄" line in PlayerView is now a tappable control that opens a glass sheet of VersionCards (slice 1 of 25-IOS-CRUD-FIRST-RELEASE.md). The separate version-rail / TrackDetailView was dropped; tracks now open straight to the player from Recents, Browse, and Project → Tracks.
  • Cover frame on the player [P3 — needs image upload] — the large cover/disc frame on the player is confusing as a primary element. Cover selection belongs in the Portal (pick among uploaded images → set as the track cover); the player should then just display the chosen cover. (No image upload on iOS in MVP, so this waits.) → The display home for big artwork is now envisioned as doc-57 §6's listening mode (Portal stays the manage home).
  • Waveform pinned on scroll [P2] ✅ shipped — pinned in PlayerView (header VStack sits above the comments ScrollView), folded into the waveform-as-scrubber slice below. Real PCM waveform rendering is still pending (placeholder is a sine pattern today).
  • Swipe-back from the top edge too [owner-raised 2026-06-26]v1 shipped (Claude Code; xcodebuild green; ⏳ on-device tuning). A simultaneousGesture DragGesture on PlayerView: a drag starting near the top (startLocation.y < 90), mostly vertical, with enough downward travel (> 70) calls dismiss() — coexists with the waveform scrub + comment scroll. Tune thresholds / band height on device.
  • Misc [later] — marquee for long titles; shuffle / prev / next / queue / share controls.

Performance & audio session (owner-raised 2026-06-20)

  • Opening a Track from Home — flash [P1 — owner-raised 2026-06-26]REAL fix 2026-06-26 (Claude Code; xcodebuild green; ⏳ on-device QA). First attempt (a "zoomless seeded push") was the wrong lever — owner wants the zoom, and the flash persisted; fully reverted (zoom + .matchedTransitionSource restored). Actual root cause: the player is waveform-centric (no big cover) — the cover lives in the bottom PlayerBar — yet the loading skeleton drew a big 180px cover hero. So the zoom grew that cover, then detail landed and the cover-less layout replaced it → cover grew then vanished = the flash. Fix = the skeleton now mirrors the loaded layout (seeded title → faint version/approve/timecode bars → waveform placeholder, no cover hero), so nothing vanishes when detail lands. Still open / needs device QA: residual first-open lag (push-time view construction — candidate for a Time Profiler trace), and whether the .animation(0.8, value: coverFile.id) body cross-fade should be trimmed.

  • First audio byte is awful — load audio FIRST, before the player view [P1 — perf, owner top priority 2026-06-24]. The AVPlayerItem is created only after GET /track/:id resolves, so nothing buffers until the player view has loaded its detail — the first play stalls badly. Fix = prefetch-on-tap with a known URL: (1) backend — expose the final audio file's signed blobUrl on the Track list payload (populate audio in the list methods + add audioFinalUrl in serializeTrack); (2) iOS — an AudioPrewarmer singleton that, the moment a row is tapped (or even appears), creates an AVPlayer + AVPlayerItem for that URL and prerolls it, keyed by trackId; the player then adopts the already-buffered item instead of creating a cold one. Net: audio starts buffering at tap, in parallel with the detail fetch + view transition. (Builds on the shipped File.duration + prefetch-on-intent work below.) Updated by doc-56 (2026-07-05, owner: "still laggy and flashy on first load"): the S1 build found this mechanism never actually fired — step (1) above (the backend list-payload half) was never built, and the iOS decode expected a single File object where the API always sent the audio array; the permissive try? nil'd silently, so prewarm + seeds no-oped on every surface (the June improvement the owner felt was the skeleton/de-hitch/duration work). Plus coverage decay: the surfaces shipped after 06-26 (approval rows, portals) never wired it at all. Doc-56 re-envisions this as a structural First-Play Pipeline (explicit audioFinalFile wire contract — shipped in S1 — + route-enforced seeds/prewarm + TTFA metric + cache/renditions forks) — treat doc-56 as the source of truth for first-play performance from here.

  • Opening the player stopped other apps' audio [quick — bug]fixed 2026-06-20 (Claude Code; xcodebuild green). The doc-35-era configureAudioSession set the category and activated the session on the player VM's init, so merely viewing a wave took audio focus and stopped music playing in another app. Now init only sets the category (with .mixWithOthers, which doesn't interrupt), and a new activateAudioSession() (setActive(true), exclusive) is called only when the user hits playplayPause (start) + selectVersion (resume). Other apps keep playing until you actually press play. Files: Features/Review/PlayerView.swift.

  • First-load lag on a track [P2 — perf]SHIPPED 2026-06-21 (Claude Code; backend nest build and iOS xcodebuild green; ⏳ visual QA). Built (1) optimistic skeletonTrackRoute carries the track name + cover thumb (the list already has both), so the player hero + title paint instantly (the thumb is already in the decoded cache) with faint placeholder bars; no more bare spinner; (2) persisted File.duration — the iOS uploader computes the local AVURLAsset length (instant moov-atom parse) and sends it as a multipart text field; backend stores it (no server-side audio-probe dep, dodges the ESM music-metadata friction); the player shows the timecode immediately and skips the asset.load(.duration) round-trip (falls back to network load on old/web versions — no backfill); (3) prefetch-on-intent — the AVPlayerItem is set current before play so AVPlayer warms the buffer while the user reads the screen. Not built: decoded-waveform cache per file id (deferred — waveform is synthesized, not fetched, so it's not the bottleneck). Original envisioning below. ↓

  • First-load lag on a track [P2 — perf] (owner-raised 2026-06-20) — the first navigation to a track lags a beat (the waveform + the duration/time appear late); subsequent opens are fine. Likely the waveform data + AVPlayerItem duration resolve synchronously/late on first appear with no warm cache. Approach (best-practice, envision before building): (1) prefetch on intent — when a track row/tile is about to be shown (or on long-press/hover), kick off the AVAsset loadValues/duration + waveform fetch so the data is warm by tap; (2) optimistic skeleton — render the player chrome immediately with a placeholder waveform + a known/estimated duration (from Track/File metadata if available) and swap in the real data when ready, so navigation never blocks; (3) cache the decoded waveform + duration per file id so a re-open is instant (mirrors the doc-34 image disk-cache thinking). The duration specifically should come from File metadata server-side if we store it (cheap), avoiding the on-device AVAsset round-trip on first paint. Mostly iOS; a small backend assist (persist audio duration on File) would remove the worst of it — flag that as the cheap backend ride-along.

TestFlight-spotted (2026-06-10)

  • Play button stuck after track ends [quick — bug] ✅ shipped — PlayerViewModel now observes AVPlayerItem.didPlayToEndTimeNotification on each loaded item (re-registered on version swap, torn down via a small ObserverHolder sidecar): on end it pauses, sets isPlaying = false, and seeks to .zero so the control returns to the play glyph and the track is immediately re-playable.
  • Accent colors out of sync across screens [quick — bug] ✅ implemented in-house by Claude Code (2026-06-11, build green — pending owner visual QA). Root cause: index-seeding in lists vs launch-randomized String.hashValue in details. Fix: Theme.accent(forKey:) (stable FNV-1a over the entity id) now colors artist + project + track identity on every screen (Artists grid ↔ detail, Recents rows, Project header ↔ track rows, player halo / PlayerBar / empty-state hero). versionColor(for: index) retained for V1/V2/V3 version-scoped elements — the semantic is now identity color = who/what it is; version color = which revision.
  • The cover model [partly shipped 2026-06-12 — Portal upload-new + PlayerBar artwork done] — supersedes both "Version switcher cover" and the older "Cover frame on the player" items. Manage in the Portal, display on the wave.
    • Manage (add / review / change): the Track Portal gets a Cover section — current cover + "Change cover" → photo picker → upload via the existing multipart plumbing (POST /track/:id/file, image/*; backend already pushes to image[] and flips the final image, and GET /track/:id already populates image). Upload-new works today. ✅ shipped 2026-06-12 (Xcode agent) — TrackDetail decodes image[] + imageFinal; TrackPortalContent has the Cover section with PhotosPicker(.images); upload reuses APIClient.uploadFile with a real image/* MIME resolved from the picked PhotosPickerItem.supportedContentTypes. Pick-among-already-uploaded images still needs a small backend set-cover verb. ✅ shipped 2026-06-12 — backend PATCH /track/:trackId/file/:fileId/set-cover (Claude Code) + iOS "Previous covers" rail in the Portal (Xcode agent): when detail.image.count > 1, a horizontal rail of ~56pt AsyncImage thumbs renders under the current cover (skipping the active one); tap → confirmationDialog ("Use this as the cover? / The current cover stays in history.") → setCover PATCH → refetch — rail, main preview, and PlayerBar artwork all update. Rail disables during upload-new or set-cover so the user can't double-fire. ⚠️ TestFlight needs the next container redeploy.
    • Display (wave page): cover appears only as supporting elements — the PlayerBar thumbnail (already the play/pause control; real artwork replaces the gradient ✅ shipped 2026-06-12 — new artworkURL: URL? param, AsyncImage renders under the existing material disc + glyph; gradient falls through on nil / loading / failure) and the screen's adaptive gradient halo derived from the artwork ✅ shipped 2026-06-12 (Claude Code, build green) — PlayerViewModel.artworkAccent downloads the cover once per file id, samples AdaptiveColor.average(from:), and the ScreenBackground halo crossfades at hero pace (0.8s) from identity accent to the artwork color (doc 22 §6's personalization signature, finally wired). No big cover frame on the player.
    • Brand images (Artist / Project) — same pattern: each node's image is managed in its own Portal and displayed on tiles/cards/heroes. Lands with Portals-everywhere in the doc-26 build (the next major phase per doc 31) — captured here so it's on the roadmap, not built piecemeal.
    • Cover versioning + release ownership envisioned (owner questions 2026-06-12) → see 28-RECORDINGS-VARIANTS-RELEASES.md §"Cover art & releases": covers already version via image[]/imageFinal (history UI ✅ shipped 2026-06-12); singles = track art, albums/compilations = Project art via the Project Portal, display preference track → project → gradient.
  • PlayerBar artwork control refined [shipped 2026-06-12 — pending QA] — owner QA on the cover slice: the material disc covered the artwork. Now: paused = artwork + small shadowed play glyph only; playing = glyph fades out and a breathing light ring (hairline stroke + slow sonar pulse, ~2.4s) carries the playback signal — the owner's "wave out of the circle" idea. Reduce Motion → static stroke.
  • Comment composer hidden by keyboard [quick — bug] ✅ shipped — CommentComposer takes an external FocusState<Bool>.Binding; PlayerView drives one (isComposerFocused) and, while focused, hides the sibling ApproveBar + PlayerBar so the composer is the only bar above the keyboard. The bottom safeAreaInset lifts via default SwiftUI keyboard avoidance, and the comments ScrollView now uses .scrollDismissesKeyboard(.interactively) so the user can swipe to dismiss.
  • Tap a comment → seek to its time [quick] ✅ shipped — CommentCard rows in PlayerView have a .onTapGesture that calls viewModel.seek(to: comment.timeStart ?? 0), the inverse of the existing waveform-dot → scroll-to-comment direction.
  • Player layout: waveform-as-scrubber, keep it visible [P2] ✅ shipped — the separate Slider is gone; Waveform now exposes onScrubChanged / onScrubEnded callbacks driven by a DragGesture(minimumDistance: 0) (taps and continuous drags share one recognizer). PlayerView suppresses periodic-time-observer writes mid-drag via isScrubbing and seeks on release. Layout was restructured so the header (title / version / timecode / waveform) sits pinned above the comments ScrollView, keeping the waveform — and the playhead — visible while comments scroll. Per-comment dots + section strips still work. ApproveBar reposition was deliberately held (see next item).
  • Waveform: scroll-then-stick (collapsing header) + native title header [P2] ✅ implemented in-house by Claude Code (2026-06-11, build green — pending owner visual QA), after a first Xcode-agent build of this item was reverted (it broke on the waveform's .gesture losing to the ScrollView pan). Now: single ScrollView + LazyVStack(pinnedViews: [.sectionHeaders]), waveform as the pinned section header (glass backing + hairline appear when pinned), title/version/timecode scroll away, brand-font title fades into the nav bar via a principal toolbar item. Scrubbing fixed with .highPriorityGesture; comment-marker taps hit-tested inside the same recognizer. ApproveBar untouched (still held, next item).
  • Comment timing precision [P2] — capturing a comment uses the instantaneous playhead, which is too coarse; let users review/adjust the exact second of a comment (a stepper / fine-scrub on the timestamp at compose time, and ideally edit-after-the-fact). Future phase — flag the design (nudge ± / type the timecode) before building.
    • Owner clarification (2026-06-20) — freeze the timestamp at compose-open [quick — bug-ish]. Today PlayerView.postComment() reads the live currentTime at send time (line ~358), so if playback keeps moving while the user types, the comment anchors to where the playhead drifted to, not where they were when they opened the box. Fix: capture currentTime into a pendingCommentTime when the composer opens / first focuses, use that for timeStart, and show it frozen in the composer. Open UX call (envision): on compose-open, (a) freeze the captured time only (playback continues — least disruptive) vs (b) also pause playback at that moment (owner's literal "the time should stop") vs (c) pause + show a scrub-to-fine-tune handle. Recommend (b) as the default (matches the mental model "I'm marking this spot") with the nudge±/edit-after from the parent item as the precision layer. Pairs with the existing edit-after-the-fact ask. Mostly iOS; no backend change.
  • Title/Version/Cover bar reposition [design — hold] ✅ decided + implemented (2026-06-11, build green — pending visual QA). Owner picked status chips in the scrolling header (under the version line, scrolls away with the title; approval is an occasional act, not a constant control). ApproveBar gained an .inline style; the bottom stack is now just composer + PlayerBar. The "reads as status, not action" affordance concern (Discoverability section) stays open — revisit after QA.

QA round 3 (2026-06-11) — addressed in-house, pending re-QA

  • Pinned-waveform backing, take 3 — the gradient scrim still produced a visible "section" edge below the title (reads white in light mode). Now a feathered halo owned by the waveform itself: a blurred rounded snBackground fill hugging the bars — no hard edges in any direction, no section band. If this still isn't right, next iteration is no backing at all + stronger bar contrast/shadow.
  • Waveform gesture intent — touching the waveform then scrolling no longer jumps the timeline. A touch is undecided until ~10pt of travel: horizontal → scrub; vertical → cancelled (user wanted the list); a large vertical pull mid-scrub bails out (slide-off-to-cancel). Taps still seek / hit comment markers.
  • Background audio ✅ shipped — UIBackgroundModes: audio + Now Playing (lock screen / Control Center show track + position) + remote transport (play/pause/scrub from lock screen & headphones). Scoped to the wave page for now — leaving it stops the session by design until the global player lands.
  • Global mini player / music-player behaviors — envisioned in 30-GLOBAL-PLAYBACK.md: Phase 2 = PlaybackManager + floating glass mini player (zero chrome when idle); Phase 3 = favorites queue / loop / continuous play. Future feature, per owner. Behavior spec now in 57-PLAYER-AS-PRODUCT.md (browse ≠ play handoff, favorites model, release-preview parity).

Naming

  • The future grouping above ArtistSpace is "Roster" (not "Label"). Not a backend entity yet — see 23-IOS-PHASE2-IA.md.

Shared surface — parked (re-envision)

  • The Shared tab shipped then was pulled (2026-06-07): "Shared" isn't a tab, it's a consequence of how Roster / workspaces / actors are modeled, which isn't decided. No Shared surface ships until the actors/Roster question in 25-IOS-CRUD-FIRST-RELEASE.md is answered.

Approval & metadata (raised after slice 5 — first-release CRUD complete)

  • Version-scoped approval [P2] ✅ designed + implemented end-to-end (2026-06-11, builds green — pending visual QA; spec in 29-VERSION-SCOPED-APPROVAL.md). Owner confirmed: the unit of approval is the FileFile.reviewStatus (+by/at), per-file PATCH /track/:trackId/file/:fileId/review-status, releaseStatus.version mirrors the final version, new upload → Under review. The player's Version chip now binds to the loaded file. Data backfilled. Cheap follow-ups open: status dot per VersionCard in the switcher; "Approved by {who}, {when}" in the Portal. ⚠️ Needs a backend redeploy (doc 27) before the chip works on TestFlight.
  • Metadata fields — completeness + sync [P2] ✅ shipped 2026-06-12 (Xcode agent, build green) — Portal editor now covers ISRC, UPC, notes, mainArtists in addition to name / label / genre / releaseDate. Wire keys match the backend TrackDTO (uppercase ISRC/UPC); UpdateTrackRequest still omits nil so unrelated fields aren't clobbered. Read mode hides empties; ISRC/UPC use uppercase / no-autocorrect input; Notes is a multi-line TextField(axis: .vertical) with a label-on-top block in read mode. credits remains parked (structured — needs a contributors editor; later).

Cover art (raised on TestFlight 2026-06-13 — grounded in 32-APPROVAL-MODEL.md §7)

  • Cover not cropped to frame [P2] — an oversized image overflows the artwork frame on upload. Near-term: client-side aspect-fill + a manual crop affordance at upload time (no backend — Sharp already emits the variants). Future phase (deferred — beta): iOS 27 on-device generative smart resize / outpaint / cover generation — real and wanted, not now; revisit post-beta.
  • Cover not shown in the hero [quick] — the cover uploads but the hero still draws the gradient. Backend healthy (image[]/imageFinal serialize); slice-9 wired artist/project images → ArtworkHero but the track-cover → hero (and project-cover → project-hero) path wasn't threaded. iOS wiring item (in next-brief.md); cascade = track cover → project art → identity gradient (doc 28).
  • Cover history = CRUD [P2]image[] is the history; setCoverImage covers change (promote); the delete-from-history verb (backend) + a Portal cover-history browser (tap-to-promote, swipe-to-delete; never the current final without re-pointing) are the missing CRUD halves.
  • Cover approval home [P2] — move the Cover sign-off off the Wave to the Project/Track Portal (cover = release art = a project/CD concern, doc 28). releaseStatus.cover already exists — a surface move; optionally upgrade to the doc-29 per-image pattern. Full model in doc 32.

Image picker (raised on TestFlight 2026-06-13)

  • Confirm the selected picture [quick] — after picking an image (artist / project / track cover), show a preview + confirm step before upload (don't upload silently on pick). Cancel re-opens the picker; Confirm uploads. Quiet, on-brand (the chosen art is a brand moment).
  • Source: Files, not only Photos [quick] — let the user pick from the Files app / document picker in addition to the Photos library (designers keep cover art in Files/iCloud Drive, not the camera roll). Add a .fileImporter (image content types) path alongside the existing PhotosPicker; both feed the same confirm step above.
  • Crop component — deferred (owner-confirmed 2026-06-13: "ok as a component/phase for later") [P2] — a reusable manual-crop component (pan/zoom to the frame) is its own later phase; aspect-fill + .clipped() already prevents overflow today. On-device generative resize is further out (doc 32 §7, post-beta).
  • Photos source broken — regression [quick] (in next-brief.md QA slice) — choosing "Photos" in the new ImagePickerButton menu doesn't open the library. Likely the PhotosPicker-inside-Menu gotcha; fix via .photosPicker(isPresented:) toggled by a @State flag. Blocks Photos upload — priority.
  • Track cover missing in the project tracklist — RE-OPENED 2026-06-13 [quick] (in next-brief.md QA slice) — first fix showed the project cover on every row, not each track's own. Root cause: the iOS Track model decodes no image field; backend already serves per-track image[] via GET /track/project/:id. Fix = add image/coverURL to Track + thread per-row with cascade track cover → project art → gradient.

Identity halo (owner-locked 2026-06-13 — refines 22-IOS-DESIGN-SYSTEM.md §6)

  • Intelligent accent halo, not averaged [P2] (in next-brief.md QA slice) — the artwork halo / AdaptiveGradient must read the image's most vibrant accent color (highest-chroma dominant cluster, dropping near-grey/black/white), never the mean (which muddies to grey). Cache per image; cover-less nodes keep the identity-accent gradient. Owner: the halo should feel pulled from the art. RE-OPENED 2026-06-13: AdaptiveColor.vibrant shipped but is wired only to the version/player — apply it to all heroes (artist/project/track), retiring the average path so no halo muddies. Re-opened again 2026-06-13 (round 2): (a) still missing on the Artists/Roster home (ArtistsHomeView); (b) intro flash — halo paints identity first then crossfades to the art color on every appear (animation keyed to color). Fix: key the crossfade to the cover identity (animate only on cover change), cache accent per URL, never show identity in front of a real cover. Full direction: doc 22 §6. Round 3 (owner 2026-06-14): (a) home halo wrongly shared — keyed to the first artist's cover and applied to the whole home + every artist; the home must have its own (identity) halo and each artist tile its own (cover-derived). (b) double halo — vibrant layer + an identity/base layer render together; when a cover exists the identity layer must not render (one layer only). Round 4 (owner 2026-06-14, deprioritized — after Wave 3 + variant slice): (a) home still double — screen snAccent halo + per-tile vibrant ArtworkHero.shadow are two different colors → make the home screen wash neutral/ambient, per-tile glow is the only halo. (b) nav flash — destination starts artworkAccent = nil → identity (Theme.accent) shows for one frame before the cached vibrant resolves; seed synchronously from AdaptiveColor.cachedColor(for:) via the route's cover URL. Diagnoses + fixes in doc 22 §6; the doc-34 disk image cache compounds (image also appears instantly on nav).
  • Halo transition should start from the parent's color, not the node's own default [P2] (owner 2026-06-19) — when a Track has a cover but its Project does not, the track halo currently crossfades from the track's own identity default → the track cover color. It should crossfade from the Project's default (identity) color → the Track cover color, for continuity (you arrived from a Project that was showing that default). Generalize the existing route-seed pattern (ArtistDetailView already seeds the halo from the navigating tile's cached cover color): carry the parent's effective color through the …Route — parent's cover color if it has one, else the parent's identity Theme.accent(forKey: parentId) — and use it as the child's initial halo, then animate to the child's own cover color. Applies down the chain (Artist→Project→Track→Version). doc 22 §6 continuity.

Image delivery / performance (owner 2026-06-14 — 34-IMAGE-DELIVERY.md)

  • Slow cover loads — right-size + cache, no CDN [P2] — Sharp variants (thumb/medium/original WebP) already exist but are unused: cover uploads store originals only and File keeps just blobUrl, so a 150px tile downloads a full original. Backend: wire uploadImageWithVariants + persist/sign thumbnailUrl/mediumUrl. iOS: request thumb for tiles/rows, medium for heroes, + a disk image cache (AsyncImage has none). Fixes the late/wrong halo too. Full plan in doc 34.
  • Thumbnails look low-res on high-density / large screens [P2] (owner 2026-06-19) — e.g. Artist images in the Roster grid render blurry on iPhone 17 Pro Max (@3x) and iPads. The fixed 150² thumb is smaller than the rendered pixels (a 60 pt avatar @3x = 180 px; bigger tiles / iPad larger). Fix = density-aware variant selection (choose by target-points × UIScreen.scale: use medium (600) whenever rendered px > ~150, reserve thumb for genuinely tiny avatars) and/or a larger thumb variant (e.g. 300²) so small contexts still get a small-but-crisp image. See 34-IMAGE-DELIVERY.md §"Device-aware variant selection".

Comments / notes visible in Portals — "eagle-level" (owner-raised 2026-06-13 — 33-NOTES-ANNOTATIONS.md §Eagle-level)

  • Annotation band in every Portal [P2] — comments "disappeared" because the Portal renders no annotation band (comments still live only in the Wave timeline). Give each Portal Anchored-here + Rolled-up-from-below notes/comments, filtered to what matters at that altitude (unresolved · tagged · status-bearing): version = today's comments (leaf, enough); track/project/artist/roster = a subtree digest — less but louder the higher you fly. Needs a backend subtree-aggregate read; rides the Portals-everywhere + Notes phase. Full model in doc 33.

Notes → first-class annotations (raised 2026-06-13 — grounded in 33-NOTES-ANNOTATIONS.md)

  • Notes = node-anchored Comments [P2] — the single Track.notes string becomes authored, dated, threaded, resolvable notes by reusing the existing Comment entity (type=NOTE, no timeStart); it already carries owner / timestamps / responses / isResolved / project+version anchors. Net-new is small: an artistSpace anchor, a NOTE kind, tags[] for cross-hierarchy surfacing (doc-26 lens), per-node read endpoints. Surfaced in the Portal's Notes section (rides Portals-everywhere). Status stays opt-in resolve, not a default; doc-32 approval elevation only for true sign-off notes. Full model in doc 33.

Validation / QA coverage

  • Shared inbound/outbound needs a multi-account matrix [P2] (owner 2026-06-13) — the Shared activity (doc-26 slices 5–6) can't be confirmed from one account. Needs seeded test accounts + a written run-through across: existing ↔ existing, invite to a brand-new account (signup → auto-add), and guest/link access (no account). A test-plan gap, not a code bug — flag for when QA tooling lands. Also tracked in 26-IDENTITY-SHARING-NAVIGATION.md §Build plan.

Toolbar & image UX (owner 2026-06-14)

  • Consolidate the 3 top-right buttons → fold ••• into the Portal [P2] — detail screens now show three trailing items (ⓘ Portal · + add · ••• overflow). The ••• menu (Rename/Delete in ArtistDetailView) is redundant with the Portal that ⓘ already opens (Rename is already there as "Edit"). Move Delete (and any other overflow actions) into the Portal and drop the ••• → cleaner two-button toolbar (ⓘ + +). This is the doc-26 Layer-4 principle: the Portal is the actions+metadata surface. Apply across Artist/Project/Track detail toolbars.
  • ProjectCard not cropped to a square [quick] — a portrait project cover makes a portrait card (seen from the artist page). Cause: in ProjectCard, AsyncImage { …scaledToFill() } is a ZStack child with no frame, so the image's intrinsic size drives the card and .aspectRatio(1,.fit) can't override it. Fix = mirror ArtworkHero: the gradient RoundedRectangle owns the square (.aspectRatio(1,.fit)), the image is an .overlay (inherits that size, scaledToFill → crops), then .clipShape. (ArtworkHero is already correct; this is the one component that regressed.)
  • Image-picker confirm sheet doesn't cover the screen [quick] — after picking, the confirm step (ImagePickerButton) presents at a partial detent, so the "Use this image" / "Choose again" controls sit below the fold and the flow reads as stuck. Fix: present the confirm step at .presentationDetents([.large]) (or a fullScreenCover) so the controls are visible without scrolling; keep it calm/on-brand.

Discoverability / general

  • Approve affordance [quick] — the three pills (Title · Version · Cover) are the approve control, but it reads as status, not action. Consider a clearer label/hint or a primary "Approve" treatment.
  • Row ellipsis → Portal, on every hierarchy item [P2] — the ••• button on list rows (Artist / Project / Track / Version) should be actionable (or removed if it adds no value), opening that item's Portal — the same metadata/inspector focus, adapted per node. This is the concrete UI hook for the "Portals everywhere" model in 26-IDENTITY-SHARING-NAVIGATION.md (Layer 4). Build with the Portals-everywhere work, not piecemeal.

Roster & long-press polish (owner-raised 2026-06-19)

  • Roster can't be changed from the Artist Portal [quick] — the existing-roster picker (shipped in RosterEditorSheet, 2026-06-19) only appears where the caller passes existingRosters. ArtistDetailView opens ArtistPortalContent(...) without that list, so the Portal's roster editor shows only create-new / remove — you can't move the artist to an existing roster from there (you can from the Home long-press, where the list is available). Fix: thread the distinct-roster list into the detail context and pass it to ArtistPortalContent. Backend ready: GET /artist/rosters[{name,color}] shipped 2026-06-19 (distinct rosters across the caller's artists) — the iOS half is just: fetch it in ArtistDetailView, pass existingRosters into ArtistPortalContent's RosterEditorSheet. (iOS only now.)
  • Long-press halo becomes a square mask, and sticks [P2 — bug] — long-pressing an artist tile (context menu) snapshots the tile for the menu preview, clipping it to its bounds so the vibrant halo/shadow glow is lost (hard square); the squared state persists until another long-press elsewhere or a navigation. Classic SwiftUI .contextMenu preview gotcha (the preview host clips the shadow/blur layer). Fix: supply an explicit .contextMenu(menuItems:) { } preview: { } whose preview renders the tile with its halo (or a clean rounded card that intentionally drops the glow), so the implicit snapshot doesn't square-mask the live tile. Verify the squared state no longer lingers after dismiss. (iOS.)

Home header & typography (owner-raised 2026-06-21)

  • Home header redesign — full-bleed bubbles + brand-font title + liquid-glass on scroll [design slice — Claude Code]. From the Home screenshot: a top banner/frame clips the artist bubbles, and the Sonnance title uses the system large-title font. The owner wants: (1) artist bubbles always fully visible — no chrome cutting the cover circles; (2) the Sonnance title in the brand display font (sonnanceDisplay = PPMonumentExtended, the same face as artist/project names) instead of the system large title; (3) the title rides up / turns Liquid-Glass on scroll (doc-22 §4 scroll-driven motion — pinProgress-style, the same mechanism as the player's waveform-halo/nav-title fade). Pairs with the deferred Home dashboard/menu redesign (doc-20 one-dashboard) — envision them together so this isn't reworked. Likely the "banner" = the nav-bar material + the ScreenBackground 0.10 wash edge; the redesign replaces the large nav title with an in-content brand title + a glass header that collapses on scroll. Grounded in doc-22 §4. ArtistsHomeView.

    Updated by doc-54 (2026-07-04): this item folds into Home v2 (the non-scrollable one-screen redesign, doc-54 §3.1) — envisioned together exactly as this item requested; the build lands with the Home v2 slice. (With no vertical scroll, the "rides up on scroll" behaviour applies to the See-all screens, not Home itself.)

  • Reject the section-label font [design — owner decision] (stakeholder rejected). The font for literals like "Projects" (the SectionHeader title) is .sonnanceTitle (Lexend-Bold) — but Lexend is NOT bundled (Typography.swift notes the .ttf isn't added), so it currently renders as the system font (SF Pro). So the rejected face is effectively SF Pro standing in for Lexend across all sonnanceTitle/ sonnanceBody/sonnanceCaption usages (section headers, row titles, body, captions — used widely). Options (owner to pick): (a) bundle the intended Lexend (Resources/Fonts/ + UIAppFonts) and see if the real face is acceptable; (b) switch the section labels to PPMonumentExtended (the display face we already bundle — heavier, very on-brand for labels); (c) choose a new secondary face and rebind sonnanceTitle/Body/Caption to it. Recommend (a) first (cheapest — it may just be the SF fallback the stakeholder dislikes), then (c) if Lexend itself is rejected. A design-system change in 22-IOS-DESIGN-SYSTEM.md §Typography — needs the owner's call before building.

How to use this doc

Items are noted, not scheduled. When a slice touches a screen, pull its [quick] items in for free polish; [P2]/[P3] items wait for their phase. Log to CHANGELOG.md when shipped and strike them here.

Ctrl-Audio Platform Documentation