Changelog
All notable changes to the Ctrl-Audio platform are documented here.
Format follows Keep a Changelog.
[Unreleased]
Added
- doc-56 S2 built — first-play made structural (2026-07-06, Claude Code; owner "ok to continue";
xcodebuildgreen, ⏳ on-device QA):- Route-enforced seeds (§4.1-B):
TrackRoute's memberwise init is now private — surfaces route throughTrackRoute(track:)(seed-complete by construction: name/thumb/audio/duration from the list Track) or the loud.coldOpen(id:surface:)escape hatch; a partially-seeded route can't be hand-rolled anymore (how the June mechanism silently decayed). Prewarm moved out of per-rowsimultaneousGesturesprinkles into the navigation layer:TrackLink(rows) +NavigationPath.push(_:)(imperative pushes) — all 7 call sites migrated. - Likelihood-prewarm (§4.2-B): when the dashboard payload lands, the top-2 candidates (Resume tile + first approval row) prewarm speculatively — the first play of the install is warm before the tap (this is also the TLS preconnect).
NWPathMonitorguard: on expensive/constrained paths speculative preroll caps at ~5 s (preferredForwardBufferDuration); tap prewarms stay unlimited (expressed intent). - Seeded transport (§4.3):
bottomGlassStack(composer +PlayerBar) now renders from route seeds before/track/:idreturns — play is pressable from the first frame; detail fills title/version in place, nothing pops. Composer no-ops safely pre-detail (postCommentguards). playImmediately(atRate: 1.0)(§4.3): user-initiated play (playPause + version-switch resume) starts the instant frames exist instead of waiting for the playthrough prediction.- Pending from S2 scope: the Front Door-over-audio-blobs spike (§4.4-D) — infra config + the doc-56 §7 Q4 egress-cost decision, owner's court. S3 (disk cache) + S4 (renditions decision) remain.
- Route-enforced seeds (§4.1-B):
- doc-56 S1 + doc-57 P0 built — the iOS half (2026-07-06, Claude Code; owner greenlight;
xcodebuildgreen, ⏳ on-device QA. The backend half landed earlier inb419748: leanaudioFinalFilerefs on approvals + recents,toAudioFinalFileRef+ unit tests, theplay:first-audioevent type.)- Cold surfaces covered (doc-56 §3.1): Home approval rows — the Review & Approve MVP path, the audited coldest first play — now seed + prewarm from the dashboard's
audioFinalFile(mirroring the Resume tile); project-portal → track resolves the already-loaded row and seeds + prewarms; artist-portal → track stays an honest cold-open, labeled (surface: "artist.portal") so it shows in the TTFA chart — no track data exists at that call site; warming it means carrying an audio ref through the portal rollup (S2 candidate). - Duration seeds end-to-end:
TrackAudioFinalRef+Track.audioFinalFiledecodeduration;TrackRoute.audioFinalDuration→seedAudio(knownDuration:)— the timecode is right on the first frame, and seeded opens skip the network asset-duration probe. - TTFA metric (doc-56 §5):
play:first-audiofires once per player session on the first.playingtransition —{ttfaMs (route→sound), sincePlayMs (press→sound, excludes think-time), seeded, prewarmed, surface, fileId}; everyTrackRoutecall site now carries asurfacelabel (home.approval / home.resume / home.newversion / project.row / project.portal / artist.portal). DEBUG builds print a[TTFA]line on every first play — the doc-56 HUD v1. - doc-57 P0 — lock-screen artwork:
MPMediaItemArtworkfed from the cover cascade (detail cover medium → seeded track thumb → project image) throughDecodedImageCache/URLCache and included in Now Playing — the lock screen / Control Center show the cover for the first time. - QA crash fixed same day (owner run: freeze, then
'An AVPlayerItem cannot be associated with more than one instance of AVPlayer'opening a track from Home):AudioPrewarmer.take()moved the actively-loading prewarmed item between players — detach→attach races AVFoundation's internal association and throws intermittently. A latent June design flaw S1 exposed (the adopt path had never executed before — seeds were silently nil, per the corrected doc-56 §2 narrative). Fix:take()now returns a freshAVPlayerItemover the warmedAVURLAsset— keeps the resolved header state + hot connection (the cold-TLS cost prewarm exists to kill); the item-level buffer re-pulls over the warm connection (bounded re-fetch, never a crash).xcodebuildgreen; ⏳ re-QA.
- Cold surfaces covered (doc-56 §3.1): Home approval rows — the Review & Approve MVP path, the audited coldest first play — now seed + prewarm from the dashboard's
- doc-57 Player as Product envisioned (2026-07-05, Claude Code; docs only — awaiting owner greenlight; four forks pre-marked with the owner same day). Owner + stakeholder extended the player vision: Sonnance as the player of your own music and the place to verify what would publish — without leaving the app.
infrastructure/57-PLAYER-AS-PRODUCT.md: handoff = browse ≠ play ✅ (opening track B while A sounds loads B paused with a prominent play; mini player/lock screen always show what's sounding; explicit play is the only switch — the loaded-paused deck literally is doc-56's prewarmed item, same machinery seen twice); favorites queue ✅ (dedicatedFavoritecollection, unique(user, resource), track-level hearts v1 →audioFinal, recency queue = doc-30 Phase 3's queue source; named playlists deferred); release-preview parity — lock-screen artwork viaMPMediaItemArtwork(P0: nothing sets artwork today; hours-sized, gate-free, greenlightable alone), motion artwork player-side (Apple spec recorded: dual 1:1 3840² + 3:4 2048×2732, seamless loop, first frame = still cover, EP/Album/Artist pages only → a Project/ArtistSpace asset in the doc-45 cloud ✅; iOS 26MPMediaItemAnimatedArtworkmakes the full-screen animated lock-screen preview literal for third-party apps), line-synced lyrics ✅ (structuredLyricscollection + tap-along sync editor on the existing scrubber + Apple-style scrolling view + TTML/LRC export; explicitly not a doc-32 approval dimension); Musixmatch: replicate, don't integrate ✅ (paid catalog-display API, no artist OAuth — and our artists own their lyrics; publishing path documented: Apple ← TTML via distributor/iTunes Connect, Spotify/IG ← Musixmatch Pro). Full-screen artwork/lyrics = a new explicitly-entered "listening mode" — the doc-22/24 waveform-centric lock stands. Slices P0–P4; P1+ gated behind doc-56 S2 + doc-30 Phase 2. Housekeeping: doc-56's broken30-FAVORITES-OFFLINE.mdlink fixed; doc-56 + doc-57 rows added to the doc-00 map (56's was missing); pointers added in docs 30 / 24 / 45 / 32. Updated 2026-07-06 (owner): all six §9 questions resolved — favorites entry on Home (Resume area); version/variant switch is browse ≠ play too → the player/mini/lock screen now show the sounding version #, reversing today's immediate-switch (fast A/B preserved via a per-VersionCardplay control); lyrics editing = edit-rights + admin (RBAC later); lyrics approval waits for the LEGO/workflow layer (doc-43/46); Musixmatch guidance docs-only pending stakeholder review; cover-model unification = own slice. Plus owner-raised whole-album play + repeat/loop: §4 reframed so the queue is first-class with sources {album/project ✅ fundamental, favorites, single} + modes {repeat off/one/all, shuffle} — album play rides existingTrack.order+ doc-56 S1's audio ref → no new backend, so it leads. Slices reordered: P2 Queue & modes (client-only) → P3 Favorites; P2+P3 realize doc-30 Phase 3. Locked 2026-07-06 (owner: "good to go"): the final two items confirmed — play-jump = jump the queue (play on a track inside the sounding album jumps to it; outside starts a fresh queue) and shuffle stays in the v1 mode set. doc-57 is now fully resolved envisioning — nothing open; build follows §8 sequencing (P0 lock-screen artwork is gate-free and ready; P1+ await doc-56 S1/S2 + doc-30 Phase 2, each with its own greenlight). - doc-56 First-Play Pipeline envisioned (2026-07-05, Claude Code; docs only — awaiting owner greenlight). Owner re-raised first-byte: "still laggy and flashy on first load" (fresh install + ~4-min song) despite the June fixes. Audit initially read the June mechanism as "held but decayed"; the S1 build sharpened it: it never fired at all (doc-56 §2) — the doc-24 backend half (final audio ref on list payloads) was never built, and the iOS decode expected a single File object where the API always sent the
audioarray, so the permissivetry?nil'd silently → prewarm + seeds no-oped on every surface since June (the felt improvement was the skeleton/de-hitch/duration work). On top of that, coverage decay: 3 of 7 player entry points never wired it — including the Home approval rows (the Review & Approve MVP path), where the dashboard's owned/shared queries don't populateaudio(whilebuildPendingApprovalsalready fetches the under-review file and drops it). Plus: transport bar gated on/track/:id(can't press play → pops in), seeds carry no duration, no audio disk cache (SAS lasts 1 year — reuse is free), no TLS preconnect, and no TTFA metric so the regression was invisible.infrastructure/56-FIRST-PLAY-PIPELINE.mdenvisions the fix as a system: Intent → Warm → Paint → Sound; route-enforced seeds/prewarm (TrackRoute(track:)sole entry), likelihood-prewarm of the top-2 dashboard candidates, seeded transport (play before detail), fileId-keyed LRU disk cache, renditions/Front Door as data-driven forks — locked by a doc-38playback_first_audiometric with budgets (warm P50 ≤ 300 ms; fresh-install Wi-Fi P50 ≤ 1.5 s). Slices S1–S4 defined; S1 (cover the cold surfaces + record TTFA) is small and hits the owner's literal scenario. doc-24 §Performance now points here. - Legacy attachment backfill + files-as-LEGO-attributes envisioned (2026-07-05, Claude Code; script typechecks clean +
nest buildgreen):scripts/backfill-attachment-fields.ts— dry-run by default (APPLY=trueto write); forward-fillsfolder+resourceType:'track'onto pre-existing track attachments by walking each track's embeddedattachmentsmap → the canonicalFiledoc (only when missing; idempotent, orphan-ref safe). Brings legacy files into the doc-45 roll-up grouped + tier-chipped, like new uploads. Owner runs it when ready (mirrors the repo's other backfill scripts).- doc-43 §3 extended —
fileis a first-class attribute node (owner-raised):File.metadatabecomes the file attribute bag; built-in file facets ship as presets (kind audio/video/image/ attachment · attachment folder · MIME/format · size · duration · version · review status); custom file attributes ride the workspace overlay (e.g. "stem instrument", "contract status"); FileType/ folders stay extensible per doc-45. Makes the asset cloud a fully-attributed layer. - doc-40 extended — files & attachments are a filter/search/sort dimension (owner: "filter & search will require it too"): filter files by kind/folder/format/size/review-status + any doc-43 custom attribute; search by name (backend
searchmodule extended toFileat scale); the roll-up view is where filtering pays off ("only Stems across this project", "every unsigned contract in the studio drive"); a Saved View here = "the Contracts lens" (doc-26).
- doc-45 step 3 — aggregate-view-up (the cloud "mapped with the hierarchy") SHIPPED, full stack (2026-07-04, Claude Code;
nest build+ e2e 5/5 onattachments.e2e-spec·xcodebuildBUILD SUCCEEDED). The owner's literal ask — "the document library… mapped with the current hierarchy" — now real: a higher node's Files zone shows its descendants' attachments too.- Backend:
AttachmentService.findForArtist/Project/Workspacegain arollupflag +?rollup=1on the three GET endpoints. Cheap ancestor-ref query (dropping theresourceTypefilter): project roll-up = files whereproject == id(own + tracks); artist =artistSpace == id(own + projects + tracks); workspace =spaceOwner == id(the whole studio drive) — because track/ project attachments already carry the ancestor refs. A read lens, not a move (doc-45). Integration test asserts a track-level stem surfaces in both the project and artist roll-ups. - iOS:
MediaFilegainsresourceType(+attachmentTier, legacy → 'track');PortalFilesSectionrows carry a tier chip (Artist · Project · Track · Studio) when the file lives below the node — the node's own files stay clean. Artist / Project / Workspace portals fetch?rollup=1, so each cloud now shows everything beneath it, folder-grouped, source-chipped. (Track = leaf, no roll-up; keeps its embedded-map zone.) - Remaining on the thread: "Add file" in the Home "+" · legacy track backfill · Track Files-zone dedupe onto the shared section · pagination when clouds get large.
- Backend:
- Files zones at Artist / Project / Workspace SHIPPED (iOS) — the asset cloud visible at every altitude (2026-07-04, Claude Code;
xcodebuildBUILD SUCCEEDED). Consumes the doc-45 step-2 backend; closes the owner's original ask ("a document library related to the element, mapped with the hierarchy"):- New shared
Features/Browse/PortalFilesSection.swift— the Files-zone body for the non-track altitudes (folder-grouped rows · type icons · size · signed-URL open · in-zone Add) + shared helpers (attachmentFolderGroups/Choices, type icon).MediaFilegainsfolder. Plumbing (folder dialog · document picker · upload) stays at each portal so the foot "+" shares the flow when the zone is hidden (doc-54 §3.3). Track keeps its shipped embedded-map variant (dedupe = later polish). - Artist Portal — self-fetches
/artist/:id/attachments; "Files (N)" zone after the artist image; foot "Add file" when empty (owner/admin). - Project Portal — attachments join
loadDetail; zone after Workflow; the foot now composes "Add cover" + "Add file" viafootAddItems(PortalAddFoot's multi-item menu grammar in use). - Workspace Portal — the "studio drive": 5th parallel fetch; zone between Studio defaults and Roster; foot composes "Create your first artist" + "Add file". Default folder suggestion = Contracts (studio-level docs).
- New shared
- doc-45 step 2 BACKEND SHIPPED — attachments at every node (the asset cloud generalized) (2026-07-04, Claude Code;
nest buildclean + full e2e suite green, 4 suites / 13 tests incl. the newtest/attachments.e2e-spec.ts). The greenlit next pass:- Model:
File(type:'attachment')gainsfolderandresourceType('track' · 'project' · 'artistSpace' · 'workspace'); legacy track attachments (noresourceType) read as 'track'; new track uploads forward-fill both fields (embedded-map backfill = later pass). Workspace attachments key onspaceOwner(the per-owner tenant, doc-55) —FileService.createnow tolerates a missingartistSpace(quota falls back to the owner). - New
file/attachment.service.ts+ endpoints:GET/POST /artist/:id/attachments·GET/POST /project/:id/attachments·GET/POST /workspace/attachments(the "studio drive", owner-scoped) — same multipart +foldercontract as the track endpoints; blob keys node-derived viacreateNodeAttachmentLink(files-ride-the-node re-key constraint stands, doc-45). - doc-44 milestone: these are the first routes consuming
PermissionService.can()— reads gate on View, writes on Upload, project chains walk project → artist, default-deny with the legacy owner/collaborator fallback (integration test asserts a stranger is 403'd). - Unlocks next: iOS Files zones at Artist/Project/Workspace (reuse the shipped Track UI pattern) · "Add file" in the Home "+" · step-3 roll-up views.
- Model:
- Portal foot-"+" ROLLED OUT across the ladder (iOS) (2026-07-04, Claude Code;
xcodebuildBUILD SUCCEEDED). doc-54 §3.3 applied per altitude after a zone audit:- Workspace Portal — Roster zone renders only when artists exist; an artist-less studio gets a dashed "Create your first artist" foot slot (create sheet → lands on the new artist in Browse) — the §3.7 "studio root onboards itself" promise delivered.
- Project Portal — ALBUM ART renders only when a cover exists (no more "No cover yet" dead weight for viewers); foot "Add cover" reveals the art tools in place (offer-then-expand joins direct-action as the second foot grammar).
- Artist / File Portals — no change by design (audit recorded in doc-54 §3.3): Artist's roster/tag are identity-metadata rows, not zones; the File leaf's only growable content is anchored Notes, whose emptiness isn't parent-knowable until doc-33 exposes counts — the Notes zone header stays the leaf's add affordance meanwhile.
- doc-55 resolutions locked + portal foot-"+" reference BUILT (iOS) (2026-07-04, Claude Code;
xcodebuildBUILD SUCCEEDED).- doc-55 §6 — all four resolved by owner: facets private by default on the card (doc-53 zero-state) · multiple facets per membership allowed (
facets[]plural — one membership, one role, many hats; e.g. artist and engineer at your own studio) · guest→member keeps the relation (person-layer history rides the upgrade, doc-41 continuity) · workspace switcher = the reserved Home top-left corner (doc-54 §8.2 pointer added). - Portal start-almost-empty, reference implementation (doc-54 §3.3 / doc-49 §7 fork 4): new
DesignSystem/Components/PortalAddFoot.swift— the dashed "open slot" foot affordance (one unfilled zone = direct button; several = one "+ Add" menu). Applied on the Track Portal: the Files zone now renders only when it has content; when unfilled, the foot offers "Add file" (folder confirmation dialog → document picker; uploading/error states surface at the foot). Roll-out to Artist/Project/Workspace/File Portals + further parent-knowable zones = next slices.
- doc-55 §6 — all four resolved by owner: facets private by default on the card (doc-53 zero-state) · multiple facets per membership allowed (
- Contact tab shell BUILT (iOS) — name locked "Contact", avatar tab icon; owner QA passed (2026-07-04, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner resolutions: §8.3 Option B (zoned tab) ✓ · organizers path A ✓ · name = "Contact" with the user's avatar in a circle as the tab icon ("the icon addresses the ambiguous tab better, at least for now"; fallbackperson.crop.circle). Device QA passed for Home v2 + Files zone + "New version".- New
Features/Contact/ContactTabView.swift— the identity corner as a 5th tab (extreme right), doc-47 §10.3 realized: Identity zone = the Contact ID seed card (avatar · name · email ·sonnance.com/@usernamewhen set — doc-53's card grows into it) · absorbed account duties (appearance · sign out — moved out ofAppMenuDrawer, which slims to Library overflow [Shared/Recents] until those rehome) · Discovery corner (globe, top-right → honest placeholder until doc-52 events exist). Contacts/Messages/Discovery zones render only when content arrives (principle 7). RootTabsView:AppZone.contact+TabAvatarLoader(circular-masked, original-rendering avatar bitmap for the tab bar; falls back to the symbol). QA checklist in.agent/next-brief.md; prior Home-v2/Files/"New version" checklists marked passed (owner).
- New
- Files zone (doc-45 step 1) + "New version" in the Home "+" BUILT (iOS); tenancy/personas envisioned (doc-55) (2026-07-04, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner's greenlight-round gaps closed:- Track Portal Files zone SHIPPED — the node's document library (doc-45 asset cloud, track altitude):
TrackDetailnow decodes the folder-structuredattachmentsmap (lenient); a "Files (N)"PortalZonelists Lyrics/Stems/Contracts + custom folders (non-empty only, principle 7) with type-iconed rows opening the signed blob; Add file = folder menu → document picker → multipartPOST /track/:id/attachments(+folderpart) → detail refetch. Parent-owned callback pattern (PlayerViewModel.uploadAttachment), mirrorsuploadCover. - "New version" SHIPPED into the Home "+" (owner-flagged gap):
HierarchyCreateSheetgrew a unified result + track-pick step (artist → album → track);TrackRoute.presentUploadVersioncarries the intent;PlayerViewauto-presents the existingUploadVersionSheetonce detail lands;AppRouter.openTrackForVersionUploaddeep-links. "Add file" joins the "+" once doc-45 step 2 (any-node attachments) exists — noted in doc-54 §8.1. - doc-45 updated — step 1 marked shipped + two owner rulings: files ride the node (a node move carries its files; storage re-key constraint flagged for step 2) · Workspace cloud lives in the Workspace Portal's Files zone (Configure ▸ Workspace); roster stays a lens (aggregate-view-up filter, no cloud of its own).
- doc-54 §3.1 gains the tile contract (per tile: on-tile references · drill-in content · source; tiles never grow the screen — displacement by priority) — answers "confirm/envision/align the tiles."
- NEW doc-55
55-TENANCY-PERSONAS.md(envisioning) — Workspace = tenant (three sanctioned crossings: migration/move · the person layer · link/guest principals) + one person, many hats: sub-accounts rejected; one User + one Contact ID +WorkspaceMembership(role, facet), the card wears the hats under per-facet visibility lenses. Roadmap §8 row added.
- Track Portal Files zone SHIPPED — the node's document library (doc-45 asset cloud, track altitude):
- Home v2 BUILT — non-scrollable one-screen dashboard + whole-hierarchy "+" (iOS) (2026-07-04, Claude Code;
xcodebuildBUILD SUCCEEDED). Implements the greenlit doc-54 §3.1/§8.1 slice:DashboardViewis no longer a ScrollView — a fixed one-screen canvas: brand header · pipeline strip (horizontal chips stay — the philosophy bans vertical scroll) · a 2-column attention tile grid replacing the stacked row lists. Tiles (render only when non-empty, priority order): Approvals (role-aware title, count + top item) · Resume (new context-aware continuity tile — last-opened track w/ artwork, zoom transition + audio prewarm + hierarchy long-press; closes the doc-47 §11 field feedback) · Open notes · Activity (unread-count aware). Row lists moved wholly into the See-all screens — the only scroll escape. AX policy stated in code: accessibility Dynamic Type sizes re-enable scrolling; Home reloads on tab-return (pull-to-refresh only in the AX fallback). NewDesignSystem/Components/DashboardTile.swift(shares the pipeline-chip card grammar).- The trailing "+" is now the whole-hierarchy create menu (doc-37 §5 queued item, owner-confirmed dimensions): New artist / New album / New track — album/track run a pick-parent flow (new
Features/Shell/HierarchyCreateSheet.swift: artist → [album] pickers, then the existing create sheets), landing on the new node in Browse via two newAppRouterdeep-links (openProjectInBrowse,openTrackInBrowse). "New version" joins when audio upload lands from Home (doc-25 staged) — the menu grows additively. - Visual QA checklist left in
.agent/next-brief.md(layout-critical routing rule).
- UX/UI review — start simple, many ways of working (doc-54) (2026-07-04, Claude Code; docs-only pass). Owner + stakeholder session consolidated into
54-UX-REVIEW-START-SIMPLE.md: two principles locked into doc-47 §7 (7 start almost empty — "the minimum that provides basic/common value as a starting point"; empty structure never renders · 8 many ways of working — structure offered, never demanded) · Home v2 = non-scrollable one screen (attention tiles + See-all drill-ins as the only scroll escape; new context-aware Resume tile; budget-verified ~400–430pt; AX Dynamic Type exception stated — resolves doc-47's logged field feedback) · new Contact tab (5th tab, extreme right; naming open): Contact ID + account + Discovery entry top-right (absorbsAppMenuDrawer) · doc-49 §7 fork 4 resolved (portals start almost empty: no-content zones don't render; one "+" foot affordance) · doc-43 sharpened (quick-add "+" = the primary authoring; minimal preset default; free-tier preset picks;[untitled]= the doc-15 competitor as the start-easy bar) · progressive Configure onboarding (doc-48; no wizard) · mgmt.ctrl confirmed under pluralism (no doc-42 change) · Event = card + sheet (PortalZone-compatible), no v1 Portal + contact↔event cross-refs (attending · registered · attended) into docs 52/53. Build order proposed (Home v2 → Contact tab shell → portal empty-state → LEGO "+"), owner greenlights before any code.- Owner decisions resolved (answers the 2026-07-03 entry's ⚠️):
reference_assets/committed and pushed as-is (doc-51 note closed) · handlesonnance.com/@usernameapproved (doc-53 §6 Q1, pending doc-21 namespace reconciliation). - Cross-refs updated: pointer blockquotes in docs 24 · 37 · 43 · 47 · 48 · 49 · 50 · 51 · 52 · 53;
00-ROADMAP.md§8 row (re-aligned 2026-07-04). - ✅ GREENLIT + addendum (doc-54 §8, same day): owner greenlit the build order and added design input, captured as options w/ pros-cons — Home "+" = whole-hierarchy create menu (confirms doc-37 §5; hierarchy-only v1, grows additively) · Home top-left = nothing (avatar interim until the Contact tab) · identity-corner inner IA: zoned tab recommended (Option B — Identity · Contacts · Messages · Discovery zones, start-almost-empty; owner's corner-entry sketch retained; Messaging threads + contact list situated → docs 52/53 pointers) · tab-name candidates + Me / World · event organizers = same app, role-driven (doc-44 event-scoped grants; Hosting lens + Contact ID hosting relation; mgmt.ctrl events lens as growth path — never a separate app). Slice 1 (Home v2) unblocked; slice 2 (Contact tab) waits on the §8.3 shape pick.
- Owner decisions resolved (answers the 2026-07-03 entry's ⚠️):
- Release vision registered — Contact ID + Discovery/Events elevated to headline features; Apple Invites UX study (2026-07-03, Claude Code; docs-only pass). Owner + stakeholder: users need features available, not a half-done product — first-release framing upgrades MVP → Release vision (loop + two headline features). Three new docs:
- doc-51
51-INVITES-UX-REFERENCE.md— the owner's 38 Apple Invites screenshots (infrastructure/reference_assets/, previously unreferenced) fully indexed + 20 stable patternsINV-01…INV-20with Adopt/Adapt/Skip verdicts (card anatomy · pinned RSVP · timespan/role lenses · dual-mode inviting · link expiry/rotation/kill-switch · QR-as-peer · tiles link-hub · asymmetric visibility · per-object privacy panel · minimal contact consent · pre-permission pitches). ⚠️ Owner decision needed:reference_assets/≈ 77 MB, untracked — Git LFS / commit / local-only. - doc-52
52-DISCOVERY-EVENTS.md(envisioning) — first dedicated Discovery/Events doc; the Event = timespan-bound node with momentum stages (draft→announced→approaching→live→afterglow→ archive) leading with a request-for-action (RSVP); consolidates + supersedes the doc-17/15 fragments (Event Organizer actor, DiscoveryFeed/MapView, Maps, Discovery Agent); sharing rides doc-26 links (+ the new one-time per-guest link idea); Event-as-Portal fork left open. - doc-53
53-CONTACTID-RELEASE.md— Contact ID release-era spec, supersedes doc-16's web-first framing (card visuals stay reference): fills the named gap of an identity privacy philosophy (owner-private by default · opt-in per field · outsider view = configured lens — extends doc-38/26 posture to people) + capabilities (graph-gated messaging · contacts graph w/ "who has me" only-me · opt-in graph-derived stats · Apple Wallet.pkpassQR card · Linktree-style tiles). - Cross-refs updated:
00-ROADMAP.md(re-aligned 2026-07-03: §5 Release-vision blockquote, §7 Release-headline slot, §8 map rows 51–53 + doc-16 note) · doc-47 §10.3/§11 pointers → docs 52/53 · doc-16 banner → doc-53 ·infrastructure/README.mdindex rows (+ pointer to the full §8 map).
- doc-51
- doc-45 asset cloud envisioned + Workspace Portal actionable (iOS) (2026-06-30, Claude Code;
xcodebuildBUILD SUCCEEDED). Two owner observations: "where do I find the files/folders cloud per hierarchy node?" and "the Workspace Portal shows rosters but has no actions."- Grounded finding + doc-45 design pass — Track attachments already exist (
Track.attachments= folder-structured Lyrics/Stems/Contracts + custom;GET/POST /track/:id/attachments) but have no iOS surface — that's why files felt missing. Nothing at Project/Artist/Workspace. Added a "asset cloud across the hierarchy" section to doc-45: one node-attachedFile(type:'attachment', resource, folder)model at every altitude, surfaced as a "Files" zone in every Portal (doc-49); attach-at-node with aggregate-view-up (a Project/Workspace cloud rolls up descendants). Owner chose envision-first; smallest first build = surface the existing Track attachments. (The File Portal ≠ the cloud — it's a single version's detail.) - Workspace Portal — first action (doc-50 action-first): the Roster zone's artists are now tappable → open in Browse (
router.openArtistInBrowse), so you can drill from the studio root down into an artist. (Manage-default + create affordances are noted next.)
- Grounded finding + doc-45 design pass — Track attachments already exist (
- doc-49 slice 5 — the File Portal: the leaf altitude (iOS) (2026-06-30, Claude Code;
xcodebuildBUILD SUCCEEDED). The fifth and final altitude — every node in the hierarchy now has a Portal: Workspace → Artist → Project → Track → File, all the samePortalZonegrammar, top to leaf.- New
Features/Review/FilePortalContent.swift— a single version/asset's detail: Identity (Version N + name + review pill + FINAL badge) · Asset (image preview for covers + metadata: version · type · length · size) · Review (doc-29 status, read-only — acting stays in the player's approve bar, so the leaf doesn't duplicate the write path) · Notes (.file-anchored, doc-33, collapsed). - Entry: long-press a version in the player's version switcher → "Version details" → the File Portal (deferred sheet-swap to avoid racing two sheets). No new backend (uses
MediaFilealready inTrackDetail.audio[]). Composed only from DesignSystem. - 🎉 The Portal fractal is fully complete — all five altitudes share one anatomy. Remaining doc-49 polish: full zone-header normalization + extract shared zone components · more discoverable File-Portal entry · doc-28 variant authoring · asset cloud (doc-45).
- New
- doc-49 slice 4 — the Workspace root Portal: the "cloud root space" (iOS) (2026-06-30, Claude Code;
xcodebuildBUILD SUCCEEDED). The last altitude — completes the fractal Workspace → Artist → Project → Track. The studio's root, the "cloud space" the owner asked about, built from the samePortalZoneanatomy.- New
Features/Configure/WorkspacePortalView.swift— a full-screen Configure destination (not a sheet, doc-49 §7 Q1) reached from Configure ▸ Workspace (flipped from Soon → live). Zones: Identity (studio name) · Portfolio (a KPI grid — artists · projects · tracks · to-approve · ready · open-notes) · Studio defaults (the doc-48 default workflow name + where to change it) · Roster (collapsed — rosters with artist counts + names, the roster-as-lens at the root). - No new backend — composes
/management/overview+/workspace+/workflow/templates+/management/matrix(parallel best-effort). Clarifies doc-48/49: Roster = a lens shown inside the Workspace Portal, not its own portal. - Fractal complete: all four object altitudes now share the zone anatomy. Remaining: full zone-header normalization + extract shared zone components · File Portal · doc-28 variant authoring · asset cloud (doc-45).
- Fixed a Swift type-check timeout in
studioName(de-sugared the nested optional/closure chain).
- New
- doc-49 slice 3 — Track Portal onto the anatomy + doc-28 variant chips surfaced (iOS) (2026-06-30, Claude Code;
xcodebuildBUILD SUCCEEDED). Two wins in one. Audit correction: the Track Portal did exist (Features/Review/TrackPortalContent.swift, presented from the player) — the doc-49 audit missed it (only surveyedBrowse/); it was the same flat wall.- Re-grouped onto the anatomy — Identity (header + metadata) + Content (cover + label copy) visible; Variants & links · Sharing · Notes · Manage collapsed (progressive disclosure).
- doc-28 variant chips — finally on iOS (the long-standing "iOS not yet"): a new Variants & links zone self-fetches
GET /track/:id/relationsand shows thevariantTypechip (Original / Radio Edit / Remix / …), cross-links (Remix of: …, Alternate of: …, with the related track's variant tag), and "Also on: {album}" multi-album appearances. NewCore/Models/TrackRelations.swift+TrackVariantsView(read-only chips; authoring/tap-to-hop is a later doc-28 slice). Composed only from DesignSystem. - Fractal status: Project · Artist · Track now share the zone anatomy; Workspace root is the last altitude (+ full zone-header normalization · File Portal).
- doc-49 slice 2 — Artist Portal onto the anatomy (iOS) (2026-06-30, Claude Code;
xcodebuildBUILD SUCCEEDED). RolledPortalZoneto the Artist Portal — same clean grammar as Project: Identity (header + metadata) + artist art visible; Team & sharing · Notes · My tag (viewer) · Manage collapsed. Stripped the redundant "MY TAG" inline header. Two of the existing portals now share the anatomy; Track Portal (net-new) + Workspace root are the remaining altitudes.- ⚠ Field note captured (owner, 2026-06-30): the Project Portal's "Cover approved 1/5" counts cover per-track, contradicting the locked "cover = release/project art" decision (doc-28). Logged in doc-28 §Cover as a reconcile-later (Album/Compilation → project-level cover step; Single → per-track is fine). Not fixed this pass (owner: "consider afterwards").
- doc-49 slice 1 — the Portal anatomy:
PortalZone+ Project Portal de-cluttered (iOS) (2026-06-30, Claude Code;xcodebuildBUILD SUCCEEDED). First slice of the Portal redesign — kills the "messy 11-section wall" the owner flagged.- New
DesignSystem/Components/PortalZone.swift— the doc-49 §3 anatomy primitive: an ordered, optionally-collapsible zone (header = tap-to-toggle row with a rotating chevron + optional glyph/ accessory; smooth expand). Primary zones non-collapsible/expanded; secondary zones collapsed by default — progressive disclosure, calm by default (doc-47 §7). The shared building block that makes "one grammar at every altitude" real in code. - Project Portal re-grouped into the anatomy order (
ProjectPortalContent): Identity (header + metadata) · Health (release readiness) · Content (tracklist + album art) · Workflow — all visible; then collapsed groups Team & sharing (team + share + team-share) · Notes · My tag (viewer) · Manage/Danger (delete). Eleven stacked sections → ~5 visible + tidy collapsed groups. Stripped the now-redundant "MY TAG" inline header (the zone owns it). - Reference implementation for the fractal: next slices roll
PortalZoneto Artist + Track (Track becomes a Portal) and the Workspace root, and extract the truly-shared zone components.
- New
- doc-50/42 — work board: inline "advance" (act where the work sits) (iOS) (2026-06-29, Claude Code;
xcodebuildBUILD SUCCEEDED). The paradigm-B promise made real — you can now move a project forward without leaving the board. Each card whose blocking step is project-level gets an inline action button — Approve / Mark done / Mark added / Decide / Reviewed (verb per step type) — that PATCHes/project/:id/readinessto the step's satisfied value, then reloads so the project re-buckets to its next stage live. No new backend (reuses the doc-46 readiness verb; owner/admin-gated server-side — graceful "you may not have permission" on 403).- Track-level blocking steps stay read-only here (they live per-track, in the Queue/Track surfaces) — only project-level steps are advanceable from the board, via
ManagementBoardItem.advanceableStep. - Board card restructured into two tap targets (no nested buttons): body → opens the project; trailing → the Advance button (or a chevron when not advanceable).
MgmtCtrlViewModel.advance()+advancingProjectIdspinner.
- Track-level blocking steps stay read-only here (they live per-track, in the Queue/Track surfaces) — only project-level steps are advanceable from the board, via
- doc-50/42 — work board: Home→Board deep-link + "what's blocking" per project (iOS) (2026-06-29, Claude Code;
xcodebuildBUILD SUCCEEDED ×2). Two refinements that make the action-first flow feel intentional.- Cross-segment deep-link — the Home pipeline chip now lands directly on the Manage ▸ Board (not the Overview):
AppRoutergainedmanageMode+goManage(mode:);MgmtCtrlViewconsumes it (.onChange/.onAppear) to switch its segment, then clears it. So Home strip → tap → the work board is one seamless hop. - "What's blocking" on each board card — every project now shows
Next: {first unsatisfied step}(accent) — e.g. Next: Mastering sign-off — surfacing what to do to advance it, not just audio/cover counts.ManagementBoardItem.blockingStepwalks the (already-present) stage→step rollup; no backend change. - Still open: truly inline act-from-the-board (approve/advance without leaving) — limited until a project-level approve verb exists; the per-track Queue covers approvals today.
- Cross-segment deep-link — the Home pipeline chip now lands directly on the Manage ▸ Board (not the Overview):
- doc-50/42 — Action-first work board: Manage Board grouped by workflow stage (iOS) (2026-06-29, Claude Code;
xcodebuildBUILD SUCCEEDED). The richer paradigm-B surface the Home pipeline strip taps into — the Manage ▸ Board went from a flat "release readiness" list to a real work board: projects grouped by their current (first-unsatisfied) workflow stage (stage header + count, in stage order, Ready last). Each project card is now tappable → opens the project (ProjectRouteon the Manage stack's shared browse grammar) so you can act from where the work sits. No backend change (reuses the cascade-aware/management/board+currentStageLabel). Light polish noted: the Home pipeline chip lands on the Manage tab (Overview), not yet deep-linked to the Board segment — cross-segment routing is a follow-up. - doc-50/37 — Action-first center of gravity LOCKED + first slice: Home pipeline strip (iOS) (2026-06-29, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner chose Action-first (B/C) as the app's primary paradigm: lead with the work + decisions, not the catalog tree. Decision recorded (doc-50 §6) + reconciled — doc-37 Home promoted from sibling to protagonist, doc-47 §10.2 + doc-49 annotated (Portals = the substrate you land on when you act, not the daily entry; backbone kept, not discarded).- First build slice — a "PIPELINE" strip on Home (doc-37 protagonist): a horizontal row of stage chips (e.g. Recording 3 · Mixing 2 · Approval 5 · Ready 4) showing where the catalog's work sits by workflow stage, right under the wordmark. Each project counts toward its current (first-unsatisfied) stage; ready projects bucket into "Ready". Taps through to the Manage board (
router.go(.manage)). - No backend change — reads the existing
/management/board(the cascade-aware readiness already carries per-projectstages, doc-48);ManagementBoardItemgainedstages+ acurrentStageLabelhelper;DashboardViewModelbest-effort-fetches the board + aggregates thepipeline. Composed only from DesignSystem. - Next slices (action-first build-out): richer work-board view (full stage columns / drill to the projects in a stage) · then the doc-49 Portal redesign as the substrate · ⌘K accelerator (later).
- First build slice — a "PIPELINE" strip on Home (doc-37 protagonist): a horizontal row of stage chips (e.g. Recording 3 · Mixing 2 · Approval 5 · Ready 4) showing where the catalog's work sits by workflow stage, right under the wordmark. Each project counts toward its current (first-unsatisfied) stage; ready projects bucket into "Ready". Taps through to the Manage board (
- doc-50 — UX paradigm options: what's the right center of gravity? (envisioning/decision-support) (2026-06-29, Claude Code; owner-raised — "envision more options to confirm we're on the greatest track, UI+UX, without much detail"). A deliberate step back before investing in the doc-47/49 build. Frames the "center of gravity" lens (every app has one primary paradigm + secondary lenses) and a 6-option menu: A Object/Spatial (current — open into the catalog tree) · B Workflow/Board (open into a stages board, move work forward) · C Activity/Feed (open into what-needs-you, act inline) · D Command/Search (⌘K intent bar) · E Document/Canvas (composable, LEGO-first) · F Player/Media (library+player, craft-first). Evaluation vs north star (MVP = Review & Approve, doc-19; differentiator = workflow, doc-46; B2B calm craft, doc-31): the heart is move-work/decide (B/C) + craft (F), while A is the substrate, not the daily entry — an A-primary app makes a busy manager hunt for what needs them. Recommendation: keep the fractal-Portal backbone (doc-49) but make the primary action-first (B/C) — promote doc-37 Home from sibling to protagonist + a light cross-catalog work-board; portals/tree become the substrate you drop into; ⌘K (D) as accelerator; Canvas/LEGO (E) as the post-PMF ceiling. Invalidates none of the backbone already built — re-frames sequencing only. §6 = the center-of-gravity choice to confirm. Registered roadmap §8; defers to
00-ROADMAP.md. No code this pass. - doc-49 — The Portal System: one fractal, clean anatomy at every altitude (envisioning/redesign) (2026-06-29, Claude Code; owner-raised). The owner's two observations — "the Project Portal is messy/ complex, needs a redesign" and "I don't see portals at Workspace/Roster, Artist, Track, Configure" — are one root cause: there is no Portal system. Verified audit:
Portal.swiftis a bare sheet+VStack;ProjectPortalContentflat-stacks ~11 heterogeneous sections (no hierarchy/disclosure → the mess); Artist/Project hand-duplicate team/share/notes/my-tag/delete; Track/Workspace/Roster have no Portal → the doc-47 §6 fractal principle ("one grammar at every altitude") was never built. The fix: one Portal anatomy = 8 ordered zones (Identity · Health · Content · Workflow/ Config · Relationships · Notes · Personal · Manage/Danger) with progressive disclosure (summary→expand, calm by default); only the Content zone varies by altitude → a true fractal, not 8 screens. Owner decisions: (1) envision first (this doc); (2) full ladder incl. Workspace root — Workspace ("cloud root space") → Artist → Project → Track → File. Clarified Roster = tag/lens (no own portal, a grouping inside the Workspace Portal + a Browse lens); Workspace = tenant root gets the root portal. Structural fix = a sharedPortalScaffold+ zone components (kills the per-entity duplication = doc-47 §6 in code). Build: scaffold → redesign Project Portal (reference, fixes the mess) first → roll to Artist+Track (Track becomes a Portal) → Workspace root → File. UI-layer over mostly-existing endpoints (net-new backend small = doc-45 asset cloud, deferred). §7 forks open (sheet-vs-fullscreen · Workspace portal home · Track-portal-vs-player · disclosure aggressiveness). Registered roadmap §8; defers to00-ROADMAP.md. No code this pass. - doc-48 — studio default workflow: the iOS surface (set it + see inherited-vs-custom) (2026-06-29, Claude Code;
nest build+xcodebuildBUILD SUCCEEDED). Makes the cascade usable + visible — you can now pin a studio default and see, on each project, whether its process is inherited or custom.- Configure ▸ Workflow — the list shows a STUDIO DEFAULT badge (and a SYSTEM badge on the shipped default); the editor gained a star control "Set as studio default" / "Studio default — every project inherits this" (tap to clear). Works for any template incl. the read-only system one. VM loads
GET /workspaceand writesPATCH /workspace/default-workflow. - Project Portal — the workflow section now shows the source (
Custom for this project·Inherited · studio default·Default process) and a "Change" menu with Apply a template plus, when custom, "Reset to studio default" →DELETE /project/:id/workflow(clears the local instance → cascade governs again). - Small backend support added:
release-readinessnow returnsworkflowSource(project/studio/system) via a newProjectService.resolveWorkflowMeta; newDELETE /project/:id/workflow(resetWorkflow, owner/admin).resolveWorkflowStructurenow delegates to…Meta(mgmt unchanged).nest buildclean. - New
Core/Models/WorkspaceSettings.swift;ReleaseReadinessgainedworkflowSource. The doc-48 studio-default loop is now end-to-end on iOS. Remaining: Artist override (rung 2) · attributes (doc-43) · web parity.
- Configure ▸ Workflow — the list shows a STUDIO DEFAULT badge (and a SYSTEM badge on the shipped default); the editor gained a star control "Set as studio default" / "Studio default — every project inherits this" (tap to clear). Works for any template incl. the read-only system one. VM loads
- doc-48 — studio-wide workflow cascade: backend (the workspace/owner default) (2026-06-29, Claude Code;
nest build+jest75/75 + workflow e2e 5/5 green). The owner's #1 from doc-48 — "my studio works this way" — now resolves automatically: a project with no own workflow inherits the studio default with zero setup.- New
workspacemodule (the forward-compatible seed of the doc-44 Workspace container) —Workspacedoc keyed 1:1 byownerwithdefaultWorkflowTemplateId.GET /workspace(my settings) ·PATCH /workspace/default-workflow {templateId}(set/clear; validates the template resolves for the owner). JWT-gated. - The cascade —
ProjectService.resolveWorkflowStructure(project)walks project.workflow ▸ owner's studio default ▸ system default (a since-deleted default falls through).release-readinessresolves it and passes it tocomputeReleaseReadiness(which gained an optionalstructureOverrideand staysthis-free, so the unit specs are untouched). Same most-specific-wins shape as doc-44 perms / doc-41 status. - e2e (in-memory Mongo) — set a studio default → a brand-new project's readiness resolves to that structure (not the system one); clearing the default falls back. +1 test (now 5/5).
mgmt.ctrlnow reflects the cascade too —ManagementService.computeBoard+buildMatrixresolve each project's structure viaresolveWorkflowStructure(project ▸ studio default ▸ system) before the readiness rollup, so the Board / Matrix / Overview "release-ready" counts honour the studio default instead of always falling to system.jest75/75 (spec mock gainedresolveWorkflowStructure).- Not yet: the iOS surfacing — "Set as studio default" in Configure ▸ Workflow + "Inherited vs Custom / Reset" on the Project Portal (next slice) · Artist-level override (rung 2) · attributes on the same rails (doc-43). (Per-project resolve in the eagle-view loop is N small indexed look-ups — batch by owner if it ever shows up in profiling.)
- New
- doc-48 — Configuration across the hierarchy (consolidation/envisioning) (2026-06-29, Claude Code; owner-raised). Unifies the owner's two open uncertainties — workflow across Roster→Variant and LEGO attributes across the hierarchy — into one model: define-at · apply-at · resolve (cascade + override). Verified finding: authoring is workspace-scoped (templates) but application is Project-only today — no cascade; every project must be instantiated individually. Real container ladder = Workspace → ArtistSpace → Project → Track; Roster = a tag/lens and Variant = a predicate (neither is a node). Resolve walks most-specific-wins up the ladder — the same rule as doc-44 permissions and doc-41 status. Owner decisions: (1) envision first (this doc), then build; (2) priority = the studio-wide workflow cascade —
resolveWorkflow(project) = project.workflow ?? artist default ?? workspace default ?? system, so a new project inherits the studio process with zero setup; "set as studio default" verb + Portal "inherited vs custom / reset". Attributes (doc-43) ride the same rails; surfacing splits Configure (define) / Browse + Manage (apply + view) — the Configure "Soon" bubbles map here. §8 forks open (where the default lives — Workspace vs Artist · cascade depth · override granularity · build the Workspace container now vs interim-store). Registered roadmap §8; defers to00-ROADMAP.md. No code this pass. - doc-46 — workflow editor: native drag-reorder + approver-role authoring (iOS) (2026-06-29, Claude Code;
xcodebuildBUILD SUCCEEDED). Completes the two authoring gaps the owner flagged.- Editor rebuilt on
List— steps now drag-reorder via the native gesture (toolbar Edit), swipe-to-delete, and the title/duplicate stay above the list. StageSections keep their rename/move/delete menu; cross-stage move + per-step delete stay in the step "⋯" menu. Read-only default uses.moveDisabled/.deleteDisabled. - Approver role per step — each step carries an approver/owner role (Owner/Admin/Editor/Viewer · doc-44, or Anyone), set from an inline accent chip-menu and in the Add-step sheet. Persists in the template's
approverRole(already on the wire model) and surfaces as the step'sownerRolein the readiness rollup → the Project-Portal pipeline already shows it. - Not yet: per-variant
appliesToVariants· config across other hierarchy dimensions (workflow anchors at Project only today) · the LEGO attribute engine (doc-43) · web parity (parked).
- Editor rebuilt on
- doc-46 — workflow editor depth: stage CRUD + reordering (iOS) (2026-06-29, Claude Code;
xcodebuildBUILD SUCCEEDED). The template editor is now a full process-builder, not just a required-toggle.- Steps reorder & move — each step's "⋯" menu gained Move up / Move down (within its stage, edge-disabled) and Move to stage → (a submenu listing the other stages) alongside Delete. Order is preserved on save (PATCH replaces
stages). - Stages are now authorable — each stage header has a "⋯" menu (Rename via alert · Move up/down · Delete), and a bottom "Add stage" button creates a new (empty) stage to fill. Collision-free
stage_…keys generated client-side (mirrors the step key-gen). - Model helper
WorkflowTemplate.uniqueStageKey(for:)+ refactored shared key-gen. The whole structure (stage order · step order · membership · labels · required) round-trips through the existingPATCH /workflow/templates/:id. Composed only from DesignSystem. - Not yet: true drag-gesture reorder (menu move up/down is the mechanism for now) ·
approverRole/ assignee authoring · per-variantappliesToVariants· web parity.
- Steps reorder & move — each step's "⋯" menu gained Move up / Move down (within its stage, edge-disabled) and Move to stage → (a submenu listing the other stages) alongside Delete. Order is preserved on save (PATCH replaces
- doc-46/47 — workflow authoring UX polish + Home field-feedback captured (iOS) (2026-06-28, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner field feedback ("can't apply / can't rename") was a discoverability gap — a new user only has the read-only default template, and the rename path (duplicate → edit) wasn't obvious. Fixed:- Duplicate-to-customize is now prominent — the read-only default's editor shows a primary "Duplicate to customize" button (not a buried toolbar item), and duplicating now pushes straight into the editable copy (
onDuplicated→path.append) so you land ready to rename + tailor, instead of popping back to the list. Editor copy clarified ("Apply it to a project from that project's portal"). - Apply control clarified — the Project-Portal affordance relabelled "Apply a template" (flowchart glyph) so the instantiate action reads as an action.
- Captured (deferred, owner) — Home / front-door rethink: leading with pending-approvals + open-notes isn't always the right landing when actively working inside an artist/project (owner had to switch tabs); Home may need to be context-aware / remember last zone, and Home scroll behaviour needs a look. Logged in doc-47 §11 "Field feedback" to design before building (informs a future doc-37 revision) — not built this pass.
- Duplicate-to-customize is now prominent — the read-only default's editor shows a primary "Duplicate to customize" button (not a buried toolbar item), and duplicating now pushes straight into the editable copy (
- doc-46 — instantiate-a-template on the project: the author→apply loop closed (iOS) (2026-06-28, Claude Code;
xcodebuildBUILD SUCCEEDED). The missing link between authoring a workflow (Configure ▸ Workflow) and running it (the Project-Portal pipeline): owner/admins can now apply a template to a project from the portal itself.WorkflowPipelineSectiongained a "Use template" menu (owner/admin) listing the available templates (default + the user's own); selecting one callsPOST /project/:id/workflow/instantiatewith{templateId}, which resolves the template's structure server-side intoproject.workflow.stages, then the portal re-fetchesrelease-readinessso the pipeline + readiness pill recompute over the new structure. Per-step statuses keyed by a surviving step are preserved.ProjectPortalContentbest-effort-fetches/workflow/templatesalongside readiness (menu just doesn't appear if it fails) +commitInstantiate. NewInstantiateWorkflowRequestwire type.- End-to-end now: author in Configure → apply on the project → the doc-46 pipeline renders & rolls up that exact structure. Not yet: template drag-reorder / stage add-remove · per-variant
appliesToVariants(doc-28variantTypeexists) · web parity.
- doc-46/47 — Workflow template authoring: the first real Configure citizen (iOS) (2026-06-28, Claude Code;
xcodebuildBUILD SUCCEEDED). The Configure zone now does something — its top row flips from Soon to live. Studios can author the doc-46 workflow they instantiate on projects, entirely from the app, over the existing/workflow/templatesCRUD (no new endpoints).- List (
Features/Configure/WorkflowTemplatesView.swift) — the read-only Studio Release (default) and the user's own templates, each showingstages · steps · requiredcounts; + creates a new template seeded from the default's structure (the recommended starting point) and opens its editor. - Editor (
WorkflowTemplateEditorView.swift) — view any template; for a custom one: rename, toggle each step'srequired, edit a step's label, add a custom step to a stage (name + type picker overapproval/task/asset/decision/reviewwith plain-language hints, plus a project-vs-track level), delete a step, Save (PATCH replacesstages), or delete the template. The system default is read-only with a Duplicate to customize action. Unique custom step keys generated client-side (custom_…, collision-free). - Models
Core/Models/WorkflowTemplate.swift(WorkflowTemplate/Stage/Step+ request body + key-gen helpers);ConfigureItem.workflownowisReady→ routes to the list. Composed only from DesignSystem. - Not yet: stage add/remove + drag-reorder of steps/stages (stages come seeded from the default this slice) ·
approverRole/assignee authoring · instantiate-a-template picker on the project (thePOST /project/:id/workflow/instantiateconsumer — pairs with the doc-46 pipeline already on the Project Portal).
- List (
- doc-47 slice 1b — declutter Home + cross-tab routing (iOS) (2026-06-28, Claude Code;
xcodebuildBUILD SUCCEEDED). Makes the new tab shell feel intentional rather than layered-on.- Home is now just the front door. Removed the quick-access tile row (Browse/Manage/Configure are tabs now) —
DashboardViewstays focused on "what needs me" (approvals · notes · activity · recents), not a launcher. The avatar drawer trimmed to the account corner + overflow: dropped the redundant Workspace group (mgmt.ctrl/artists/projects/tracks/teams — all zones now), keeping only Library (Shared · Recents, which have no tab home) + the You section (appearance · sign out). AppRouter(new, inRootTabsView) — a@MainActor @Observablecross-tab coordinator owning the selectedzone+ the Browse path, injected into the environment. Lets a surface in one tab switch tabs and deep-link in one gesture. First use: create-artist (Home toolbar/empty-hero) nowrouter.openArtistInBrowse(id:)→ hops to the Browse tab and opens the new artist there, instead of pushing a redundant copy inside Home's stack.BrowseZoneViewroots itsNavigationStackonrouter.browsePath.- Not yet: more cross-tab entry points as they arise · the first real Configure citizen (workflow template authoring, doc-46) · Roster Portal · account "own corner" / Contact ID.
- Home is now just the front door. Removed the quick-access tile row (Browse/Manage/Configure are tabs now) —
- doc-47 slice 1 — the three-zone tab shell (iOS) (2026-06-28, Claude Code;
xcodebuildBUILD SUCCEEDED). The north-star made navigable: the signed-in root is now a tab bar (doc-47 §10.1) — Home (the front-door "what needs me" sibling, doc-37) · Browse (catalog) · Manage (mgmt.ctrl x-ray, doc-42) · Configure (the control room, formerly with no surface at all). Purely additive —DashboardView(Home) left fully intact, so nothing regressed; the zones appear alongside it.RootTabsViewrewritten as a 4-tabTabView(AppZoneenum) — reversing its old "deliberately NOT a tab bar" stance per the owner's doc-47 decision. Each zone owns its ownNavigationStack+ path + zoom namespace.- New
Features/Shell/BrowseDestinations.swift— the shared.browseDestinations(path:zoomNS:)modifier installs the four route destinations (String/track → artist → project → track, doc-36 zoom transitions) once, so Browse and Manage drill the hierarchy identically ("one grammar at every altitude", doc-47 §6) instead of per-stack copies. Browse rootsArtistsHomeView; Manage rootsMgmtCtrlView. - New
Features/Configure/ConfigureView.swift— the Configure zone as an honest thin shell (doc-47 §11): lists the control-room citizens (Workflow templates · Teams & roles · Attributes · Workspace) with what each is; Teams routes to the realTeamsView, the rest carry a Soon pill (their authoring UIs are named later slices). Composed only from DesignSystem. - Not yet (named follow-ups): declutter Home's now-redundant quick-tiles/drawer groups (Browse/Manage are tabs now) · cross-tab deep-linking (tap a Home shortcut → switch tab vs push-in-place) · the first real Configure citizen = workflow template authoring (doc-46's next step) · Roster Portal · account "own corner" / Contact ID.
- doc-47 — Product North Star: three zones + one navigation grammar (consolidation/envisioning) (2026-06-28, Claude Code; owner-raised). A step back to consolidate the whole product so surfaces stop being bolted on. Diagnosis: today = object-first Portals (bottom-up) + a bolted-on Manage tab (top-down), with no orientation/intent layer and no home for the configuration substrate (workflow/attributes/teams). 2 decisions locked with owner: (1) navigation model = three zones + fractal Portals — Browse (catalog; one Portal grammar at every altitude — roster→artist→project→ track→file, roster finally gets its Portal) · Manage (top-down x-ray = doc-42 + doc-38) · Configure (the missing control room = doc-46 templates · doc-43 attributes · doc-44 teams/roles · account); (2) capture as a north-star doc first, then build. The front door ("what needs me", doc-37) = role-driven landing over the zones, not a 4th zone. Doc invents nothing — places every prior doc (reconciliation map §8) and adds the actors×actions matrix (§4) + 6 design principles (intent-first · one grammar at every altitude · calm→dense · configuration-as-a-place · one-surface-role-driven · minimum-taps). The real gap surfaced: Configure has no surface today. §10 forks open (zone-switcher mechanism · front-door↔Manage merge · workspace container · MVP subset). Registered in roadmap §8; defers to
00-ROADMAP.mdon conflict. No code this pass. - doc-46 — workflow engine iOS surface: the release pipeline in the Project Portal (2026-06-28, Claude Code;
xcodebuildBUILD SUCCEEDED). First time the workflow engine is visible to users — the deep backend (stages → steps, per-type satisfaction, configurable structure) now renders as a pipeline on the Project Portal, driven by the sameGET /project/:id/release-readinessrollup the portal already fetched. No new endpoints.- New
Features/Browse/WorkflowPipelineSection.swift— ordered stage cards (preproduction → recording → production → rights → release) each with a satisfied progress bar and its steps; track-level steps show an approved count (x/total), project-level / custom steps show a per-type state chip. Composed only from DesignSystem tokens (SnSpacing/SnRadius/Color.sn*). - Inline step authoring — owner/admins set a project-level step's status from a per-type menu (
approval/task/asset/decision/revieweach carry their own state vocabulary, mirrored fromworkflow.types.tsSTEP_STATES/STEP_SATISFIED) →PATCH /project/:id/readiness, then the rollup re-fetches so counts + the readiness pill recompute.tracklistis read-only here (it owns the dedicated Tracklist section; the endpoint rejects it). - New
Core/Models/WorkflowModels.swift(WorkflowStepTypewith states/satisfied/glyph,WorkflowReadinessStage/WorkflowReadinessStep,UpdateProjectReadinessItemRequest) +ReleaseReadinessextended with optionalstages/items(back-compat). Wired intoProjectPortalContentbelow the release-gate headline. - Not yet: workflow template/instance authoring on iOS (instantiate from a template, add/rename/ remove steps — backend
POST :id/workflow/instantiate+PATCH :id/workflowexist) · per-variantappliesToVariants(doc-28variantTypenow exists) · web parity.
- New
- doc-28 Option C — recording variants + cross-references + multi-album (backend) (2026-06-28, Claude Code;
nest build+jest75/75 + e2e 3/3 green, lint clean). The pragmatic middle the doc locked: models remixes / radio-edits / explicit-clean / live + "a track on multiple albums" without a full Release/Work remodel. Backend-contract-first (iOS chips/lenses follow). Also unblocks doc-46's per-variant (now there's avariantTypeto evaluateappliesToVariantsagainst).- Track schema (doc-28 Option C):
variantTypeenum (originaldefault · radioEdit · explicit · clean · instrumental · remix · live) ·relations: [{track, type}](alternateOf·remixOf·sameRecordingAs) ·appearsInProjects: [projectId](SECONDARY membership; the canonicalprojectstays home). Newmodels/variant.type.ts. - Mirror-write logic (
TrackService) —addRelation/removeRelationwrite both tracks so a cross-link is navigable forward AND back (symmetric types same;remixOfmirrors the same type for now — inverse "remixedBy" label is a noted refinement).addAppearance/removeAppearance(guards the home project) ·getRelationsresolves variant + cross-links + appearances for the chips. Writes gated to owner/admin/editor (lighter metadata than approvals). - Endpoints:
GET/POST/DELETE /track/:id/relations·POST/DELETE /track/:id/appearances·variantTypesettable via the existingPUT /track/:id(schema enum-validated). e2e (in-memory Mongo): defaultoriginal, mirror cross-link both ways + remove, secondary appearance + home-guard. - Not yet: Option B (formal Release/UPC, true M2M) · the iOS variant chips + group-by/cross-link lenses (doc-26) · wiring per-variant
appliesToVariantsinto the workflow rollup (doc-46, still envision-first).
- Track schema (doc-28 Option C):
- doc-46 — workflow engine LIVE smoke-test + template CRUD completion (backend) (2026-06-28, Claude Code;
nest build+jest75/75 + e2e 4/4 green, lint clean). De-risked the foundation by running it for real, then completed template management.- Integration smoke-test (
test/workflow.e2e-spec.ts, onmongodb-memory-server) — boots the real Mongoose models against an in-memory MongoDB and round-trips the whole flow: list system default → create custom template → instantiate on a project (the embeddedProject.workflowObject actually round-trips through Mongo) → customtaskstep satisfied on'done'→computeReleaseReadinessready; plus a project-level step status +assigneepersisted. Validates DI + Mongoose storage + the rollup over real documents — what the pure unit tests can't. Added the^src/(.*)$path mapper totest/jest-e2e.json. First time the workflow engine has executed end-to-end. - Template CRUD completed —
GET /workflow/templates/:id·PATCH /workflow/templates/:id(edit name/stages) ·DELETE /workflow/templates/:id, all owner-scoped; the computed system default is immutable (edit/delete → 400). +1 e2e test (edit/delete + system-immutability). - Per-variant NOT built (correctly blocked) —
Trackhas novariantTypeyet (doc-28 unbuilt), soappliesToVariantswould be inert; and the owner chose envision-first for variants. The model is captured in46 §per-variant(per-project workflow · step-scope: recording-intrinsic vs release-contextual). Builds when doc-28'svariantTypeships.
- Integration smoke-test (
- doc-46 §per-variant & multi-album workflow — envisioning (2026-06-28, Claude Code; owner-decided). Captured (not built) the per-variant model before coding. Owner decision: workflow is PER-PROJECT — a recording can sit at a different stage in each album it appears in. Resolves the doc-28 tangle (
variantType+appearsInProjects[]) via step SCOPE: recording-intrinsic steps (takes · arrangement · mix · master) follow the recording (shared across albums); release-contextual steps (cover · title · tracklist · details) are per (recording, project). A variant = a Track; per-variant = per-trackappliesToVariants. Status-storage seam noted: multi-album needs a (recording, project) junction for release-scoped steps; today'sTrack/Project.readinessmaps are the seam. Recorded in46-WORKFLOW-ENGINE.md§per-variant. - doc-46 — per-type step states + per-step assignment (backend) (2026-06-28, Claude Code;
nest buildwithjest75/75 green, lint clean). Steps now behave correctly by type, and each step can be assigned to a person (actor-awareness — the "ensure it adapts to the user" thread).- Per-type satisfaction (
workflow.types.ts): each step type has its own state vocabulary + one "satisfied" value — approval=Approved · task=done · asset=present · decision=decided · review=reviewed (STEP_SATISFIED·STEP_STATES·satisfiedFor()). The release gate usessatisfiedFor(step.type, status), so ataskis done on'done', not'Approved'(and vice-versa). computeReleaseReadinessis type-aware — per-stepsatisfiedviasatisfiedFor; each rollup item now carriesownerRole(from the structure) and, for project-level steps,assignee. Track step status relaxed to a free string (per-type vocab) — title/version/cover stay on/release-status.- Assignment:
PATCH /track/:id/readinessandPATCH /project/:id/readinessaccept an optionalassignee(userId), stored alongside the step status in thereadinessmaps. - +3 tests (per-type
satisfiedFor; ataskstep rejecting an approval-style status; embedded customtaskstep satisfied on'done'). Still later (doc-46): per-variant overrides (align with doc-28 —variantType+ a track appearing in multiple albums) · template edit/delete · the authoring UI · iOS surfaces.
- Per-type satisfaction (
- doc-46 increments 2–3 — workflow templates + per-project instances + custom steps (backend) (2026-06-28, Claude Code;
nest build+jest73/73 green, lint clean). The differentiating foundation: studios define their process once, projects instantiate + tweak it, steps can be fully custom (in the studio's own language). Structure is configurable; status storage is unchanged (no migration) — the clean seam. Also: relabeled the over-genericmetadatastep → "Details" (owner's call; avoided "Attributes" to not collide with doc-43's term).src/shared/workflow.types.ts—WorkflowStructure(Stages → Steps) shared by templates + instances;buildDefaultWorkflowStructure()(the "Studio Release" default, derived from the catalog),applyProfile()(back-compat with the doc-45 flat profile),requiredSteps().- New
workflowmodule —WorkflowTemplateentity (owner-scoped,stages[]) +WorkflowService(GET /workflow/templates= computed system default + the user's templates;POSTto create) +resolveStructure(templateId). Project.workflow(embedded instance) +Project.readinessmap (project-level/custom step status).POST /project/:id/workflow/instantiate(copy a template/inline structure in),PATCH /project/:id/workflow(replace structure — add/remove/rename/custom steps),GET /project/:id/workflow(instance or default),PATCH /project/:id/readiness(project/custom step).computeReleaseReadinessis now structure-driven — rolls up the project's embedded workflow if present, else the default +readinessProfile; resolves each step's status from the existing stores bykey(legacyreleaseStatusfor title/version/cover ·Track/Project.readinessmaps for everything else, incl. custom keys). Keepsitems[]·stages[]· all legacy fields → mgmt.ctrl Board unchanged. Custom track steps settable via the relaxedPATCH /track/:id/readiness(any non-legacy key, validated service-side, not the static catalog).- +5 tests (workflow.types helpers + an embedded-workflow rollup with a fully custom step "beatSold"). Design principle in code/doc: "start simple, scale to your complexity — only when you need it." Still later (doc-46): per-type states · per-variant overrides · per-step assignment · the authoring UI · iOS surfaces.
- doc-46 increment 1 — Stages → Steps + default "Studio Release" template (backend) (2026-06-28, Claude Code;
nest build+jest68/68 green, lint clean). First build of the workflow engine on the shipped readiness seed — evolves the flat checklist into the lifecycle pipeline shape. No new schema or endpoints; catalog + rollup only. Motto threaded in: "start simple, scale to your complexity — only when you need it."readiness-catalog.ts→ the default workflow template — items now carry astage(Pre-production · Recording · Mix & Master · Rights & Credits · Release, ordered viaWorkflowStages) and atype(approval/task/asset/decision/review). Added creative steps (studio/arrangement/takes, optional) so the lifecycle is represented, and relabeledmetadata→ "Credits & metadata" (the over-generic name the owner flagged). Existing keys preserved →DefaultReadinessProfileunchanged in content (back-compat).computeReleaseReadinessnow returnsstages[]— the required steps grouped into lifecycle stages (each: label · steps · satisfiedCount · satisfied), in stage order; only stages with a required step appear. Keepsitems[]+ all legacy fields, so mgmt.ctrl Board + the project endpoint are unchanged. The new creative track steps are settable via the existingPATCH /track/:id/readiness(auto-included inTrackReadinessKeys).- +1 unit test (stage grouping); existing readiness tests updated for stage-ordered output.
- v1 simplification (noted):
typeis metadata for clients; satisfaction is uniform (Approved). Per-type states · workspace templates + instantiate/tweak · per-variant · assignment · authoring UI are doc-46's later increments.
46-WORKFLOW-ENGINE.md— adaptable, actor-aware workflow engine envisioning (2026-06-28, Claude Code; owner-raised off doc-45). Owner pushback: a fixed Title·Cover·Version·…·Metadata checklist is too release-centric and technical, ignores the creative/recording lifecycle (re-record · mic/studio/featured-artist · test-pressing · splits · contracts) where most actors + work live, and is only toggle-configurable. Reframe: "release readiness" → "model your own process." Generalizes the shipped doc-45/32 readiness seed (a flat profile) into Stages → Steps. 3 decisions locked (2026-06-28, owner): (1) shape = ordered Stages → Steps (lifecycle pipeline; not flat list, not state machine); (2) templates + full custom steps (opinionated workflow templates as starting points AND owner-defined custom steps in their own language); (3) studio/workspace templates → project instance (+ per-variant doc-28 overrides). Step = type (approval/task/asset/decision/review) + ownerRole/assignee + checkpoint + assetSlot; release gate = "all required steps satisfied". Hub =mgmt.ctrlBoard + Portals · config engine = doc-43 LEGO · actors = doc-12/17 · gating = doc-44 · assets = doc-45. User-friendly = template-first, calm-by-default, their-language. Default "Studio Release" template grounds the real lifecycle (Writing→Pre-pro→Recording→Mix→Master→QC→Rights→Release). Post-MVP, built incrementally on the shipped seed. Registered in00-ROADMAP.md§8.- doc-45/32 — configurable release-readiness profiles (LEGO), backend (2026-06-28, Claude Code;
nest build+jest67/67 green, lint clean). First build off the doc-45 envisioning — the readiness checklist is now data, not a hardcoded Title·Cover·Version triple: a project chooses which items it requires and the release gate rolls up that set. Minimal LEGO (doc-43 pattern) applied to the workflow.src/shared/readiness-catalog.ts— the available readiness items as fixed presets (stable key + i18n label, likelifecycle-status.ts): title · version · cover · lyrics · master · metadata (track) · tracklist (project), each withlevel+approverRole+defaultRequired. Default profile = title/version/cover/tracklist (today's behavior).Project.readinessProfile?: string[](the configurable required set; absent ⇒ default) +Track.readinessmap (generic store for the non-legacy items; title/version/cover keep living onreleaseStatusfor back-compat).computeReleaseReadinessgeneralized — rolls up over the project's profile (track item ⇒ every track Approved; project item ⇒ project Approved); returns the per-itemitems[]+profileplus all legacy fields (audioApproved/coverApproved/tracklistApproved/ready) so mgmt.ctrl Board + the project endpoint are unchanged.- Endpoints:
GET /project/readiness/catalog(render labels + customize) ·PATCH /project/:id/readiness-profile(set required set, owner/admin-gated, keys validated) ·PATCH /track/:id/readiness{key,status}(set lyrics/master/metadata, gate mirrorsrelease-status) ·GET /project/:id/release-readinessnow returns the configurable rollup. 7 unit tests on the rollup (default + custom profiles,readiness-map items, back-compat fields). - Next: the iOS surface (render the live checklist + edit a project's profile + set the new item statuses) on the mgmt.ctrl Board / project Portal; later, per-variant profiles (doc-28) + full LEGO config authoring (doc-43).
45-RELEASE-PIPELINE-ASSETS.md— release pipeline / project assets envisioning (post-MVP) (2026-06-28, Claude Code; owner-raised mid-mgmt.ctrl). Captures a product idea: motion/animated covers (Apple-Music-style) stored · previewed-as-the-store-renders · approved, inside a project "cloud"/asset space (lift attachments from Track up to Project/Artist + documents), plus the realization that release-readiness has more & configurable elements than Title·Cover·Version — varying per project/album and per variant (remix/radio-edit/live/instrumental). Framed as largely a synthesis of doc-32 (approval dimensions) + doc-28 (variants) + doc-43 (LEGO — applied to the readiness checklist itself) + doc-34 (images → motion media) + track attachments; net-new = motion- cover media type + per-node asset space + configurable readiness profiles. Vision: release-readiness = a configurable, variant-aware checklist over a project asset space, surfaced by themgmt.ctrlBoard and Portals (the workflow hub). Full configurability depends on doc-43; motion covers + asset space are separable and could land earlier. Open questions (format/preview fidelity · asset-space model · profile authoring · variant↔profile binding) parked for a deeper post-MVP pass. Registered in00-ROADMAP.md§8.- doc-42
mgmt.ctrl— iOS Matrix view (catalog health tree) (2026-06-27, Claude Code;xcodebuildBUILD SUCCEEDED, first try). Adds the 5th view to the iOS command center, after the owner confirmed the MVP works live. A phone-native roster → artist → project → track tree offGET /management/matrix: roster headers · artist cards with lifecycle pills · per-projectDisclosureGroup(header = project + audio/cover readiness counts + a READY/BLOCKED badge; expands to track rows with a release-status pill); tapping a track navigates to its player (peek-then-go). NewManagementMatrix*Codable models;MgmtCtrlViewModelnow loads the matrix as a 5th parallel read. Composed from DesignSystem (LifecycleStatusPill · StatusPill · SectionHeader ·.snCard). Remaining mgmt.ctrl view: Insights heatmap UI (doc-38 read side). Noted backlog (doc-42 §13 follow-ups): richer activity audit (create/update/delete · who/what/when) + doc-40 filter/search/order-by. - doc-42
mgmt.ctrl— iOS MVP screen (Overview · Timeline · Queue · Board + bulk) (2026-06-27, Claude Code;xcodebuildBUILD SUCCEEDED, iOS Simulator SDK). The first built mgmt.ctrl surface, on iOS (web deferred to post-iOS-MVP per owner). Requirements locked in42-MANAGEMENT-CTRL.md§13.- New
Features/Management/—MgmtCtrlView(aScreenBackground+ Overview KPI grid + a Timeline · Queue · Board segmented switcher, calm cards composed only from DesignSystem) +MgmtCtrlViewModel(@Observable, loads the four/management/*reads in parallel viaAPIClient,LoadState, pull-to-refresh) +Core/Models/ManagementModels.swift(Codable mirrors of the backend shapes incl. the provenance breadcrumb → "Pop › Ava › LP1 › Tidewater"). - Actions: per-row Approve / Request-changes on the Queue (loops
PATCH /track/:id/release-statusover the under-review dimensions) + multi-select bulk (a bottom action bar →PATCH /management/bulk: bulk approve audio / set lifecycle status), with the partial-failure summary surfaced ("3 done · 1 skipped · 0 failed"). Rows navigate to their source track (peek-then-go, doc-36). - Entry: a new
AppSection.mgmtCtrl("management.ctrl"), reachable from both the avatar drawer (Workspace group) and a leading "Manage" tile on the Home quick-access row. No role gate (corrected 2026-06-27, owner): access roles are per-resource (you're owner of a track), there is no global "manager" identity role, and a fresh user owns their own stuff — somgmt.ctrlshows for everyone as the lens over their own catalog. A real "manager license" (capability granted to a user, independent of any resource) is a separate future axis — recorded in44-...§open-Q6. The earlieruserRole-scalar gate was the wrong abstraction (and the reason it wasn't appearing). - Backend enabler:
PermissionServicegained a grant-or-legacy fallback (grant first, else the resource'sowner/collaborators) so the bulk per-nodecan()is correct for all resources (incl. ones created after a backfill) — bulk works without flipping globalPERMISSION_ENFORCE, removing the new-resource 403 hazard.nest build+jest40/40 green (+2 fallback tests). - Verified: full
xcodebuildcompile green (caught + fixedShapeStylevsColorshorthand errors —.snForegroundSecondary/.snStatusAmberneed the explicitColor.form in.foregroundStyle). Synchronized folders → new files auto-join the target. Deferred to a later iOS slice: the Matrix grid + Insights heatmap UI (the read side of the telemetry now being captured); web emitter + screens are post-iOS-MVP.
- New
- doc-38 — iOS telemetry emitter (the capture client) (2026-06-27, Claude Code;
xcodebuildBUILD SUCCEEDED, iOS Simulator SDK). Makes the just-built backend backbone real — the iOS app now emits the events the heatmap/Workspace-Insights aggregate. First iOS slice of the telemetry work.Core/AnalyticsClient.swift— a@MainActorbatching, fire-and-forget emitter: events buffer and flush toPOST /analytics/eventson a 20s timer, on app-background, and at a 100-event cap; best-effort (drops on auth-failure, re-queues capped on transport error). Owns the playback-segment bookkeeping so the player makes only one-line calls. Heartbeats carry the listened segment{posStart, posEnd}(→ the waveform heatmap); cumulativemsPlayedis emitted once per play session (on complete/stop) so the backend's engagement-band + listen-through stay correct. Resume-after-pause continues the same session; seeks close the played segment so skipped spans aren't heat. Wire types arenonisolated struct … Encodable/Decodable, Sendable(matches the house Codable-model pattern under the project's default-MainActor isolation).- Session lifecycle (
SonnanceApp.swift) —@Environment(\.scenePhase)→app:openon each foreground,session:end(minutes-in-app) + flush on background. - Playback hooks (
PlayerView.swift, 5 surgical call sites) —play:start/play:pauseinplayPause(),play:seekinseek(),play:heartbeatfrom the existing 0.1s time observer (throttled to ~10s segments, only while playing),play:completein the end-of-track notification, andplaybackStop()(session summary) on the player's.onDisappear. - Verified: full
xcodebuildcompile green (caught + fixed a real Swift-6 main-actor-isolation error on the wire types before it shipped). The project uses synchronized folders, so the new file auto-joins the target — no manual Xcode step. Still follow-on: web emitter, guest/link ingest path, identified opt-in role-gating, and the iOS read surfaces (track-Insights heatmap UI, Workspace Insights screen) that render what's now being captured.
- doc-38 — analytics telemetry backbone (backend, full stack) (2026-06-27, Claude Code;
nest buildgreen +jest59/59 green, lint clean). Owner chose telemetry-first (before iOS). Built the complete doc-38 backend: capture + aggregate-on-read for both consumers. Newsrc/modules/analytics/. Caveat held in the open: the store stays empty until a client emits events — the iOS/web heartbeat emitters are the follow-on work; this is the pipe + the reads.- Append-only
Eventstore ({actorType user|guest, actor, linkId, eventType, nodeType, nodeId, sessionId, ts, props, engagementBand, location}) with hot-path indexes + a TTL index onts(ANALYTICS_RAW_TTL_DAYS, default 180 — raw reaped, aggregate-on-read bounded to the window until materialized rollups land, doc-38 §scale-lever). POST /analytics/events— batched ingest; stamps actor + server-time fallback; classifies each play by depth (engagementBand: impression/sample/listen/complete frommsPlayed÷File.duration, doc-38 decision 2) via one batched duration lookup.GET /analytics/track/:id/insights(the hero) — waveform heatmap (per-second position histogram over the listened[posStart,posEnd)segments) + plays · unique listeners · avg listen-through % · band breakdown · hook rate (listen/impression).GET /analytics/workspace— owner BI: opens · minutes-in-app · active days · top tracks (scoped to the caller's catalog), windowed (windowDays, default 30).- Wired the doc-42
mgmt.ctrlInsights view →GET /management/insights(consumes Workspace Insights) — completes the mgmt.ctrl surface (Activity · Approvals · Overview · Matrix · Board · Insights + bulk). Privacy posture per doc-38: aggregate-by-default, guest = link-attributed with coarselocation(city/country, never raw IP), raw-event TTL. Heavy reductions are pure functions (computeTrackInsights/computeWorkspace/classifyEngagementBand) → 7 unit tests, no DB. - Still client-side (follow-on, not built): the event emitters (iOS
play:heartbeat+ session events; web later), the guest/link ingest path (unauthenticated, link-scoped — ties to doc-26/39), and identified opt-in role-gating on reads (aggregate-default holds for now).
- Append-only
- doc-42
mgmt.ctrl— backend read surface complete (Overview · Matrix · Board) (2026-06-27, Claude Code;nest buildgreen +jest52/52 green, lint clean). Finishes the cross-catalog read endpoints (the only remaining view, Insights, is correctly deferred — it needs the doc-38 telemetry backbone). All safe, query-scoped, no authz dependency; pure composition over existing services.GET /management/overview— portfolio KPIs: counts of artists · projects · tracks, plus pendingApprovals · releaseReadyProjects · unresolvedNotes (composesArtistSpaceService.findAll,ProjectService.findAll, the track lists,CommentService.getUserRollup).GET /management/matrix— the Catalog Health Matrix: roster → artist → project → track tree with status + per-project readiness on every node, built from the caller's populated tracks (owned ∪ shared).GET /management/board— Release-Readiness: per-project release-gate rollup (reusesProjectService.computeReleaseReadiness, doc-32), most-blocked first.ManagementModulenow imports ArtistSpace/Project/Comment modules; +6 unit tests (overview/matrix/board). Backend mgmt.ctrl surface = Activity · Approvals · Overview · Matrix · Board + the bulk write; only doc-38 Insights pending.
- doc-44 RBAC — enforcement layer + doc-42 bulk write (shadow-first) (2026-06-27, Claude Code;
nest buildgreen +jest49/49 green, lint clean). Built the migration + enforcement steps on the doc-44 foundation, shadow-first so nothing breaks, and landed the doc-42 bulk write as the first real consumer. Owner-flagged: fans = a future low-privilege principal on the Link/Guest seam (post-MVP) — recorded in doc-44 §open-questions.GrantBackfillService+scripts/backfill-grants.ts(doc-44 migration step 1) — every resource'sowner+ eachcollaborators[]entry → a User grant; everyTeamShare→ a Team grant; deduped to the highest role per (principal, scope); idempotent upsert. Dry-run by default, mutates only withAPPLY=true(mirrors the existing backfill-script pattern). PurebuildGrantOpsunit-tested (6 tests).PermissionGuard+@RequirePermission(action, resourceType)(doc-44 enforcement) — the single guard reads the decorator, builds the resource scope chain, gates viaPermissionService. Shadow vs enforce viaPERMISSION_ENFORCE: unset/false (default) never blocks — only logs where the grant model would deny (surfacing divergences pre-flip);=trueenforces (default-deny).- Closed the
track.updategap —PUT /track/:id(previously no role check) now carries@UseGuards(PermissionGuard) @RequirePermission(Edit, Track), in shadow mode (logs only until the flip).TrackModuleimportsPermissionModule. PATCH /management/bulk(doc-42 §6, the original goal) — fan setStatus / approve / reject across many tracks, gated per-node viaPermissionService(a node the caller can't write is skipped, not failed); wraps existingtrack.update/updateReleaseStatus; returns{total, succeeded, skipped, failed, results[]}so partial success is explicit. Requires the backfill to have run (no grants ⇒ all skipped, by design — never a silent over-grant). 7 bulk unit tests.- Rollout (owner-run, each reversible):
npx ts-node scripts/backfill-grants.ts(dry run) → re-run withAPPLY=true→ boot + watch[shadow] would DENYlogs to confirm parity → setPERMISSION_ENFORCE=trueto enforce. Verification boundary: build + 49 unit tests + lint are green; runtime DI/boot needs a Mongo connection (npm run dev) — not run in this environment.
- doc-44 RBAC — foundation:
Grantmodel +PermissionServiceresolver (non-breaking) (2026-06-27, Claude Code;nest buildgreen +jest37/37 green, lint clean). The concrete, runnable core of the doc-44 authorization redesign — built for alignment review before any enforcement flips on. No routes touched, zero behavior change. Newsrc/modules/permission/:Grantschema (models/grant.schema.ts) — the single authz primitive:(principalType user|team|link, principalId, scopeType ArtistSpace|Project|Track|File, scopeId, role, effect allow|deny, createdBy). Indexed for resolution + a unique(principal, scope)index for idempotent backfill. ReusesRole/SharedResourcefrom collaboration.permission.actions.ts— the doc-44 action matrix as a data-shaped constant (ROLE_ACTIONS,ROLE_LEVEL,roleCan,highestRole): view/comment/edit/upload/approve/delete/share/transfer, owner ⊇ admin ⊇ editor ⊇ viewer. Swappable for doc-43 custom roles without touching call sites.PermissionService—can(user, action, scopeChain)+resolveRole(...)computed live from the grant ledger + accepted team membership, walking node→ancestor scopes. Default-deny; most-specific scope wins → deny beats allow → highest allow-role wins (doc-44 §resolution). Short-TTL membership cache (+clearCache); scope-chain builders (track/project/artist, populated-or- id tolerant); ashadowCompare(legacy, resolved)helper for the migration. Not injected into any route yet.- 15 unit tests proving the semantics: direct + cascade + most-specific-override + highest-role + deny-beats-allow + closer-deny-over-ancestor + default-deny + team-grant + the action matrix + scope-chain builders.
PermissionModuleregistered inapp.module.ts(exports the service). - Deferred to post-alignment (doc-44 §sequencing): backfill script (collaborators[]+TeamShare → grants), shadow-mode wiring, the
@RequirePermissionguard (closes thetrack.updatedefault-allow gap), read-path convergence ontoaccessibleIds, then the doc-42 bulk write on top.
44-RBAC-TEAMS-PERMISSIONS.md— the authorization redesign envisioning doc (2026-06-27, Claude Code; owner-raised). SupersedesRBAC-DESIGN.md. Surfaced by the doc-42mgmt.ctrlbulk write — the first feature mutating many aggregated nodes, which can't lean on the implicit "you only know IDs you can see" that masks today's gaps. Finding (verified 2026-06-27): authN (JWT) is fine and reads are safe by query-scoping, but write authZ is inconsistent —updateReleaseStatusgates owner/admin whiletrack.updatedoes not (track.service.ts:1365→ a default-allow gap protected only by ID obscurity); theTeamentity exists but fans out to per-resourcecollaborators[](a bulk-add convenience, not a live permission); and there is no central resolver (grep: noPermissionService/canUserPerform/getCollaboratorRole). 6 decisions locked at envisioning (2026-06-27, owner): (1) one centralPermissionService.can(user, action, resource)+ Nest guard, default-deny, callable from services too; (2) computed-live + cache (ReBAC / Google-Zanzibar model) — resolve from team membership + scope assignments + grants at access time, stop materializing per-resource roles (retire the fan-out); (3) unifiedGrant = (principal, role, scope)with principal ∈ User · Team · Link/Guest (teams = scaling, direct grants = solo/one-off, links = guests — doc-26/38); (4) cascade with override (most-specific scope wins → highest role within scope → explicit deny beats allow); (5) two role axes kept separate — team-governance role (TeamMember.teamRole) vs access role (owner/admin/editor/viewer); (6) shadow-mode migration — backfill grants fromcollaborators[]+TeamShare, run the resolver compare-only (log divergences, don't enforce) before flipping; non-breaking + reversible. Action-matrix is data-shaped (→ doc-43 custom roles); grant changes ride the doc-41 audit spine; teams = premium (doc-31); reads later converge ontoaccessibleIds()so see-vs-change can't drift. First consumer = doc-42 bulk write (resumes at the guard step); mgmt.ctrl reads already shipped are safe and unchanged. Foundational — sequence before any further mutation surface. Registered in00-ROADMAP.md§8;RBAC-DESIGN.mdbanner-marked superseded.- doc-42
mgmt.ctrlslice 1 — backend/management/*aggregate endpoints (2026-06-27, Claude Code;nest buildgreen +jest22/22 green). The first build off the doc-42 envisioning — owner-chosen backend-first, scoped to the two pre-telemetry views (Activity Timeline hero + Approvals Queue). Newsrc/modules/management/module (controller + service + spec), registered inapp.module.tsnext toMobileModule; JWT-gated on/management,@GetUser()-scoped.GET /management/activity?limit=— cross-catalog activity feed, newest-first digest. Merges the user's notifications with audit-derived "track touched" events; every row carries a provenance breadcrumb (roster › artist › project › track) + atargetfor doc-36 peek-then-go; notifications are enriched with provenance when theirpointerIdresolves to one of the user's tracks. Uses the interim source doc-42 specifies (notifications + audit timestamps) since the doc-38 event log isn't built — same shape will sit over the events backbone when it lands. Limit capped at 100 (default 30).GET /management/approvals— cross-catalog approvals queue, oldest-first. Lifts theMobileServicepending-approvals logic from the single-user home to the manager's full owned ∪ collaborator ∪ shared set; each item = track + provenance + under-reviewdimensions(Title/Version/Cover) + per-versionrequestedBy/requestedAt+ computedageHours. One batchedFileService.findUnderReviewByTrackIdsquery (no N+1).- Pure composition over Track/Notification/File services — no new domain state, no schema. Matches the
MobileServicepattern. Next slices: Matrix/Board/Insights views, thePATCH /management/bulkwrite wrapper (doc-42 §6), scope/roster filters, and a client (web or iOS, TBD).
43-CONFIGURABLE-ATTRIBUTES.md— the "LEGO" configurable-attribute engine envisioning doc (2026-06-27, Claude Code; owner-raised, split out of doc-42 §8). The composable data substrate undermgmt.ctrl(doc-42): entities carry different attribute sets, the same attribute can live at different hierarchy levels, catalogs are per-workspace definable. Consolidates every deferred customization thread (doc-17 Lego OS · doc-20 §4 customization/templates · doc-41 per-workspace status catalog · doc-31 monetization). Finding (verified 2026-06-27): net-new, no foundation — all hierarchy schemas are fixed@Prop;main.tsforbidNonWhitelistedactively rejects unknown top-level fields (so attrs must live in one whitelisted bag, validated service-side); only seeds = unusedFile.metadata: any(file.schema.ts:68) +tags[];lifecycle-status.tsis the exact precedent to generalize (stable key + i18n label + per-entity catalog +@IsIn); no workspace container — ArtistSpace is the de-facto tenant root; monetization rails exist (user.subscriptionFree/Explorer/Pro/Diamond). 3 decisions locked at envisioning (2026-06-27, owner): (1) typedattributesbag + per-workspaceAttributeDefinitioncatalog ("EAV-lite": key/label/type/levels[]/ options/required/default/origin, service-layer validated — not full per-value EAV nor dynamic Mongoose schemas); (2) global presets + workspace override — ship default catalogs that work day one (free), workspaces tweak/extend/retire (premium), definitions inherit down the hierarchy (matches doc-41 "fixed defaults now, customizable later"); (3) authoring = both — inline quick-add a field frommgmt.ctrl+ a full Workspace Settings catalog editor. Attribute-at-different-levels vialevels[]+ opt-in cross-level inheritance.statusCode/tagsfold in as the first managed definitions. Premium / post-MVP layer (gate catalog writes onsubscription; reading existing values never gated → graceful downgrade); default presets can ship early as fixed catalogs (cheap, like lifecycle-status); does NOT block doc-42 (which consumes the effective catalog). Envisioning only — no build this pass. Registered in00-ROADMAP.md§8; doc-42 §8 forward-link updated.- 5 detail decisions resolved same day (2026-06-27, owner): (1) home = a dedicated
WorkspaceSettingsdoc (room to grow for doc-20 §4 theming/saved-views); (2) existing fixed fields (ISRC/UPC/genre/…) are reconciled into the catalog as managed presets, not duplicated — available per level/required, so users only see fields they need; (3) cross-level inheritance is opt-in per definition (inherit); (4)reftype supported (values pointing at other nodes; coordinate with doc-28 relations); (5)statusCode+tagsfold in as the first managed definitions. §Open-questions → §Decisions resolved.
- 5 detail decisions resolved same day (2026-06-27, owner): (1) home = a dedicated
42-MANAGEMENT-CTRL.md—management.ctrlManagement Command Center envisioning doc (2026-06-27, Claude Code; owner-raised). A Power-BI-grade eagle-view over the whole catalog (roster → artist → project → track → version) for managers/A&R/admins/execs — status · pending approvals · release-readiness · notes · listening engagement — without drilling node-by-node, and read+WRITE incl. bulk (the Portals top-down synthesis vs per-node Portals). Key reframing: this revives & supersedes doc-17 §5 "Hierarchical Management Control" (the parked/managevision) and extends doc-38 (its analytics engine); it is the sibling of, not the same as, doc-37 Home (Home = "what needs my attention"; this = "the whole catalog's state, cross-actor"). Six decisions locked at envisioning (2026-06-27, owner): (1) new doc extending doc-38 (38 = telemetry/engagement/heatmap engine, 42 = the eagle-view product surface); (2) one unified surface — operational + analytical together, analytical cells in a needs-telemetry state until the doc-38 backbone ships, operational widgets run on today's schema; (3) platform-agnostic — calm cards on phone → dense matrix/grid on wide screens, lead platform chosen at slice time; (4) hero = the cross-catalog Activity Command Timeline, every row a provenance portal to source (doc-36 peek-then-go); (5) read and write incl. bulk — inline edit · per-row decisions · bulk actions that wrap existing mutation endpoints with per-node role checks + partial-failure reporting (no new write semantics); (6) "LEGO" configurable attributes = companion, out of scope → proposed doc-43 (management.ctrl reads/writes whatever attributes exist, so it does not block on the attribute engine).- Findings (verified 2026-06-27): no cross-workspace aggregation exists — queries are per-resource; the only aggregate is single-user
GET /mobile/dashboard→ net-new/management/*aggregate endpoints + aPATCH /management/bulkwrapper are the real work. The activity hero's hard dependency is an event log (today onlycreatedBy/modifiedBy+ notifications exist; the doc-38 events backbone becomes the activity SoT, with an interim notifications+audit feed). The configurable-attribute ("LEGO") engine is net-new — fixed@Propschemas +main.tsforbidNonWhitelistedactively blocks unknown fields; only seeds = unusedFile.metadata: any(file.schema.ts:68) +tags[]; lifecycle catalog flagged deferred (lifecycle-status.ts:6). Recommended next: author43-CONFIGURABLE-ATTRIBUTES.mdto consolidate the deferred doc-17/20/41/31 customization threads. Build after doc-37 + first-byte perf + the doc-38 backbone. Registered in00-ROADMAP.md§8 doc-map; doc-17 §5 marked superseded. - Follow-up resolutions (2026-06-27, owner): working label
mgmt.ctrl(revisit branding before any public string); top-nav destination for now (Home being re-designed concurrently); surface is a multi-view command center — Timeline (default) · Matrix · Queue · Board · Insights · Saved — built open/composable via a view+widget+action registry (decoupled widgets, "open, not hooked-in"; per-workspace configurable later via doc-43, same seam); action model at three altitudes (global toolbar · per-row · bulk, all role-gated). Placeholder/fixture-data-first — design & test the whole surface against mock/management/*responses at the registry binding layer, real data swaps in behind the same bindings per widget. doc-43 greenlit (the LEGO attribute model is not clearly specified in current docs → warrants its own envisioning; separate task, does not block). MVP minimum-lovable centre = Timeline + Queue (both pre-telemetry).
- Findings (verified 2026-06-27): no cross-workspace aggregation exists — queries are per-resource; the only aggregate is single-user
38-ANALYTICS-INSIGHTS.md— analytics/insights envisioning doc (2026-06-26, Claude Code; stakeholder-raised). Captures the "opens / minutes / most-listened part" ask + actor-driven metrics. Key finding: no telemetry exists today —Track.lastAccessedAtis a single overwritten timestamp (recent-access), not an event log; "most-listened part" forces an append-only events backbone + aggregation, not counters. Decision locked at envisioning: build both owner-BI and in-product actor metrics on one unified pipeline (also feeds doc-36 seen/view — no second telemetry path). Hero surface = intensity heatmap on the existing waveform; Workspace Insights role-gated (doc-20); Home gets a pulse card only. Capture-first slice sequence (ingestion + playback heartbeat ship before any read surface); privacy = aggregate-default / identified opt-in / raw-event TTL. Build after the active doc-37 Home Dashboard + the standing first-byte perf priority. Registered in00-ROADMAP.md§8 doc-map.- All 5 detail decisions locked same day (2026-06-26, owner): (1) own the events store as SoT, mirror to PostHog/Amplitude later (don't rebuild funnels/retention UI); (2) classify plays by depth (
engagementBand: impression / sample / listen / complete) instead of discarding short plays — a first impression is signal → free hook-rate + skip/completion-rate; (3) identified visibility opt-in + role-gated, granularity via the Teams/Role model (no separate consent system); (4) aggregate-on-read (no batch rollup pipeline; sole watch-point = the per-file position histogram → escalate to a write-time bucket$inconly if it gets slow); (5) guest/link plays = identifiable unit attributed to the share link + timestamp + coarse city/country location (store derived location, not raw IP — GDPR), with optional guest self-ID. Doc §Decisions converted from "to confirm" → "locked."
- All 5 detail decisions locked same day (2026-06-26, owner): (1) own the events store as SoT, mirror to PostHog/Amplitude later (don't rebuild funnels/retention UI); (2) classify plays by depth (
Added
- doc-41 slice 2 — lifecycle
statusCodeon the hierarchy (backend) (2026-06-26, Claude Code;nest build+jest14/14 green). The workflow-stage axis, kept distinct from approval (doc-32) and soft-delete state.- New
src/shared/lifecycle-status.ts— fixed default catalogs as stable keys + labels (i18n-ready): Artist = active/archived · Project = draft/active/released/archived · Track = draft/published/archived, with per-entity default keys + key arrays for validation. statusCodeadded to ArtistSpace / Project / Track schemas (enum+default→ a fresh record is active/draft, never blank). Added to the update DTOs (@IsIn-validated on the ArtistSpace class DTO); Track's newstatusCodeis separate from its legacy loosestatusstring (deprecate later).- Follow-ups: a
GETstatus-catalog endpoint (clients render labels / "customize later"); wire the doc-40 status filter/sort + a status pill in iOS/web; relax the schemaenumwhen the per-workspace custom catalog lands. Decisions 1–2 locked (fixed defaults + these catalogs).
- New
- doc-41 slice 1 — audit fields
createdBy/modifiedByon the hierarchy (backend) (2026-06-26, Claude Code;nest build+jest14/14 green). Owner decisions locked: lifecycle status = fixed sensible defaults (customization deferred); build the audit slice now.createdBy+modifiedBy(ref User) added to ArtistSpace / Project / Track schemas. Set on create (= owner);modifiedBystamped on the mainPUTedit, actor threaded from@GetUser(Track now always passes the user, not only on status changes).createdAt/updatedAtalready existed (timestamps: true) and serialize by default — so CreatedOn/ModifiedOn were already there, this adds the actor.- Updated
artistSpace.controller.speccall-signature assertions (now passuser._id). 14/14 green. - Follow-ups: surface "Modified by · when" in iOS/web (read-only consume); stamp
modifiedByon the secondary track mutate paths (cover/version/status/rename —updatedAtalready correct there); optional backfill ofcreatedBy/modifiedByfor pre-existing records. Next slice = lifecyclestatusCode.
41-RECORDS-AUDIT-STATE-STATUS.md— audit / state / status (+ i18n) foundation (2026-06-26, Claude Code; owner-raised). The data layer under doc-40: CreatedBy/On · ModifiedBy/On · state (Active/Inactive) · status (Draft/Published/…) · timezone/i18n across the hierarchy. Key finding (verified against the schemas): half already exists — every model hastimestamps: true(socreatedAt/updatedAtare there),owner≈ createdBy, soft-deleteisDeleted≈ Active/Inactive, approvalreleaseStatus(doc-29/32) ≈ a status. Real gaps: explicitmodifiedBy/createdByaudit-user, a lifecyclestatusCode(distinct from approval — fixed sensible defaults now, per-workspace customization deferred), and i18n (nouser.timezone/locale; store UTC/render-local; status stored as stable keys not display strings for localization). Core principle: four orthogonal axes — audit · state · lifecycle status · approval — don't overload one "status" field (today's looseTrack.status: stringshould become the enumstatusCodeor be deprecated). Powers doc-40 filters/sorts/defined-views; extends thesearchmodule (today ArtistSpace/Project/Track names only) to comments/notes + a global Search view. Slice 1 (audit) is cheap/no-fork. 5 decisions flagged. Registered in00-ROADMAP.md§8.
Added
- doc-41 surfacing (iOS) — lifecycle status pill on Project cards (2026-06-27, Claude Code;
xcodebuildBUILD SUCCEEDED). First client surfacing of the doc-41 slice-2statusCode.- New
LifecycleStatusPill+LifecycleStatuscatalog (DesignSystem/Components/) — key→label/color mirror of the backendlifecycle-status.ts. Kept separate from the existing approvalStatusPill(SnStatus, doc 29/32) per doc-41's four-orthogonal-axes rule. Calm: the neutraldraftdefault is hidden, so only Active / Released / Published / Archived show a pill. statusCodedecoded onProjectSummary;ProjectCardgained astatusCodeparam and renders the pill in its metadata row (next to the type), wired fromLibraryView(Projects) +ArtistDetailView.- Follow-ups (companion pieces to make it visible/usable): a status picker in the Project Portal to set it (DTOs already accept
statusCode); the doc-40 Status filter/sort inViewOptionsMenu; the pill on Track/Artist surfaces;modifiedBy"by {name}" needs backend.populate(currently an id). Until status is settable in-app (or set via API), pills stay hidden (existing data + new = draft).
- New
Changed
- Player open flash — REAL fix: skeleton now mirrors the loaded layout; zoom kept (iOS) (2026-06-26, Claude Code;
xcodebuildBUILD SUCCEEDED; ⏳ on-device QA). Owner reported the flash persisted and that removing the zoom was wrong — they want the zoom. Root cause found: the player is waveform-centric (no big cover) — the cover lives in the bottomPlayerBar— but the loading skeleton showed a big 180px cover hero. So on open the zoom grew that cover, thendetaillanded and the cover-less layout replaced it → the cover "grew then vanished" = the flash. The earlier "zoomless seeded push" was the wrong lever and is fully reverted (zoom +.matchedTransitionSourcerestored on recents/approval;TrackRoute.zoomflag removed).- Fix:
loadingStatenow mirrors the loaded layout — seeded title → faint version/approve/timecode bars → waveform placeholder, no big cover hero. The title stays put and the wave placeholder becomes the real wave; nothing vanishes. The cover stays in the bottom bar in both states. - Top-edge swipe-back (owner-raised): a downward drag starting near the top now dismisses, in addition to the left-edge swipe —
simultaneousGesturewith strict conditions so it never steals the scrub/scroll. ⏳ needs on-device gesture tuning. File:Features/Review/PlayerView.swift.
- Fix:
- "Albums" → "Projects" + redundant section titles removed in the hierarchy (iOS) (2026-06-26, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner QA.- "Albums" relabelled "Projects" everywhere it surfaces (drawer / quick-access via
AppSection.title, and the section screen viaLibraryScope.title) — the entity is a Project; "Albums" didn't match reality. Raw values (.albums) kept so stored prefs/keys are unaffected. - Dropped the inner "Projects" / "Tracks" section titles + count bubbles in the hierarchy browse views too —
ArtistDetailView(the projects grid) andProjectTracksView(the tracks list); the artist / project hero above already gives context. Matches the same cleanup already done on Home/Library/Artists. Files:Features/Shell/AppMenuDrawer.swift,Features/Shell/LibraryView.swift,Features/Browse/ArtistDetailView.swift,Features/Browse/ProjectTracksView.swift.
- "Albums" relabelled "Projects" everywhere it surfaces (drawer / quick-access via
- Player open no longer hitches + timecode = plain fade-in matching start color (iOS) (2026-06-26, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner QA.- Opening freeze fixed (first-byte follow-up).
PlayerViewModel.initransetupRemoteCommands()+seedAudio()(→player.replaceCurrentItem) synchronously during the navigation push, hitching the transition before the skeleton could paint. Both now run in a newonAppearSetup()called from the view's.task(after the first frame). First-byte is unaffected — the row already prewarmed the file's buffer on tap (AudioPrewarmer), so adopting it a beat later is free. Removed the now-duplicatesetupRemoteCommands()from the detail-init path (single owner =onAppearSetup). - Focal end-time: dropped the grey skeleton box — owner preferred just the fade. The end time now simply fades in (opacity) into its fixed-width slot when known; no placeholder block.
- End-time color now matches the start time (
snForeground, notsnForegroundSecondary) — the two halves read as one component in light theme (the dimmer grey looked off). File:Features/Review/PlayerView.swift.
- Opening freeze fixed (first-byte follow-up).
- Player focal timecode — no jump, no re-flash, fixed-width slot (iOS) (2026-06-26, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner QA follow-up on the end-time skeleton.- No more position jump. The end-time sat in an auto-sized slot, so skeleton (wide) → real time (narrow) reflowed and recentered the whole
0:00 — 0:00row. The end time now lives in a fixed-width slot (sized by a hidden00:00), so the value just crossfades in place — the row never moves. - No re-flash on something already loaded. Added a process-wide duration cache by fileId (
PlayerViewModel.durationCache): a file's length is immutable, so persisted or previously-probed durations render the total immediately on revisit — the skeleton now only appears on a genuine first probe of a legacy file (new uploads with persistedFile.durationnever show it). - Crossfade unified under one
.font/.monospacedDigiton the slot so the timecode reads as one component. File:Features/Review/PlayerView.swift.
- No more position jump. The end-time sat in an auto-sized slot, so skeleton (wide) → real time (narrow) reflowed and recentered the whole
- Sort menu (doc-40 slice 1) — reusable
ViewOptionsMenuon Artists + Library (iOS) (2026-06-26, Claude Code;xcodebuildBUILD SUCCEEDED on iPhone 17 Pro). Generalizes the roster-toggle pattern into the per-view display-prefs framework.- New
ViewOptionsMenu+SortField(DesignSystem/Components/ViewOptionsMenu.swift) — a toolbar Menu with a SortPicker+ an optionalextraslot for view-specific toggles. Persistence is the caller's (@AppStorage, keyed per view) so each view's sort survives relaunch. Sort presets: Default (server order) · Name (A–Z) · Recently updated. - Artists — Sort (Default / Name) + the "Group by roster" toggle now live in the same menu; sort applied to both the flat grid and within roster groups. Persisted at
sort.artists. - Library — per-scope persisted sort (
sort.albums/sort.tracks) via a dynamic-key@AppStorage; Albums = Default / Name, Tracks = Default / Name / Recently updated (Track.updatedAtDate). Menu hidden on the empty state. Sort keys are honest to the models (Albums/Artists carry no date — no fake "recent"). Files:DesignSystem/Components/ViewOptionsMenu.swift(new),Features/Browse/ArtistsHomeView.swift,Features/Shell/LibraryView.swift.
- New
- Player end-time skeleton + redundant section titles removed + roster opt-in/margin (iOS) (2026-06-26, Claude Code;
xcodebuildBUILD SUCCEEDED on iPhone 17 Pro). Owner QA on the skeleton + library/roster views.- Focal end-time no longer flashes
0:00.PlayerView.focalTimecodeshowedText(format(duration))=0:00until the duration resolved (legacy versions probe it from the network), flashing to the real time. Now it renders aSkeletonBlockwhileduration <= 0and crossfades (ZStack +.opacitytransition,.easeInOut(0.35)) skeleton → real total in place — no snap/jump (owner follow-up 2026-06-26). The previous PlayerBar-only skeleton didn't cover this separate big-display label. New tracks (persistedFile.duration) still show the real time instantly (no skeleton). - Redundant inner section titles + count bubbles removed (owner: "keep the screen title, drop the second one that says the same with its count").
LibraryView(Albums + Tracks) andArtistsHomeViewflat layout dropped the innerSectionHeader(title:count:)— the in-contentSectionScreenHeaderalready names the screen. - Artists: roster grouping is now opt-in + saved. Was automatic on any roster presence; now flat by default with a persisted
@AppStorage("artists.groupByRoster")toggle in a new toolbar View options Menu (shown only when ≥1 artist has a roster). First instance of the doc-40 per-view display-pref pattern. - Roster name margin fixed. Roster section headers had negative leading padding (
-SnSpacing.md, flush to the screen edge); removed → aligned to the content margin (doc-22 spacing). Files:Features/Review/PlayerView.swift,Features/Shell/LibraryView.swift,Features/Browse/ArtistsHomeView.swift.
- Focal end-time no longer flashes
Added
40-SORT-FILTER-SEARCH.md— per-view Sort/Filter/Search envisioning doc (2026-06-26, Claude Code; owner-raised; slice 0 shipped same day). The app needs consistent Sort / Filter / Search per view with persisted prefs. Finding: a filter foundation exists (LensBar+ doc-26SavedViews) but is Artists-only + filter-only — no Sort, no Search, grouping was hardcoded-automatic. Model = a per-view view-config (Filter chips · Sort menu ·.searchable), split persistence: display prefs = local@AppStorage(roster toggle = first instance), named Saved Views = server (doc-26). Each axis to its idiomatic iOS home; default stays calm (doc-20). Roster UI/UX confirmed against doc-22/26/36 (margin + opt-in). Registered in00-ROADMAP.md§8.
Changed
- Loading skeletons — grey shimmer covers + a loading timecode (iOS) (2026-06-26, Claude Code;
xcodebuildBUILD SUCCEEDED on iPhone 17 Pro / iOS 26.5 SDK). Owner: skeletons were showing the colored halo while a cover loaded, and the unknown duration showed a bare--:--— both should read as a neutral loading skeleton (the shimmer the web app uses).- New
Shimmer.swift— ports the web.skeleton-shimmer:Color.snSkeleton(grey#EBEBEB/#3A3A3A, added toTheme.swift), a Reduce-Motion-safe.shimmering()sweep (1.4 s, left→right),SkeletonFill(fills a cover),SkeletonBlock(inline value placeholder). CachedImagenow distinguishes 3 states —loading(URL in flight) vsempty(no URL / failed) vsimage. A new optionalloading:builder shows the grey skeleton only while fetching; a seeded decoded-cache hit still paints on the first frame (no skeleton, no flash, doc-34 win preserved); an already-shown cover stays put across a version switch (no grey flash).loadingdefaults toplaceholder, so untouched call sites are unchanged.- Wired into the cover sites —
ArtworkHero,TrackRow,ProjectCardpassSkeletonFillfor loading, so the colored halo shows only when there's genuinely no cover (identity), grey shimmer while loading. - PlayerBar timecode — when
duration <= 0(legacy versions probe it from the network), the total shows aSkeletonBlock(0:03 / ▓▓▓) instead of--:--, snapping to the real time once known. Files:DesignSystem/Components/Shimmer.swift(new),Core/CachedImage.swift,DesignSystem/Theme.swift,DesignSystem/Components/{ArtworkHero,TrackRow,ProjectCard,PlayerBar}.swift.
- New
Added
39-SAMPLE-SHARING.md— clip/sample-sharing envisioning doc (2026-06-26, Claude Code; owner-raised). "Share a sample, not the whole track" — a teaser (the hook) or a focused-feedback clip without exposing the full master. Extends doc-26 sharing (a link +{clipStart, clipEnd}window); inherits doc-38 guest analytics (hook-rate/listen-through for free). Load-bearing fork: (A) playback-window link (client-enforced, ~zero infra — recommended for the teaser/feedback need) vs (B) server-trimmed ffmpeg artifact (hard leak-protection — only if the master must never be downloadable). Authoring surface = waveform drag-select. Build after doc-37 + the doc-38 backbone. Registered in00-ROADMAP.md§8.
Changed
- Back button = icon only + long-press menu on every section + section typography normalized (2026-06-26, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner QA.- Reverted the back-button label — owner prefers just the chevron.
DynamicBackButtonis icon-only again (tap = clean pop, long-press = ancestor menu, swipe-back intact). - Long-press menu now on the section screens too.
dynamicBackis applied once at the dashboard'snavigationDestination(for: AppSection)wrapper, so Albums / Tracks / Shared / Teams / Recents / Artists all get the chevron + long-press → Home (the deeper Player/Album/Artist keep their richer menus). Was missing on the section lists (they used the system back). - Typography normalized. New
SectionScreenHeaderrenders the brand display title in-content (sonnanceDisplay(32, .black)); applied to LibraryView (Albums/Tracks), SharedView, RecentsView, the Teams section wrapper, and ArtistsHomeView (refactored to the shared header) — previously only Artists had the big title, the rest used plain system nav titles. Files:Features/Shell/SectionScreenHeader.swift,Features/Shell/DynamicBackButton.swift,Features/Shell/DashboardView.swift,Features/Shell/LibraryView.swift,Features/Shared/SharedView.swift,Features/Review/RecentsView.swift,Features/Browse/ArtistsHomeView.swift.
- Reverted the back-button label — owner prefers just the chevron.
- Back button shows the parent label + buffering spinner on the play control (2026-06-25, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner QA on the dynamic back button.- Labelled back. The custom back button was a bare chevron; it now shows
‹ {nearest parent}(icon + literal, like the native back) — e.g.‹ Albumon the player,‹ Artiston the album,‹ Homeon the artist. Tap stays a clean pop (matches swipe-back, preserves the parent's state); long-press still opens the full ancestor list.DynamicBackButtonderives the label from the nearest jump. - Buffering spinner (no more "frozen" feel). While the player wants to play but is still pulling the cold first byte, the play thumb shows a spinner instead of a static glyph.
PlayerViewModelobservesplayer.timeControlStatus(.waitingToPlayAtSpecifiedRate→isBuffering);PlayerBarrenders the spinner. Files:Features/Shell/DynamicBackButton.swift,Features/Review/PlayerView.swift,DesignSystem/Components/PlayerBar.swift.
- Labelled back. The custom back button was a bare chevron; it now shows
- First byte — audio decoupled from the detail fetch (seed-through-route) (2026-06-25, Claude Code;
xcodebuildBUILD SUCCEEDED). The prewarm helped, but the player still only installed the audio item afterGET /track/:idreturned — so first play was gated on the detail network round-trip. Now the final audio's signed URL + file id ride onTrackRoute(from the project-track rows and the home Recently-opened rows), andPlayerViewModel.initinstalls the item immediately (adopting the prewarmed buffer) via a new sharedinstallItem/seedAudio, beforebootstrap/fetchDetail. The detail-drivenloadCurrentnow skips rebuilding an already-seeded warm item (installedFileIdguard). Net: tap → buffering starts (prewarm) → view appears with the item already installed → first play is warm, independent of the detail fetch. If first byte is still slow on a truly cold backend/blob after a deploy, that residual is server/infra cold-start — amin replicas/ warmup change ininfrastructure, not client. Files:Features/Review/PlayerView.swift,Features/Browse/ArtistsHomeView.swift(TrackRoute),Features/Browse/ProjectTracksView.swift,Features/Shell/DashboardView.swift. - Dynamic back button at every level + swipe-back restored + Home-track transition parity (2026-06-25, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner QA follow-up to the player-only dynamic back.- Reusable
dynamicBack(_:onBack:)(Features/Shell/DynamicBackButton.swift) — tap = back one level, long-press = aMenuof ancestor jumps; passnilto keep the system back. Applied to PlayerView (Album · Artist · Home), ProjectTracksView (Artist · Home — artist from the populated tracks), and ArtistDetailView (Home). Data-driven from each screen's own parents, so it's correct no matter how you arrived. - Swipe-back restored. Replacing the system back button disables the interactive pop gesture;
SwipeBackEnabler(aUIViewControllerRepresentable) re-enablesinteractivePopGestureRecognizerwith a delegate gated to non-root screens — so swipe-from-edge works on every dynamic-back screen. - Home→track now matches Project→track. The dashboard Recently opened and approval rows push a
TrackRoutewith amatchedTransitionSource(were baretrackIdstrings) → same zoom-push transition, and with swipe-back, the same feel as opening a track from a Project's track list. Files:Features/Review/PlayerView.swift,Features/Browse/ProjectTracksView.swift,Features/Browse/ArtistDetailView.swift,Features/Shell/DashboardView.swift.
- Reusable
Added
- Dynamic back button on the player (long-press → jump up the hierarchy) (2026-06-24, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner: the Back button was static — only back one level. Now onPlayerView, tap = back one level (normal pop), long-press = a menu of ancestors (Album · Artist · Home) to jump straight up. Built data-driven from the track's own populated parents —TrackDetailnow decodesproject+artistSpace(TrackHierarchyRef); the jump rewrites the hostNavigationPathto a coherent chain (Artist → Album) so the new screen keeps a sensible back trail.PlayerViewtakes the hostpathbinding (threaded from the dashboard'sTrackRoute/Stringdestinations); aMenu(…, primaryAction:)gives tap-vs-long-press for free. Tradeoff: replacing the system back button disables the interactive swipe-back gesture on the player; restore + extending the menu to the Album/Artist screens are follow-ups. Files:Core/Models/TrackDetail.swift,Features/Review/PlayerView.swift,Features/Shell/DashboardView.swift.
Added
- Audio prewarm — first byte loads on tap, not after the player view (2026-06-24, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner's top priority: cold first play (worst right after a fresh build/deploy) was awful because theAVPlayerItemwas only built afterGET /track/:idresolved.AudioPrewarmer.shared(Core/AudioPrewarmer.swift) — on row tap, holds a muted, pausedAVPlayerfor the final audio so AVFoundation starts pulling the first bytes immediately (an item only buffers once attached to a player). Keyed by file id (SAS query signatures rotate per fetch, so the list URL ≠ the detail URL — but the file id is stable and we adopt the exact prewarmed item). FIFO-capped at 2 entries.PlayerViewModel.loadCurrentadopts the prewarmed item (AudioPrewarmer.take(fileId:)) when the loaded version matches — detaching it from the prewarm player — so first play starts from a warm buffer. Falls back to a fresh item when nothing was prewarmed.- No backend change needed for the main paths.
/track/project/:idand the dashboard recents both go throughfindOneById, which already returnsaudio= the final File with a signedblobUrl. iOSTracknow decodes it (audioFinal/audioFinalURL/audioFinalFileId, permissive — nil on endpoints whereaudiois a ref array). Prewarm wired on the project-track rows (ProjectTracksView) and the home Recently opened rows (DashboardView). Approvals + the artist→project path still use a ref-array shape — a uniformaudioFinalUrlin the serializer is the second pass.
Changed
- Sections are pushed views, not sheets — unified navigation stack (2026-06-24, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner QA: the icon-row / drawer sections (Artists, Albums, Tracks, Teams, Shared, Recents) opened as modal sheets; owner wants pushed views with a back button + the browse zoom transitions, for all entries (icon row, drawer, and long-press hierarchy nav from home/notes).DashboardViewnow owns the singleNavigationStack. Added anavigationDestination(for: AppSection)and switched the icon-row tap, draweronSelect, create-artist completion, and long-presshierarchyMenutopath.append(section)(waspresentedSection+.sheet). TheProjectRoute/TrackRoute/ArtistRoutedestinations gained.navigationTransition(.zoom(sourceID:in:))on the sharedzoomNS.- Section views are now embedded content (no inner
NavigationStack, no own route destinations).ArtistsHomeViewtakes the hostpathbinding + sharednamespace(its tiles are the matched zoom sources);LibraryView,SharedView,RecentsViewrender as plain content;TeamsViewwas already stack-free (dashboard adds its title). All onward navigation + zoom now flows through the one stack, so Artist → Album → Track is a continuous push chain with a back button to home. Files:Features/Shell/DashboardView.swift,Features/Browse/ArtistsHomeView.swift,Features/Shell/LibraryView.swift,Features/Shared/SharedView.swift,Features/Review/RecentsView.swift.
Added
- Home QA batch — quick-access tiles, hierarchy nav, composer growth (2026-06-24, Claude Code;
xcodebuildBUILD SUCCEEDED). From owner device QA (2026-06-24).- Home quick-access row. A horizontal row of squared icon+title tiles under the wordmark (Artists · Albums · Tracks · Teams · Shared) — the most-reached sections are now one tap from home instead of two via the avatar drawer. Reuses
AppSection.icon/.title; tap setspresentedSection. File:Features/Shell/DashboardView.swift(quickAccessRow/quickTile). - Home-row hierarchy navigation (doc-36 peek-then-go). Long-press an approval or note row → choose where to land: the track, its album, or (approvals only) its artist — instead of only the leaf. No new backend:
findAllByUseralready.populate('project')/.populate('artistSpace'), so the dashboard payload already carried the parents; the iOSTrackmodel just wasn't decoding them. Added permissiveproject/artistSpace(TrackHierarchyRef) toTrack, anArtistRoutenavigation destination (→ArtistDetailView), and a sharedhierarchyMenuon both row types. Files:Core/Models/Track.swift,Features/Shell/DashboardView.swift.
- Home quick-access row. A horizontal row of squared icon+title tiles under the wordmark (Artists · Albums · Tracks · Teams · Shared) — the most-reached sections are now one tap from home instead of two via the avatar drawer. Reuses
Changed
- "Jump back in" → "Recently opened" (2026-06-24, Claude Code) — owner couldn't tell what the section meant; renamed to say plainly what it is (the tracks you last opened).
DashboardView.swift. - Comment composer grows with the text + tap-outside to dismiss (2026-06-24, Claude Code;
xcodebuildBUILD SUCCEEDED). From owner QA: the keyboard sat on top of the box and tapping away didn't close it.CommentComposerTextField is nowaxis: .vertical(lineLimit(1...5)), bottom-aligned so the timecode chip + send button anchor to the last line as it grows;PlayerViewgained a focus-only clear overlay that dismisses the composer on a tap anywhere off it (the composer sits in.safeAreaInset, so it stays tappable). Files:DesignSystem/Components/CommentComposer.swift,Features/Review/PlayerView.swift. - Per-actor relevance — dashboard section labels (doc-37 §0.3) (2026-06-24, Xcode agent; backend
nest buildEXIT:0 + iOSBuildProjectBUILD SUCCEEDED).- Backend (
mobile.service.ts):GET /mobile/dashboardnow computesuserRole— the user's highest role across all their tracks (Owner > Admin > Editor > Viewer) — and includes it in the payload. Two new private helpers:computeAggregateRole(userId, tracks)+getRoleOnTrack(track, userId). - iOS (
DashboardPayload.swift): AddeduserRole: String?to decode the new field. - iOS (
DashboardView.swift):DashboardViewModelstoresuserRole(defaults"owner"until loaded). NewapprovalSectionTitlecomputed var: Owner/Admin → "Needs your approval"; Editor → "Pending review"; Viewer → "Awaiting your review". Wired into the section header and the "See all" navigation title. The client stays dumb — the server tells it what role it is, the client renders the right label.
- Backend (
Fixed
- Player/notes QA quick wins (2026-06-24, Claude Code;
xcodebuildBUILD SUCCEEDED). From owner device QA.- Waveform takes the artwork color; markers stay version-coded.
WaveformgainedmarkerAccent; the player passes the halo/artwork color for the bars and the version color for comment markers (was one accent for both). - Removed the scrollable dead space under comments. The
Spacer(minLength: 220)below the comment list let you scroll into a void —.safeAreaInset(.bottom)already reserves the glass-stack height. Cut to a small clearance. - Comment time frozen at compose-open. New
composeTimecaptured when the composer focuses; the comment anchors there instead of tracking the live playhead until send. - Assign/edit/delete on notes via long-press too. Added
.contextMenu(same actions as the ⋯ menu) to Portal note rows. Files:DesignSystem/Components/Waveform.swift,Features/Review/PlayerView.swift,Features/Browse/PortalSharedSections.swift.
- Waveform takes the artwork color; markers stay version-coded.
Added
- Email/password login + Portal note assign-to-others picker (2026-06-24, Claude Code;
xcodebuildBUILD SUCCEEDED).- Email/password login.
SignInWithAppleViewnow has an "or" email + password form under the Apple button →AuthManager.signInWithEmail(POST /user/login, returns the sameAuthResponse). No backend work (endpoint already existed). Unblocks logout→login on the Simulator (where Apple Sign In can't authenticate).EmailLoginRequestmodel; styled fields (AuthFieldStyle), focus/submit chaining. - Assign-to-others picker.
PortalNotesSectiongains an "Assign to…" ⋯ action (shown when the host passes the node's roster) → a Liquid-Glass picker sheet of collaborators (avatar + name, current assignee checked, Unassign at the bottom) →commitAssign. Wired in the Project and Artist portals via their existingcollaboratorsForDisplay([ArtistCollaborator]); the Track portal passes none for now (assign-to-me/unassign still work there). Completes the doc-36/37 assign feature. Files (iOS):Core/AuthManager.swift,Core/Models/AuthModels.swift,Features/Auth/SignInWithAppleView.swift,Features/Browse/PortalSharedSections.swift,ProjectPortalContent.swift,ArtistPortalContent.swift.
- Email/password login.
Fixed
- Logout/login: socket teardown + Sign-in-with-Apple button constraint + drawer appearance row (2026-06-24, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner QA after sign-out.- Socket not torn down on logout.
AuthManager.signOut()cleared the keychain + user but never disconnectedSocketIOManager— it kept the old session's connection open, retrying writes with a stale token ("Socket is not connected" spam) and a stale room subscription that could bleed into the next account. NowsignOut()callsSocketIOManager.shared.disconnect()first. - Sign-in-with-Apple unsatisfiable constraint. The button had
.frame(height: 52)but no width cap, so it stretched full-bleed (~392pt) pastASAuthorizationAppleIDButton's internalwidth <= 375→ the exact constraint break in the logs. Capped to.frame(maxWidth: 375)(Apple's documented max) + centered. - Drawer appearance row. Reverted the appearance icons to outline (owner preference — not filled) and rebuilt the row as a
Menumatching the destination/sign-out rows (26pt icon column +SnSpacing.md) so the moon/sun aligns with the other drawer icons instead of sitting in the Picker's own layout. - Note: the
AKAuthenticationError -7071/Code=1000in the logs is Apple's auth service failing at the system level — on the Simulator this happens when it isn't signed into iCloud (Settings → Sign in). Apple Sign In works on a real device/TestFlight. An email/password dev login would unblock simulator testing if wanted (backend already supports it). Files:AuthManager.swift,SignInWithAppleView.swift,AppMenuDrawer.swift,ThemeManager.swift.
- Socket not torn down on logout.
Added
- Workspace › Albums + Tracks — flat library (doc-37 #4) (2026-06-24, Claude Code; backend
nest build- iOS
xcodebuildBUILD SUCCEEDED). Two new drawer destinations (Workspace group), Albums and Tracks as separate items (owner request), listing everything the current user can reach, flat — no Artist → Project → Track drilling.LibraryView(scope:)/LibraryViewModel: Albums fromGET /project(ProjectSummarygrid) · Tracks fromGET /track/all-tracks(own · collaborator · shared · via-space); the VM fetches only the scoped list. Each element is a portal to its source (doc 36) — album → project view, track → player — via the view's own browse stack. Backend: added.populate('image')tofindAllTracksByUserId(it didn't populate covers). NewAppSection.albums/.tracks. Also: appearance icons switched to filled SF Symbols (sun.max.fill/moon.fill) for clarity in the drawer's You group. Files (back):track.service.ts; (iOS):Features/Shell/LibraryView.swift,AppMenuDrawer.swift,DashboardView.swift,DesignSystem/ThemeManager.swift.
- iOS
- Navigation-interactions pass: actionable ⋯ menus + Portal note edit/delete/assign (2026-06-22, Claude Code; backend
nest build+ iOSxcodebuildBUILD SUCCEEDED). Owner direction: make every element navigable/actionable.- Dead ellipses → real menus (#6).
TrackRow/ProjectCardhad a decorativeellipsisicon with no action. Replaced with a reusableRowMenudriven bymenuActions: [RowMenuAction]— renders a realMenuwhen actions are supplied, nothing when empty (no misleading affordance). Works inside aNavigationLink(the menu intercepts its own tap). - Portal notes: edit + delete (#5).
PortalNotesSectionnote rows get an author-only ⋯ menu — Edit reuses the composer (pre-fills, routes submit toPUT /comment/:id), Delete confirms thenDELETE /comment/:id. (Both endpoints already existed; just surfaced.) - Portal notes: assign-to-user (#5). New
Comment.assignee(ref User, distinct fromowner) — a triage dimension for a future "assigned to me" lens.UpdateCommentDto.assignee(string | null); populated in all note/rollup reads (findNotes, both rollups). iOS:PortalNote.assigneeshown as "Assigned to {name}"; menu offers Assign to me / Unassign (collaborative, not author-gated) via a dedicatedAssignRequest(encodesnullto unassign). Assign-to-others picker = follow-up (needs the node's collaborator list). Files (back):comment.schema.ts,comment.dto.ts,comment.service.ts. Files (iOS):DesignSystem/Components/TrackRow.swift(RowMenu/RowMenuAction),ProjectCard.swift,Core/Models/Comments.swift,Features/Browse/PortalSharedSections.swift.
- Dead ellipses → real menus (#6).
Fixed
- Track list covers wiped by
filterTrack(root cause) + dashboard note navigation (2026-06-22, Claude Code; backendnest build+ iOSxcodebuildBUILD SUCCEEDED). Owner QA on the aggregate dashboard.- Covers (real root cause).
filterTrack(run byfindAllByUser/findAllSharedWithUser) loopedfor (const fileId in track.image)—for…inover an array iterates indices ("0","1"), sofindById("0")always failed and it rebuilttrack.image = [], silently wiping the populated covers (the earlier.populate('image')was nuked immediately; covers never actually resolved on any list endpoint). Replaced withfilterFileRefs(for…of, keeps populated File docs, drops onlyisDeleted, fetches only for raw ids). Fixes "Needs approval" covers and every other list that feeds rows. - Notes are portals to their source (doc 36). Dashboard "Open notes" now navigates by anchor level — track → player, project → project view (added
ProjectRoute+TrackRoutedestinations toDashboardView, making it a real browse root). Previously project/artist-anchored notes did nothing on tap. (Artist-level notes still carry no id in the rollup payload — not navigable yet.)
- Covers (real root cause).
Added
- Home Dashboard —
GET /mobile/dashboardaggregate read model (2026-06-22, Claude Code; backendnest build+ iOSxcodebuildBUILD SUCCEEDED). The dashboard is now one round-trip (was 5 parallel reads), and the aggregate is a purpose-built read model, not just a bundle (doc-37 §3 + §0).- New
mobilemodule (MobileController/MobileService, registered inapp.module, JWT-gated).GET /mobile/dashboardcomposes the existing services →{ pendingApprovals, notes, activity, recents }. No new domain state; globalSignUrls+StripPrivateTagsinterceptors apply automatically (covers signed, private tags stripped). - Precise
who·whenfor approvals (the enrichment the per-list payloads can't give): each pending track is joined to its under-review file's submitter + submit time via one batchedFileService.findUnderReviewByTrackIds(populatedowner, newest-first). Approval rows now read "Cover · Under review — by Maya · 2d ago" instead of the track-updatedAtapproximation. - iOS: new
DashboardPayload/DashboardApproval(reusesTrack/AppNotification/RollupResponse/CommentOwner);DashboardViewModel.load()collapses to the single call;pendingApprovalis now[DashboardApproval]. On failure the dashboard keeps its last content (single point of failure tradeoff). - Next on this read model: per-actor relevance/ordering (Owner/Editor/Viewer emphasis, doc-37 §0.3) + Viewer "Awaiting approval" copy. Files (back):
mobile/*,file.service.ts,app.module.ts. Files (iOS):Core/Models/DashboardPayload.swift,Features/Shell/DashboardView.swift.
- New
Fixed
- Dashboard "Needs approval" covers + richer rows (2026-06-22, Claude Code; backend
nest build+ iOSxcodebuildBUILD SUCCEEDED). Owner QA: approval covers were all the same green, and rows felt thin.- Cover bug (backend).
findAllByUser/findAllSharedWithUsernever.populate('image')(recents did, viafindOneById), so owned/shared tracks arrived with no cover → the client fell back to a color. Added.populate('image')to both — covers now resolve anywhere those lists feed (dashboard, Artists grid). - All-green fallback (iOS). The approval row hardcoded a V1-green placeholder; now the per-track identity color (
Theme.accent(forKey:)), matching recents.
- Cover bug (backend).
Added
- Home Dashboard — richer rows + "See all" (doc-37 §0 digest → drill-in) (2026-06-22, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner-chosen direction: section depth over section count, actor-neutral first.who · whenon the rows from data already in the payloads (no backend): approval subtitle now reads e.g. "Cover · Under review — 2d ago" (dimensions fromreleaseStatus+ relativeTrack.updatedAt, newly decoded — the serializer already round-trips it); notes gain a "by {author} · {when}" meta line (populated owner +createdAt). (Precise per-version "who submitted" still wants the aggregate read model.)- Digest → drill-in. Each attention section now shows a capped preview (5) with a "See all →" header link into a dedicated full-list screen (
DashboardListroute +sectionListView, reusing the same row builders). "Jump back in" has no See-all (continuity, not a backlog). The VM keeps the full activity feed (was pre-capped at 6) so See-all is complete; notes See-all is bounded by the rollup limit (12). Files:Core/Models/Track.swift(decodeupdatedAt),Features/Shell/DashboardView.swift,track.service.ts(.populate('image')).
- Home Dashboard slice 3 — rolled-up notes + user-wide rollup endpoint (2026-06-22, Claude Code; backend
nest build+ iOSxcodebuildBUILD SUCCEEDED). Completes the dashboard's four attention sections (doc-37 §2): needs-approval → open notes → activity → jump-back-in.- Backend — user-wide rollup. New
CommentService.getUserRollup(userId)— the eagle view across every space the viewer can access (owner · collaborator · shared, viaArtistSpaceService.findAll), same unresolved/tagged digest as the per-nodegetRollupbut withartistSpace: {$in: spaceIds}(a single indexed find on the existing denorm). Exposed asGET /comment/rollup/me(@GetUser()-scoped, static route before:id).CommentModulenow importsArtistSpaceModule(no cycle — artistSpace doesn't import comment). - iOS — "Open notes" section.
DashboardViewModeladds a parallel/comment/rollup/meread (decodes the existingRollupResponse→[PortalNote]). Rows show the note content + a provenance breadcrumb (Project › Track · V#) and the unresolved accent dot, tappable to the anchoring track's player (project/artist-level notes render non-navigable). Placed second per doc-37 §2 priority. - Remaining (optional, latency only): the
GET /mobile/dashboardaggregate to collapse the now-5 parallel dashboard reads into 1 — the dashboard is fully functional without it. Plus the Viewer role-gate copy ("Awaiting approval"). Files (back):comment.service.ts,comment.controller.ts,comment.module.ts. Files (iOS):Features/Shell/DashboardView.swift.
- Backend — user-wide rollup. New
- Home Dashboard slice 2 — attention sections wired (2026-06-21, Claude Code;
xcodebuildBUILD SUCCEEDED). The dashboard now surfaces real "what needs my attention" content — 2 of 3 attention sections with zero backend work:- Needs your approval — derived client-side from the tracks the viewer can already list (
/trackowned +/track/shared), filtered toTrack.effectiveStatus == .underReview(deduped). No new endpoint — the listTrackalready carriesreleaseStatus. Tap → player (where the ApproveBar is already role-gated, so Viewer is effectively read-only). - Recent activity — new
AppNotificationmodel (named to avoid Foundation'sNotification);DashboardViewModelfetches/notification, sorts newest-first, caps at 6. Read-only rows (title / detail / relative time / unread dot); tap-to-navigate viapointerIddeferred (varies by event type). - Sections render only when non-empty (doc-37 §2.1); priority order approval → activity → recents.
hasContentspans all three so the welcome hero only shows when truly empty. - The four reads (
recent-access,notification,track,track/shared) fan out in parallel per load/refresh. Remaining for slice 3: the rolled-up unresolved notes section (the only one that needs a user-wide read — no per-node rollup covers it) and theGET /mobile/dashboardaggregate to collapse these 4 round-trips into 1. Role-gate refinement (Viewer header copy "Awaiting approval") also deferred. Files:Core/Models/AppNotification.swift,Features/Shell/DashboardView.swift.
- Needs your approval — derived client-side from the tracks the viewer can already list (
- Home Dashboard slice 1b — the dashboard root (2026-06-21, Claude Code;
xcodebuildBUILD SUCCEEDED). Home is now the dashboard. NewFeatures/Shell/DashboardView.swift(+DashboardViewModel): the signed-in root — in-contentSONNANCEwordmark, leading avatar → drawer (now includes.artists), trailing+create, and a "Jump back in" recents section wired to/track/recent-access(tap → player). Progressive-disclosure empty state (doc-37 §2.1): a brand-new user with no content sees a single welcome hero + "Create your first artist" CTA instead of empty section headers. The attention sections (needs-approval · rolled-up notes · activity) are scaffolded but render only once wired (slice 2), so the home never shows an empty header.RootTabsView→DashboardView.ArtistsHomeViewreverted to being purely the drawer's "Artists" screen (drawer wiring removed; in-content titleSONNANCE→Artists). ⏳ Visual QA: launch lands on the dashboard; recents vs. welcome hero; drawer → Artists/Teams/Shared/Recents; create-artist from the hero + the+. - Home Dashboard slice 1a — avatar nav drawer + tab collapse (2026-06-21, Claude Code;
xcodebuildBUILD SUCCEEDED). First build of doc-37: established the single-surface IA. NewFeatures/Shell/AppMenuDrawer.swift— a Liquid-Glass account/navigation sheet (.thinMaterial, medium/large detents) with an identity header (avatar + name/email; storage meter deferred — clientUsercarries no subscription yet), role-gateable destinations grouped Workspace (Artists · Teams) / Library (Shared · Recents), and a You group (appearance + sign out).AppSectionenum is the destination catalogue. Wiring:RootTabsViewcollapsed from a 3-tabTabViewto the singleArtistsHomeViewroot; the old trailing profile menu replaced by a leading account avatar that opens the drawer; Teams/Shared/Recents now reached from the drawer (presented as large sheets — each owns its ownNavigationStack, so pushing would double the nav bar; see follow-up)..artistsomitted from the drawer this slice (home is still the artist grid — it joins when the dashboard root lands). ⏳ Visual QA: avatar drawer open/dismiss, destination sheets, sign-out.- Follow-ups: (1b) dashboard root — swap the artist-grid home for the attention dashboard (§2), add
.artiststo the drawer; (2) wire the attention sections + role-gate; (3)GET /mobile/dashboardaggregate; (4) unified-stack refactor so Teams/Shared/Recents become true pushed destinations (strip their innerNavigationStacks) instead of sheets.
- Follow-ups: (1b) dashboard root — swap the artist-grid home for the attention dashboard (§2), add
- Phase opened: Home Dashboard (doc-37) (2026-06-21, owner-locked). Next phase after the first-load perf slice = the role-driven "what needs my attention" home dashboard (doc-20 §4.2). New envisioning doc
37-HOME-DASHBOARD.md; roadmap doc-index +.agent/next-brief.mdActive-phase updated. One dashboard, role-gated content — not per-user-type homes (deferred). Mostly assembly of shipped features (roll-up doc-33, approval doc-32, realtime doc-35, notifications, recents — all source endpoints verified to exist); the only new backend is the optionalGET /mobile/dashboardaggregate. No code yet — phase setup so it survives a context compaction. Owner decisions + nav envisioned (2026-06-21): home is the dashboard; roster + other sections move to a secondary menu — envisioned as an account-avatar Liquid-Glass drawer (NOT a tab bar, which dilutes the one-surface intent; NOT a literal edge-drawer, non-native on iOS) of role-gated destinations (Workspace / Library / You), trailing+stays create; empty state = progressive disclosure + promoted create-artist hero; Viewer gets the dashboard (approval read-only). Full reasoning in37-HOME-DASHBOARD.md§5. - iOS:
SONNANCEhome title now renders in the brand display face (real fix) (2026-06-21, Claude Code;xcodebuildBUILD SUCCEEDED). Supersedes the round-2 appearance-proxy attempt: SwiftUINavigationStackignoresUINavigationBarAppearancefont for large titles (UIKit-only API), so the face never changed (only the uppercasing did). Fixed by rendering the wordmark as in-contentTextin.sonnanceDisplay(.black)at the top ofArtistsHomeViewwith the system title hidden (navigationTitle("")+.inline) — the pattern the player title already uses. File:Features/Browse/ArtistsHomeView.swift. ("Rides up on scroll" stays as Home-header redesign #6.) - First-load perf slice — optimistic skeleton · persisted
File.duration· prefetch-on-intent (2026-06-21, Claude Code; backendnest build+ iOSxcodebuildBUILD SUCCEEDED). Attacks the remaining first-open / first-byte lag (doc-24 §Performance). Three coordinated parts:- Optimistic skeleton (iOS, the felt win). Opening a track no longer shows a bare centred spinner while
GET /track/:idruns.TrackRoutenow carries the track name + cover thumb (the list already has both), seeding the player so the hero + title paint instantly (the thumb is already in the decoded cache from the row) with faint placeholder bars for the parts that need the fetch — the layout doesn't jump when detail lands. Halo also seeds from the track's own cover first (doc-22 §6 continuity). BarePlayerView(trackId:)entries (Recents / Shared) degrade gracefully to no-name + project cover. - Persisted
File.duration(backend ride-along). New optionalduration(seconds) on theFileschema. The iOS uploader computes it locally from theAVURLAsset(instant — moov-atom parse, no decode) and sends it as a multipart text field;POST /track/:id/filecoerces + validates it (parseDuration) and stores it. No server-side audio-probe dependency (dodges the ESMmusic-metadata/CJS friction). The player shows the timecode immediately and skips theasset.load(.duration)network round-trip; absent on images / pre-slice / web uploads, where it cleanly falls back to loading from the asset. - Prefetch-on-intent (iOS). The
AVPlayerItemis set as the player's current item as soon as detail loads — before the user taps play — so AVPlayer buffers the first bytes while they read the screen; first play then starts without the cold-buffer wait (session stays unactivated, so it never interrupts other apps). Files (back):file/models/file.schema.ts,file/models/file.dto.ts,track/track.controller.ts,track/track.service.ts. Files (iOS):Core/Models/MediaFile.swift,Core/APIClient.swift,Features/Review/PlayerView.swift,Features/Browse/ArtistsHomeView.swift,Features/Browse/ProjectTracksView.swift. ⏳ Visual QA: confirm the skeleton paints instantly and the timecode shows before play on newly-uploaded versions (old/web versions still load duration over the network — that's expected, no backfill).
- Optimistic skeleton (iOS, the felt win). Opening a track no longer shows a bare centred spinner while
Fixed
- iOS: first-song cold-start warm-up · player album-cover fallback ·
SONNANCEbrand title (2026-06-21, Claude Code;xcodebuildBUILD SUCCEEDED). Three owner-raised polish items.- First-track cold-start lag (only the very first open after a fresh install).
SonnanceAppnow warms the AVFoundation stack off-main at launch (setCategory+ a throwawayAVPlayer) so connecting tomediaserverd+ initializing the audio session happens during launch, not on the first play. (The first audio byte fetch is still network-bound — inherent to a cold cache.) doc-24 §Performance. - Player shows the album cover for coverless tracks.
PlayerBarusedcoverFile.blobUrlwith no fallback, so a track without its own cover showed a bare gradient. Now it cascades track cover → project/album cover (coverThumbURL ?? projectThumbImageURL ?? projectImageURL), matching the hero + doc-28 display cascade. SONNANCEtitle in the display font. The home title now renders in PPMonumentExtended-Black. (Round 3 — the real fix. Neither the legacylargeTitleTextAttributesproxy nor a fullUINavigationBarAppearanceapplies a custom font to a SwiftUINavigationStacklarge title — SwiftUI re-applies its own title rendering (the appearance font works in UIKit only; uppercasing the string "took" but the face never changed). Fixed by rendering the wordmark as in-contentTextin.sonnanceDisplay(.black)at the top ofArtistsHomeViewand hiding the system title (navigationTitle("")+.inline) — the same pattern the player's display title already uses successfully. The transparentUINavigationBarAppearancefrom round 2 stays (it correctly controls the bar background); only the font lever moved in-content.) The "rides up on scroll" treatment is the deferred Home-header redesign #6. Files:SonnanceApp.swift,Features/Review/PlayerView.swift,Features/Browse/ArtistsHomeView.swift.
- First-track cold-start lag (only the very first open after a fresh install).
Added
- iOS:
CachedImagesweep — every image now uses the decoded cache; font verified (2026-06-21, Claude Code;xcodebuildBUILD SUCCEEDED). Completes the decoded-image-cache rollout — zeroAsyncImageremain in the app (outsideCachedImageitself).- Swapped the remaining 8 sites: player mini-artwork (
PlayerBar), track/cover thumbs + hero (TrackPortalContent— also removes itsProgressViewspinners), portal covers (ArtistPortalContent/ProjectPortalContent), and avatars (note + collaborator avatars inPortalSharedSections,TeamDetailViewmember avatars,TeamsViewteam avatars). Avatars/covers now layer the placeholder (initials / icon) under aCachedImagewith a clear placeholder → instant on revisit, no flash, no spinner anywhere. - Font verified (resolves doc-24 #7): read the bundled Lexend
nametables — PostScript names are exactlyLexend-Bold/Lexend-Regular/Lexend-Light, which matchTypography.swift'sFont.custom(...). SosonnanceTitle/Body/Caption(incl. the "Projects"SectionHeader) are rendering real Lexend now that the files are bundled +UIAppFonts-registered. Stakeholder-approved; nothing to change — any remaining dislike is a styling choice (weight/treatment), not a wrong font. Files:DesignSystem/Components/PlayerBar.swift,Features/Review/TrackPortalContent.swift,Features/Browse/ArtistPortalContent.swift,Features/Browse/ProjectPortalContent.swift,Features/Browse/PortalSharedSections.swift,Features/Teams/TeamDetailView.swift,Features/Teams/TeamsView.swift.
- Swapped the remaining 8 sites: player mini-artwork (
- iOS: decoded-image cache — kill the "default cover" flash app-wide (doc 34; CDN de-prioritized) (2026-06-21, Claude Code;
xcodebuildBUILD SUCCEEDED). The real root cause of the laggy/flashy cover loads:AsyncImagealways cycles.empty → .success, even when the bytes are cached — because the doc-34URLCacheonly holds raw bytes that still need an async decode. That one placeholder frame is the flash, and it repeated on every appear / scroll / zoom.DecodedImageCache(newCore/CachedImage.swift) — a process-wideNSCache<NSURL, UIImage>of decoded images (~96 MB, cost-weighted). The missing tier aboveURLCache(bytes/disk).CachedImageview — seeds from the decoded cache ininit, so a previously-seen cover paints on the first frame (no placeholder, no flash); a miss shows the placeholder, fetches via the URLCache- backedURLSession, decodes off the main thread (preparingForDisplay), stores it, and crossfades it in. Drop-in for the success/placeholderAsyncImagepattern.- Swapped
AsyncImage → CachedImagein the cover components:ArtworkHero(heroes + artist tiles),ProjectCard,TrackRow. The card populating the cache means the zoom destination hero reads the same decoded cover instantly → the Artist→Project flash is gone at the source. (Avatars + remainingAsyncImagesites are a follow-up sweep.) - CDN de-prioritized (owner call): the decoded cache is the short-term, free answer to cover performance; a CDN (doc-05 Front Door) is expensive and not a must — explicitly moved to a far-future scale lever, not near-term (doc-34 updated). Files:
Core/CachedImage.swift(new),DesignSystem/Components/ArtworkHero.swift,DesignSystem/Components/ProjectCard.swift,DesignSystem/Components/TrackRow.swift.
Fixed
- iOS: kill the cover flash + loaders in the zoom transition; version-color the activity feed (2026-06-21, Claude Code;
xcodebuildBUILD SUCCEEDED). Owner-raised, post-QA of the zoom transition + ACTIVITY feed.- No more spinner during the zoom.
ProjectTracksViewonly rendered the hero after tracks loaded — so the zoom revealed a centeredProgressView. Restructuredcontentso the hero always renders immediately (seeded from the route) and the tracks list fades in beneath it; the full-screen spinner is gone (owner: "get rid of loaders"). - No more cover pop.
ArtworkHero'sAsyncImagealways cycles.empty → .success(even cached), which read as a flash. Now it crossfades the cover in over the identity-colored gradient (Transaction(animation:)+.opacitytransition) — doc 22 §4 "nothing pops." Applies everywhere ArtworkHero is used (tiles + heroes). - Activity items follow their variant's color. A rolled-up version comment now takes that version's color code (
Theme.versionColor): the kind chip, a newV{n}capsule, and the timecode chip are all tinted by the version color, so comments read as grouped by their variant. Notes (no version) keep the accent. Files:Features/Browse/ProjectTracksView.swift,DesignSystem/Components/ArtworkHero.swift,Features/Browse/PortalSharedSections.swift.
- No more spinner during the zoom.
- iOS: roll-up polish — level-context label, version suppression, Artist→Project zoom flash (2026-06-21, Claude Code;
xcodebuildBUILD SUCCEEDED). Three owner-raised items off the consolidatedACTIVITYfeed + the new zoom transition.Here→ level context. The genericHEREtag on a node's own annotations now reads the level (ARTIST/PROJECT/TRACK) + the node name below it (PROJECT· Demo Place) — a "where we are" reminder (doc 36).anchorNamethreaded intoPortalNotesSectionfrom the three portal contents.- Version level suppresses it — at the version (
.file) level a timecoded comment already implies "this version", so the level tag + name are hidden (owner-raised; doc 36). - Artist→Project zoom flash fixed.
ProjectTracksViewdidn't seedartworkAccentin its init (unlikeArtistDetailView), so during the zoom the project halo started at the identity default and crossfaded to the cover color — a flash. Now it seeds from the navigating card's already-cached cover color (projectThumbImageURL ?? projectImageURL), so the halo is correct on the first frame (doc 22 §6). The Roster→Artist hop never flashed becauseArtistDetailViewalready seeds. Files:Features/Browse/PortalSharedSections.swift,Features/Browse/ArtistPortalContent.swift,Features/Browse/ProjectPortalContent.swift,Features/Review/TrackPortalContent.swift,Features/Browse/ProjectTracksView.swift.
Added
- iOS: zoom-into-cover hierarchy navigation transition (doc-36) (2026-06-21, Claude Code;
xcodebuildBUILD SUCCEEDED — needs visual QA + halo-timing tuning). The browse pushes (Home→Artist→Project→Track→ Player) popped, fighting the identity gradient/halo at the top of every screen. Now the tapped cover is the matched source and each destination zooms from it, so the gradient reads as continuous through the hierarchy — the unbuilt navigation half of doc-22 §4 ("nothing pops") + §6 (halo continuity).- One shared
@Namespace zoomNSinArtistsHomeView(theNavigationStackowner), threaded down toArtistDetailView+ProjectTracksView(whose project/track source tiles live in child views). - Sources:
.matchedTransitionSource(id:in:)on the artist tile (Home), project card (ArtistDetailView), track row (ProjectTracksView) — via a smallView.zoomSource(_:in:)helper that no-ops when the namespace is absent (so a view built outside the stack just falls back to a normal push). Destinations:.navigationTransition(.zoom(sourceID: route.id, in: zoomNS))on eachnavigationDestination. - The halo is already identity-keyed (doc-22 §6), so it interpolates across the push for free. iOS-18 APIs; graceful fallback to a normal push when a source isn't found.
- Remaining: visual QA + tuning against the halo crossfade timing (don't double-animate), per the doc-36 §Navigation-transitions note. Implementation is compile-verified; the polish pass is visual. Files:
Features/Browse/ArtistsHomeView.swift,Features/Browse/ArtistDetailView.swift,Features/Browse/ProjectTracksView.swift.
- One shared
- Portal annotations consolidated into one
ACTIVITYfeed (2026-06-21, Claude Code;nest buildclean + iOSxcodebuildBUILD SUCCEEDED). Owner-raised: the Notes band + the roll-up band were two separate sections — merge them. Now oneACTIVITYsection per Portal (doc-33 §Portal annotations, doc-36):- Two zones, one feed: the node's own annotations (tagged
Here) pinned on top, then the rolled-up subtree digest (provenance breadcrumb) below. Compose stays on top; the unresolved count moved into the section header. Default sort = unresolved-first, then newest within each zone. - Backend —
findNotesscoped to the EXACT anchor (comment.service.ts). Post-denorm, every note carriesproject/artistSpace, so a bare{project}match returned the whole subtree; the "Here" zone must be the node's own notes, so the query now excludes deeper anchors ({project, track:{$exists:false}, file:{$exists:false}}, etc.). This also makes the existing "Anchored here" band correct. - iOS (
PortalSharedSections.swift):hereItems(= scopedfindNotes) +belowItems(= roll-up, de-duplicated against the Here ids so a here-note isn't shown twice), eachdigestSorted;noteRowgained theHerecapsule; the separaterollupBandremoved (items now render inline below the Here zone). - Sort control (
Here-first | Newest | Unresolved) is the deferred follow-on (default ships now).
- Two zones, one feed: the node's own annotations (tagged
Fixed
iOS: Post button always reachable above keyboard (2026-06-21, Xcode agent;
BuildProjectgreen). Bug: inPortalNotesSection.composeView, the inline Post button sits below the tags field and scrolls off-screen when the software keyboard is raised. Fix: added a keyboard toolbar (ToolbarItemGroup(placement: .keyboard)) to the compose VStack so a Post button appears pinned above the keyboard whenever either TextField (content or tags) is focused — the inline Post button is retained as well. Disabled state mirrors the inline button (empty content or in-flight post). File:Features/Browse/PortalSharedSections.swift.iOS: note compose field hidden by keyboard + rollup tap hitch (2026-06-20, Claude Code;
BuildProjectgreen). Two QA fixes from owner testing of the doc-33 roll-up iOS slice.- Compose field scrolls into view (
Portal.swift+PortalSharedSections.swift): added.scrollDismissesKeyboard(.interactively)to the Portal'sScrollViewso a downward swipe dismisses the keyboard without losing the compose state; wrappedPortalNotesSection.bodyinScrollViewReaderand added.onChange(of: showComposer)to callproxy.scrollTo("portal_notes_composer", anchor: .bottom)(with a 150 ms delay so the view is inserted before the scroll fires) — ensures theTextFieldis visible when the keyboard rises. - Rollup tap hitch reduced (
PortalSharedSections.swift): (1) removed thewithAnimation(.easeInOut)wrapper from theonTapGesturetoggle — SwiftUI can't smoothly interpolatelineLimitchanges and the animation was the source of the layout-measurement hitch; (2) deferred.textSelection(.enabled)to expanded state only (usingif isExpandedbranching, since.enabled/.disabledare non-substitutable concrete types) — eliminates gesture-recognizer overhead on every collapsed row. Files:DesignSystem/Components/Portal.swift,Features/Browse/PortalSharedSections.swift.
- Compose field scrolls into view (
Backend: "Couldn't post note" + missing provenance on anchored notes — inconsistent populate (2026-06-20, Claude Code;
nest buildclean, lint clean). Root cause: the doc-33 iOS slice extendedPortalNoteto decodetrack/project/fileas populated objects (for roll-up provenance), butcreateNote(thePOST /comment/noteresponse) andfindNotes(GET /comment/notes) only populatedowner— so those refs came back as raw ObjectId strings.decodeIfPresenthits a type mismatch (string where an object is expected) and the whole decode throws → "Couldn't post note" (the note WAS created server-side; only the response decode failed) and no breadcrumb on the "Anchored here" notes. Fix:createNote+findNotesnow populate the same provenance shape as the roll-up (track/projectname shortId slug,fileversion type), so every annotation response decodes identically and notes carry their breadcrumb too. (createCommentByFile's wave payload is decoded asTrackComment, which ignores those fields — left as-is.) File:comment/comment.service.ts.
Added
iOS: doc-33 eagle-level roll-up — provenance breadcrumb, peek-then-go navigation, touch-text, roster nits (2026-06-20, Claude Code;
BuildProjectgreen). Completes the iOS half of the doc-33 eagle-level roll-up slice.PortalNotemodel now decodestrack/project/fileprovenance fields populated by the backend rollup endpoint (RollupProvenanceRef+RollupFileRefinComments.swift). Nil-safe — notes-only responses unaffected.- Rollup band (PortalSharedSections.swift): each item now shows a provenance breadcrumb (e.g. "Demo Place › Track 3 › V2") trimmed to whatever the backend populated. Items expand on tap (peek-then-go, doc 36): collapsed = 2-line truncated; expanded = full content + breadcrumb + timecode + "Open in {trackName} →" button. The "Open" button fires
onNavigateToTrack(trackId, timeStart?), threaded fromPortalNotesSection→ArtistPortalContent/ProjectPortalContent→ArtistDetailView/ProjectTracksView; the host view setsshowPortal = falseand pushes aTrackRouteonto the nav path once the sheet dismisses. Long-press → context menu (Copy text / Mark resolved)..textSelection(.enabled)on both note content and rollup item content. commitToggleRollupResolveadded — optimistic resolve toggle for rollup items (separate fromcommitToggleResolvewhich targets the "Anchored here" notes array).- Roster nits (ArtistsHomeView.swift): (1) Roster section header count trailing margin fixed — changed
.padding(.horizontal, -SnSpacing.md)to.padding(.leading, -SnSpacing.md)so the count text has a layout-gutter margin from the screen edge. (2) Per-artist roster capsule suppressed in grouped layout (showRoster: Bool = trueonArtistTile;groupedContentpassesfalsesince the section header already names the roster). Files:Core/Models/Comments.swift,Features/Browse/PortalSharedSections.swift,Features/Browse/ArtistsHomeView.swift,Features/Browse/ArtistPortalContent.swift,Features/Browse/ArtistDetailView.swift,Features/Browse/ProjectPortalContent.swift,Features/Browse/ProjectTracksView.swift.
Backend: doc-33 eagle-level roll-up — notes & comments surface up the hierarchy (2026-06-20, Claude Code;
nest buildclean,jest14/14, 0 new lint errors, boot-verified —/comment/rollup+/comment/rollup/countsmapped, no DI errors). Owner-confirmed model (doc-33 §Surfacing UX, decisions 6a–6d): a node's Portal shows a rolled-up digest of child annotations, and cards/rows everywhere get an ambient count badge — one unified roll-up (notes + comments), digest-filtered (unresolved · tagged).- Denormalized ancestor chain. Every
Commentnow carries its full lineagetrack/project/artistSpace(indexed), set on create from the most-specific anchor (resolveAnchors: file→track→ project→artist). The timelinecreatederives project+artist off the track it already fetches (no extra query);createNoteresolves the chain (a project-anchored note gets its artist via one Project lookup — theProjectmodel is now registered inCommentModule). So a subtree roll-up is a single indexed find, correct across children the client never loaded. GET /comment/rollup?{trackId|projectId|artistId}=X→{ counts: {total, unresolved, tagged}, items: [capped, newest-first] }— the Portal "rolled-up" band.GET /comment/rollup/counts?type=&ids=a,b,c→ batch{ id: counts }via one aggregation — powers the badge across a whole grid in one request. Both declared before@Get(':id').- Provenance on roll-up items (owner-raised 2026-06-20) — items now populate
track/projectnames (+shortId/slug) andfile.version, so a rolled-up annotation can show its source path (📝/💬 · roster › artist › project › track › V#) and the client can deep-link to the exact anchor. Grounds the new36-INTERACTION-PATTERNS.md(envisioning: provenance + peek-then-go navigation, touch-text select/copy/edit, the Roster Portal, and "view"/seen events). - Filter = digest (decision 6b): unresolved OR tagged. The per-actor lens (awaiting-my-approval, #legal) layers on the client (doc-26). Roster badge = the client sums its artists' counts (no new endpoint).
- Backfill
scripts/backfill-comment-ancestors.ts(dry-run-gated) denormalizes ancestors onto existing comments so they show in roll-ups. Run it after deploy (existing comments lack the denorm). - Deferred/flagged: access-gating the roll-up endpoints (matches the existing comment reads, which don't gate — hardening follow-up); the per-actor lens; live-emit of count changes; doc-32 approval-status in the "status-bearing" filter. Next = iOS (badge on Artist/Project/Track/Roster cards + the Portal band). Files:
comment/comment.service.ts,comment/comment.controller.ts,comment/comment.module.ts,comment/models/comment.schema.ts,scripts/backfill-comment-ancestors.ts.
- Denormalized ancestor chain. Every
Fixed
- iOS+backend: "Couldn't load notes" —
APIClientcorrupted query-string URLs (2026-06-20, Claude Code; iOSxcodebuild+ backendnest buildboth green). Root cause:APIClient.buildRequestbuilt URLs withAPIConfig.baseURL.appending(path:), which treats the whole string as one path component and percent-encodes the?. The doc-33 notes call (GET /comment/notes?artistId=…) was the first iOS GET with a query string, so the?became%3F: the server received/comment/notes%3FartistId=…as one segment, missed@Get('notes'), fell through to@Get(':id'), andfindById("notes?artistId=…")threw a Mongoose CastError → 500 → "Couldn't load notes." (My earlier "env/deploy" diagnosis was wrong — it was a URL-builder bug, not freshness.)- iOS fix: new
APIClient.makeURL(path:)splits path vs query viaURLComponents(preservingpercentEncodedQuery) when the path contains?; non-query paths keep the oldappending(path:). Fixes every current + future query-string GET, not just notes. File:Core/APIClient.swift. - Backend hardening:
CommentService.findByIdnow guardsisObjectId→ clean 404 instead of a Mongoose CastError 500 for a malformed id (misrouted query string, bad deep-link, etc.). File:comment/comment.service.ts.
- iOS fix: new
- iOS: opening the wave player no longer stops other apps' audio + comment-box literal (2026-06-20, Claude Code;
xcodebuildBUILD SUCCEEDED). Two owner-raised wave-view items.- Audio-session interruption. The doc-35-era
configureAudioSessionset the category andsetActive(true)onPlayerViewModelinit, so merely viewing a track took audio focus and stopped music playing in another app. Split it: init now only sets the category (.playback+.mixWithOthers— non-interrupting), and a newactivateAudioSession()(setActive(true), exclusive, re-sets the category without.mixWithOthers) fires only when the user hits play (playPausestart +selectVersionresume). Other apps keep playing until you actually press play; background playback / lock-screen transport unchanged. File:Features/Review/PlayerView.swift. - Comment-box placeholder. The wave timeline composer said "Add a note" — confusing now that real Notes (doc 33) exist elsewhere. Changed to "Add a comment" (it's the time-synced comment box). File:
DesignSystem/Components/CommentComposer.swift.
- Audio-session interruption. The doc-35-era
Added
- iOS: doc-33 eagle-level roll-up — ambient badges + Portal activity band (2026-06-20, Xcode agent;
BuildProjectgreen, 0 diagnostics across 8 files). Surfaces roll-up counts on every card/row and adds an "ACTIVITY" rolled-up band in all three Portals — pure iOS, no backend changes.RollupCounts+RollupResponseadded toComments.swift;PortalNoteextended withtype?+timeStart?so the roll-up band can decode both NOTE-kind and timeline-comment items.- Ambient badge on cards/rows — amber capsule showing
unresolvedcount, hidden when 0:ProjectCard(.topLeadingartwork overlay),TrackRow(inline before status pill),ArtistTile(.topTrailingcircle overlay). One batch request per grid:GET /comment/rollup/counts?type=&ids=a,b,cloaded best-effort after main data inArtistsHomeViewModel,ArtistDetailViewModel,ProjectTracksViewModel. Roster section headers show a client-side-summed badge across their artists' unresolved counts. - Portal "ACTIVITY" band in
PortalNotesSection— loaded in parallel with notes viaGET /comment/rollup?{param}=id. Shows unresolved count, then per-item rows: kind chip (note vs feedback), author + relative date, optional timestamp pill, 3-line content preview, resolved checkmark. File-anchor skips the band (endpoint is track/project/artist only). Tap-to-navigate is a flagged follow-on. Files:Core/Models/Comments.swift,DesignSystem/Components/ProjectCard.swift,DesignSystem/Components/TrackRow.swift,Features/Browse/ArtistsHomeView.swift,Features/Browse/ArtistDetailView.swift,Features/Browse/ProjectTracksView.swift,Features/Browse/PortalSharedSections.swift.
- iOS: doc-33 v1 — Portal Notes section (Artist / Project / Track portals) (2026-06-20, Xcode agent;
BuildProjectgreen, 0 diagnostics across 5 files). Surfaces the backend NOTE-kind Comments in all three Portals — pure iOS UI, no backend changes.PortalNote+CreateNoteRequestadded toComments.swift— mirrors the backendCommentschema (_id,content,owner(populated),createdAt,isResolved,tags[]).CreateNoteRequestcustom-encodes with nil-omission so the backend'swhitelist+forbidNonWhitelistedpipe never rejects; usesartistSpaceId(notartistId) matchingCreateNoteDto.NoteAnchorTypeenum maps artist / project / track / file to the correct query param (artistId,projectId,trackId,fileId) forGET /comment/notes?…and to the correct body field forPOST /comment/note.PortalNotesSection(new reusable component inPortalSharedSections.swift) — "NOTES" section header + "+" toggle to open inline compose; per-note rows show avatar (initials + themed accent, async image), author name, relative date (RelativeDateTimeFormatter), content, tag chips (horizontal scroll,#-prefixed); resolve toggle (circle → green checkmark, optimistic flip with server revert on failure); empty state; loading and error states.- Wired into
ArtistPortalContent(anchorType: .artist),ProjectPortalContent(anchorType: .project), andTrackPortalContent(anchorType: .track). - Deferred (per brief): file-level anchor, eagle-level roll-up, note live-emit / notifications, approval elevation, pinning, #Tags lens cross-hierarchy surfacing. Files:
Core/Models/Comments.swift,Features/Browse/PortalSharedSections.swift,Features/Browse/ArtistPortalContent.swift,Features/Browse/ProjectPortalContent.swift,Features/Review/TrackPortalContent.swift.
- Backend: doc-33 v1 — notes as node-anchored Comments (2026-06-20, Claude Code;
nest buildclean,jest14/14, 0 new lint errors, boot-verified —/comment/note+/comment/notesmapped, no DI errors). Grounds the owner's "real Notes" ask on the existingCommententity (no new collection) — a Note is a Comment anchored to a node instead of a moment. Purely additive (no client breakage).Commentextensions:CommentType.NOTE; anartistSpaceanchor (notes can live at any level — artist / project / track / version);tags: string[]+shareTags— the cross-hierarchy #Tags surfacing, owner-private + opt-in. Privacy is free: the existingStripPrivateTagsInterceptorduck-types{owner, tags, shareTags}, soComment.tagsare stripped from non-owner reads automatically.POST /comment/note(CreateNoteDto= content + one anchor id + tags + shareTags) →createNote, a track-tolerant path (the timeline-commentcreateassumes a track + notifies its collaborators; notes can anchor anywhere, so they get their own path).GET /comment/notes?artistId|projectId|trackId|fileId→findNotes, newest-first, the Portal's "Anchored here" band.- Confirms doc-33 decision 1 (explicit NOTE kind) + decision 3 (per-resource, owner-private, opt-in tags — same flag as the node tags shipped 2026-06-18).
- Deferred (flagged): the eagle-level roll-up (decision 6 — subtree comment-aggregate read); the
Track.notes → Track.descriptionrename (decision 2 — touches clients, needs coordination); note notifications + live-emit to the anchor room; opt-in approval elevation (decision 4) + pinning (decision 5). Plus the iOS Portal Notes section. Queued innext-brief. Files:comment/models/comment.schema.ts,comment/models/comment.dto.ts,comment/comment.service.ts,comment/comment.controller.ts.
- Backend: doc-32 v1 — approval as a dimension (cover per-image + tracklist + release-gate rollup) (2026-06-20, Claude Code;
nest buildclean,jest14/14, 0 new lint errors, boot-verified — new routes mapped, no DI errors). Generalizes the doc-29 audio-version approval into a dimensional model. Owner-confirmed scope: cover (per-image) + tracklist (decisions 1 & 3).- Cover = a per-image approved dimension. The existing
PATCH /track/:trackId/file/:fileId/review-statusnow generalizes its mirror: an image final →releaseStatus.cover, an audio final →releaseStatus.version(unchanged). Reuses the doc-29File.reviewStatusfields — the verdict attaches to the exact cover File, never a floating "cover." Honesty hooks: a fresh cover upload (createNewFile, image) → cover Under review + the new File Under review;setCoverImage(promote) →releaseStatus.coverfollows the promoted image's verdict. - Cover-history DELETE (doc 32 §7.3):
DELETE /track/:trackId/image/:fileId→softDeleteImageVersion, soft-deletes an image version and pulls it fromimage[], guarded — refuses the currentimageFinal(promote another cover first), so the badge never points at a deleted image. Completes the cover CRUD (setCoverImage= promote was the only verb). - Tracklist dimension:
Project.tracklistStatus { status, by, at }(reusesTrackReleaseStatus) +PATCH /project/:id/tracklist-status(owner/admin gated in the service, mirroring the doc-29 gate).Track.orderalready existed, so no order-model change. (Reset-on-reorder deferred — the reorder endpoint is currently commented out / unexposed; flagged in doc 32.) - Release-gate rollup:
GET /project/:id/release-readiness— read-time compute (no stored truth): every track's final audio + cover Approved and the tracklist locked →{ ready, tracksTotal, audioApproved, coverApproved, tracklistApproved, tracks[] }. The Project-Portal headline + the precondition the future distribution gate reads. Computed inproject.servicefrom tracks the controller fetches viatrackService.findOrderedTracks. - iOS Portal surfacing shipped — see entry below.
- Remaining: Status-lens surfacing + still-envisioned dimensions (lyrics, master, phase marker) + review-history log + quorum (doc-32 decisions 2/4/5/6). Queued in
next-brief. Files:track/track.service.ts,track/track.controller.ts,project/project.service.ts,project/project.controller.ts,project/models/project.schema.ts.
- Cover = a per-image approved dimension. The existing
- Backend: doc-35 phase 4b — real-time emit on sharing mutations (completes backend realtime) (2026-06-20, Claude Code;
nest buildclean,jest14/14, 0 new lint errors, boot-verified). Extends the realtime layer to sharing, so Shared / With-me feeds can go live. With this, all backend emit for doc-35 is done — the only remaining piece is the iOS Shared-feed consumer.- Events → the affected member's
user:room:collaborator:added(a user opens a share link →addUserToLink),collaborator:removed(owner removes someone from a space/project/track),team:sharedteam:unshared(fan-out to each accepted team member onshareToTeam/unshare). Thin payloads ({ resourceType, resourceId, [teamId,] actorId }) per the doc-35 convention; members suppress their own echo viaactorId.
- Emitted from the services (
collaboration.service,team.service) — unlike the controller-emitted hierarchy CRUD (4a), because the affected-member fan-out is computed inside the service (the team recipient list, the cascade). One event per user-action: the collaborator-remove cascade (space → projects → tracks) emits a single space-level event — the recursiveremoveCollaboratorFrom*calls are gated by!recursive. - DI: both services inject the existing
@GlobalEventsGateway(zero module plumbing); each got a small best-effortemitToMemberFeed()(a socket failure never breaks the write). Boot confirmed the DI graph resolves (collaboration + team routes mapped, noUnknownDependencies). - Paired iOS follow-on (the last realtime piece): the Shared / With-me VM joins its
user:room and merges these events — it already has theSocketIOManagerplumbing from phase 5. Queued innext-brief. Files:collaboration/collaboration.service.ts,team/team.service.ts.
- Events → the affected member's
Added
- iOS: doc-32 Portal approval surfacing (2026-06-20, Xcode agent;
BuildProjectgreen, 0 diagnostics). Wires the doc-32 backend (cover per-image approval + cover-history delete + tracklist lock + release-gate rollup) into the Track Portal and Project Portal. Pure iOS — no backend changes.- Track Portal — cover approval (doc 32): the Cover section now shows the current cover's approval
StatusPill(sourced fromdetail.releaseStatus.cover). Owner/admin sees Approve / Request Changes buttons that callPATCH /track/:trackId/file/:fileId/review-statuswith the image file ID — the backend mirrors the verdict intoreleaseStatus.cover. The Label Copy Table "Track cover release" row now reflects the live status instead of a hardcodednoStatus. - Track Portal — cover-history delete (doc 32 §7.3): history rail items gain a red ×-trash badge. Tapping calls
DELETE /track/:trackId/image/:fileIdvia newPlayerViewModel.deleteCoverVersion()(refetches detail on success). The badge is disabled for the current final (the server would reject it anyway — backend guard is authoritative). - Project Portal — tracklist lock (doc 32): new "TRACKLIST" section (always visible) shows the current
tracklistStatusas aStatusPill. Owner/admin gets aMenuto set the status to anyTrackReleaseStatusvalue viaPATCH /project/:id/tracklist-status; the Portal reloads after success. - Project Portal — release-gate headline (doc 32): new "RELEASE READINESS" section loaded alongside the project detail via
GET /project/:id/release-readiness(best-effort, never blocks). Shows: audio N/M · cover N/M · tracklist status + aReady / Not readyStatusPill. Refreshes after each tracklist status change. - Models added:
TracklistStatusstruct (inProject.swift) — decoded fromProjectDetail.tracklistStatus.ReleaseReadiness+ReleaseReadinessTrack(inReleaseStatus.swift) — response shape ofGET /project/:id/release-readiness.UpdateTracklistStatusRequest— body forPATCH /project/:id/tracklist-status. Files:Core/Models/Project.swift,Core/Models/ReleaseStatus.swift,Features/Review/TrackPortalContent.swift,Features/Review/PlayerView.swift,Features/Browse/ProjectPortalContent.swift.
- Track Portal — cover approval (doc 32): the Cover section now shows the current cover's approval
- iOS: doc-35 phase 4b consumer — Shared / With-me feed goes live (completes doc-35) (2026-06-20, Xcode agent;
BuildProjectgreen, 0 diagnostics).SharedViewModelnow subscribes to theuser:room so the Shared tab updates in real-time when collaborators are added/removed or a team share is granted/revoked — no manual pull-to-refresh needed.SocketUserEventextended with 4 new cases:collaboratorAdded,collaboratorRemoved,teamShared,teamUnshared— matching the thin{ resourceType, resourceId, [teamId,] actorId }payloads emitted bycollaboration.service/team.service(phase 4b). Parsed inSocketIOManager. parseSocketEventunder a new// MARK: User room — sharing eventsblock.SharedViewModel.setupSocket()—connect()+observeUserhandler; any of the 4 sharing events triggersreloadAll()(parallel reload of bothload()+loadByMe()). Own-echo suppressed viaactorId == currentUser.id.onReconnect→reloadAll()to catch any missed events after a drop.teardownSocket()clears the handler + reconnect slot on view disappear.SharedViewwires the calls:setupSocket()after initial load inside.task;.onDisappearcallsteardownSocket(). Matches the pattern established byArtistsHomeViewin phase 5.ArtistsHomeViewModel.handleUserEventupdated to handle the 4 new enum cases (break) so it stays exhaustive.doc-35is now fully complete — all backend emit (phases 1–4b) and all iOS consumers (phases 3, 5, 4b consumer) are shipped. Files:Core/SocketIOManager.swift,Features/Shared/SharedView.swift,Features/Browse/ArtistsHomeView.swift.
Fixed
- iOS: AVAudioSession main-thread hang-risk warnings (PlayerView) (2026-06-19, Claude Code;
xcodebuildBUILD SUCCEEDED). Xcode flaggedPlayerView.swift:139–140(SessionCore.mm:631/AVAudioSession_iOS.mm:975) — bothPlayerViewModelinits configured the shared audio session (setCategory(.playback)+setActive(true)) synchronously on the main actor (@MainActorclass), which Apple warns can hang the UI becausesetActiveblocks on the audio server. ExtractedconfigureAudioSession()that runs the category/activate calls in aTask.detached(.userInitiated)off the main thread; both inits now call it..playbackneedn't be active beforeAVPlayerstarts, so deferring activation is safe — background playback / lock-screen transport behavior unchanged. File:Features/Review/PlayerView.swift.
Added
iOS: doc-35 phase 5 — browse view-models go live (2026-06-20, Xcode agent;
BuildProjectgreen).ArtistsHomeViewModel,ArtistDetailViewModel, andProjectTracksViewModelnow subscribe to their respective Socket.IO rooms so open lists update in real-time without a manual pull-to-refresh.SocketIOManagerextended: artist room (join:artist/leave:artist), project room (join:project/leave:project), user-room observer (observeUser/stopObservingUser). Three new event enums —SocketArtistEvent,SocketProjectEvent,SocketUserEvent. On CONNECT ack, all joined rooms are re-joined (matching the existing track-room behaviour). 500ms dedup window (recentEventKeys) drops duplicate deliveries that arise because the backend emitsproject:*to bothartist:+project:rooms andartist:*to bothartist:+user:rooms.ArtistsHomeViewModel—setupSocket()registers auserHandler(theuser:room is auto-joined server-side on authentication);artist:updated→ full reload;artist:deleted→ in-place remove + empty-state flip; own-echo suppressed viaactorId.teardownSocket()on view disappear (called when navigating forward), so child VMs get theonReconnectslot.ArtistDetailViewModel—setupSocket()joinsartist:{id}and registers anartistHandler;project:created/updated+artist:updated→ full reload;project:deleted→ in-place remove;artist:deleted→ setsdidDelete = true→ triggers dismiss. Own-echo suppressed.ProjectTracksViewModel—setupSocket()joinsproject:{id}and registers aprojectHandler;track:created/updated→ full reload;track:deleted→ in-place remove. Own-echo suppressed.- View lifecycle: each view's
.taskcallssetupSocket()after initialload();.onDisappearcallsteardownSocket().onReconnecttriggers a freshload()on reconnect for each active VM. - Files:
Core/SocketIOManager.swift·Features/Browse/ArtistsHomeView.swift·Features/Browse/ArtistDetailView.swift·Features/Browse/ProjectTracksView.swift.
Backend: doc-35 phase 4a — real-time emit on hierarchy CRUD (2026-06-19, Claude Code;
nest buildclean,jest14/14 green, 0 lint errors, boot-verified — gateway init + all artist/project/track routes mapped, no DI errors). Extends the realtime layer beyond phase-2 (track comments + review/release) to the browse hierarchy, so open lists can go live: track CRUD → the parent project room, project CRUD → the artist (+ project) room, artist update/delete (incl. roster) → the artist (+ owner user) room.- Events:
track:created/updated/deleted(project:room) ·project:created/updated/deleted(artist:+project:rooms) ·artist:updated/deleted(artist:+ owneruser:rooms). Thin payloads ({ ids…, actorId }) per the doc-35 convention; clients suppress their own echo viaactorId. - Emitted from the controllers, not the services — the controller is the uniform choke point where the actor (
@GetUser) and the operation result both exist (the services have multiple internal create paths and mostly lack the user). Status-change emits stay in the service (phase 2, tied to the notify logic). Each controller got a smallidOf()(populated-doc | bare-ObjectId | string) + a privateemit*Eventhelper; deletes capture the parent id before the soft-delete. Emits are best-effort — wrapped so a socket failure can never break the HTTP response. - DI: the three controllers inject the existing
@GlobalEventsGateway(zero module plumbing, same as phase-2'strack.service).artistSpace.controller.spec.tsgained anEventsGatewaymock provider; suite stays green.softDeleteroutes on track/project/artist gained@GetUser()for theactorId. - Inert until the paired iOS slice: only
PlayerViewModeljoins atrack:room today; the browse view-models (Artists grid, Project tracklist, Artist detail) don't yetjoin:project/join:artistor merge these events. Phase 4a is the backend-contract-first half; the iOS join/merge is queued. Phase 4b (collaborator/team sharing events — per-recipientuser:fan-out) is the remaining backend emit. Files:track/track.controller.ts,project/project.controller.ts,artistSpace/artistSpace.controller.ts(+.spec.ts).
- Events:
iOS: device-aware cover variant selection (doc-34, completes the loop) (2026-06-19, Claude Code;
xcodebuildBUILD SUCCEEDED). The backend has served right-sized cover variants since 2026-06-14 (+ the 300²thumbnail2xtier added earlier today), but the client picked variants by role, not by rendered pixels — so the 140pt artist circle on the Home/Roster grid was fed the 150² thumb (≈420px on @3x → upscaled, blurry: the owner's exact complaint), and the Shared tab fed the full original into the same 140pt tiles (crisp but slow). Now selection is driven bypoints × displayScale.ImageVariantSet(newCore/Models/ImageVariantSet.swift) — aSendablevalue type holding thumb/thumb2x/medium/original URLs withurl(forTargetPoints:scale:): the smallest available tier whose pixel size ≥ target, else the largest. Absent tiers are skipped, so a pre-doc-34 image (original only) always resolves.ArtworkHerogainedvariants:+@Environment(\.displayScale); when variants are set it resolves the right URL from its ownsize(the component knows its frame, per the doc). Backward-compatible —imageURL:callers unaffected.- Models
ArtistSpace/ProjectSummary/ProjectDetaildecode the newimageThumbnail2xand exposeimageVariants. (Track/File covers already render at correctly-sized tiers — 44pt rows use thumb, 180pt heroes use medium — so the 2x field there is a noted, non-urgent follow-up.) - Call sites: Home artist tiles (
ArtistsHomeView.ArtistTile, incl. the long-press preview), Artist detail hero + project grid cards (ArtistDetailView: cards 150²→medium), and all four Shared tiles (SharedViewinbound + outbound: original→variants/medium). Net: a 140pt avatar pulls thumb2x on @2x / medium on @3x instead of upscaling the 150² thumb, and grid cards stop downloading full originals. Files:Core/Models/ImageVariantSet.swift(new),Core/Models/ArtistSpace.swift,Core/Models/Project.swift,DesignSystem/Components/ArtworkHero.swift,Features/Browse/ArtistsHomeView.swift,Features/Browse/ArtistDetailView.swift,Features/Shared/SharedView.swift.
iOS: three owner-raised quick fixes (2026-06-19, Xcode agent;
BuildProjectgreen).- Roster picker in Artist Portal —
ArtistDetailViewnow fetchesGET /artist/rostersin parallel with its existing artist + projects calls and passesexistingRostersintoArtistPortalContent. The existing roster picker (picker section above the create-new fields) now works from the Artist detail page, not only from the Home long-press. Wire typeRosterDTOis file-levelnonisolatedto satisfy Swift 6Sendableconstraints; mapped to the existingRosterOptionafter fetch. - Short share URLs —
CollabLinkgainsshortId: String?;PortalShareSection.shareURL(for:)now emits{webOrigin}/s/{link.shortId ?? link.id}, using the 6-char code when the backend has assigned one and falling back to the full_idfor older links. - Long-press halo square-mask fix —
ArtistsHomeView.artistLinkcontextMenu migrated from the plain.contextMenu { ... }form to.contextMenu(menuItems:preview:). The explicitpreview:renders a cleanArtistTilein a paddedsnBackgroundframe, so iOS snapshots that bounded view instead of clipping the circular halo glow to a square on long-press. Files:ArtistDetailView.swift,SharedItems.swift,PortalSharedSections.swift,ArtistsHomeView.swift.
- Roster picker in Artist Portal —
Backend: 300²
thumbnail2ximage variant for high-density tiles (doc-34) (2026-06-19, Claude Code;nest buildclean,jest14/14 green, 0 new lint errors). Closes the owner-raised 2026-06-19 doc-34 gap: Roster/artist tiles looked low-res on @3x / iPad because the only sub-hero variant was the 150² thumb, which upscales on a 60pt avatar (≈180px drawn) while the next tier up — the 600pxmedium— wastes payload. Added a third cover-cropped WebP variant 300² across the whole pipeline, so the client ladder becomes thumb 150 → thumb2x 300 → medium 600 → original.- Pipeline:
image.service.tsemitsthumbnail2x(300² cover-crop, EXIF-rotated like the 150²);blob-storage.service.tsuploads<key>-thumb2xand returnsthumbnail2xUrl;storage.service.tspropagates it. - Persisted + auto-signed:
File.thumbnail2xUrl?andArtistSpace/Project.imageThumbnail2x?(additive, back-compat; absent on pre-existing images). Both names sign for free viasignUrlsInObject(thumbnail2xurlcontains "url";imagethumbnail2xcontains "image") — no interceptor change. - Wired into every cover path: track create +
createNewFile(new cover version), artist create/ update, project create/update — each now also persists the 300² URL on theFileand mirrors it onto the entity. - Backfill extended:
scripts/backfill-image-variants.tsnow re-selects images missingthumbnail2xUrl(so early-doc-34 covers that only have thumb/medium get upgraded — variant regen is idempotent) and writes the 300² to File + entity. Still dry-run-gated (APPLY=true). - Spec fix:
artistSpace.controller.spec.tshad twoupdateassertions that were already stale since the 2026-06-14 doc-34 commit (exact-matched{ image }while the controller addsimageThumbnail/imageMedium); updated the mock + both assertions to the real variant shape — suite is green again. - iOS follow-up (the actual user-facing fix): the client variant selection helper —
targetPx = points × UIScreen.scale, pick thumb ≤150 · thumb2x ≤300 · medium ≤600 · original — + durable disk cache. Backend-contract-first per the routing rule; queued in.agent/next-brief.md. Reaches TestFlight backend on the next container redeploy. Files:image/image.service.ts,azure/blob-storage.service.ts,storage/storage.service.ts,file/models/file.schema.ts,file/models/file.dto.ts,artistSpace/models/artistSpace.schema.ts,artistSpace/models/artistSpace.dto.ts,artistSpace/artistSpace.service.ts,artistSpace/artistSpace.controller.ts(+.spec.ts),project/models/project.schema.ts,project/models/project.dto.ts,project/project.service.ts,project/project.controller.ts,track/track.service.ts,scripts/backfill-image-variants.ts.
- Pipeline:
Backend: short share-link codes +
GET /artist/rostersaggregate (2026-06-19, Claude Code;nest buildclean, 0 new lint errors, both verified against real data). Two owner-raised concerns (2026-06-19).- Short share codes (doc-26): the
Linkentity had no short code — every/s/{id}used the 24-char ObjectId. Added a unique+sparseshortId(6-chargenerateShortId(), the same nanoid used by artist/project/track) set on share-link creation (createShareResourceLink+createShareLink, via a collision-retryinggenerateUniqueShortId()).findLinkByIdnow resolves byshortIdOR_id(Types.ObjectId.isValidbranch), andaddUserToLink's guard was relaxed accordingly — so new/s/xr7kq2links resolve and legacy/s/{_id}links keep working. Verified: a 6-char code is not a valid ObjectId and resolves by both paths./s/confirmed = the universal share route (ENTITY_PREFIX.SHARE='s';a/p/tare the per-entity deep-link prefixes) — not artist-specific; kept as-is. Follow-ups: iOS should emitlink.shortIdin the share URL (one-liner); existing links keep their_idURL (re-sharing reuses them) — a backfill / lazy-assign is the optional next step. GET /artist/rosters: distinct{name, color}rosters across the caller's artists (owned · collaborator · shared), deduped + sorted (ArtistSpaceService.getDistinctRosters). Declared before:idso the static path wins. Unblocks the iOS Artist-Portal roster picker (it can fetch existing rosters in the single-artist context where the full list isn't loaded). Verified: returns the user's 3 rosters, route not shadowed. Files:link/models/link.schema.ts,link.service.ts,collaboration.service.ts,artistSpace.controller.ts,artistSpace.service.ts.
- Short share codes (doc-26): the
iOS: Halo transition from parent's identity color (2026-06-19, Xcode agent;
BuildProjectgreen). Fixes owner-raised doc-22 §6 continuity gap: when a Track has its own cover but its Project does not, the player halo previously crossfaded from the track's own identity default (a different color than the project the user just came from). Now the initial halo is seeded from the project's identity color (Theme.accent(forKey: projectId)) so the transition reads as continuous browsing context.TrackRoutegainedprojectId: String?(nil for Recents / Shared entry points with no single project context; backward-compatible via default nil).ProjectTracksViewpassesviewModel.projectIdin both NavigationLink and create-track callbackTrackRouteconstructions.PlayerViewModel.init(trackId:)acceptsprojectId: String?; seedsartworkAccentfromTheme.accent(forKey: pid)whenprojectThumbImageURLis nil (no project cover cached), so the track cover animates FROM the project's ambient color rather than the system default. Files:ArtistsHomeView.swift,ProjectTracksView.swift,PlayerView.swift.
Backend: real-time emit on track comments + review/release status (2026-06-19, Claude Code;
nest buildclean, 0 new lint errors, boot-verified — gateway inits + DI resolves, no cycles). doc-35 phase 2, after thejoin:*access gate. Domain services now broadcast typed events to the (access-gated) Socket.IO rooms so live sessions update without a manual refresh — the owner's "two users on the same version, comments appear at publish" scenario.CommentServiceinjectsEventsGatewayand emits totrack:${trackId}:comment:created({trackId, actorId, comment}— full populated payload, safe since the room is access-gated → instant insert, no refetch),comment:reply({trackId, commentId, response}),comment:updated({trackId, commentId}— thin; covers resolve/edit/like),comment:deleted({trackId, commentId}).TrackServiceinjectsEventsGatewayand emitstrack:releaseStatusChanged({trackId, dimension, status, actorId}) andfile:reviewStatusChanged({trackId, fileId, status, actorId}) to bothtrack:${id}andproject:${projectId}(the project room drives tracklist status badges).actorIdlets clients ignore their own echo.- Emits are post-mutation (after the DB write) and a no-op when the room is empty, so they can't affect the REST result; no module changes needed (
WebsocketModuleis@Global). - Remaining (doc-35 phase 3): the iOS Socket.IO client — connect post-auth,
join/leaveper visible screen, merge events, catch-up fetch on reconnect. Live socket delivery gets verified there (or via a two-account run). Files:comment.service.ts,track.service.ts.
iOS: Socket.IO live-sync client — doc-35 phase 3 (2026-06-19, Xcode agent;
BuildProjectgreen). Comments and status pills now update live without a manual refresh. Backend emit was already live (phase 2); this slice wires the iOS receive side.SocketIOManager(Core/SocketIOManager.swift):@MainActor @Observablesingleton;URLSessionWebSocketTask-based Socket.IO v4 client (EIO=4, WebSocket transport; no third-party SPM dependency). Speaks the Engine.IO framing directly: Engine.IO PING→PONG (2/3), namespace CONNECT ack (40), EVENT (42[name,payload]). Auth: JWT in query string (?token=<jwt>) +Authorization: Bearerheader on the upgrade request. Auto-reconnects after a 3-second back-off; re-joins all rooms on namespace CONNECT ack.onReconnectcallback fires only after the first successful connection so the initialbootstrap()fetch isn't duplicated.PlayerViewModel(PlayerView.swift) — three new methods:setupSocket()(called frombootstrap(), idempotent):connect()→joinTrack()→ registers a per-trackId handler → wiresonReconnecttoloadCommentsAndSections().teardownSocket()(called fromPlayerView.onDisappear):leaveTrack()→ deregisters handler → clearsonReconnect.handleSocketEvent(_:): applies eachSocketTrackEventto in-memory state —comment:created(appends tocommentsiffileIdmatches current file, skips own echo),comment:deleted(removes by id),comment:reply/comment:updated(refetch — thin events),track:releaseStatusChanged(merges intodetail.releaseStatusin place, skips own echo),file:reviewStatusChanged(refetches detail iffileId== current file, skips own echo). Files:Core/SocketIOManager.swift(new),Features/Review/PlayerView.swift.
iOS: Roster existing-picker in
RosterEditorSheet(2026-06-19, Xcode agent;BuildProjectgreen). Fixes owner-reported gap: users can now assign an artist to an already-existing roster without retyping the exact name and color. When the home view has ≥1 roster, the sheet opens with an "EXISTING ROSTERS" list above the create-new fields; tapping a row immediately PUTs the artist into that roster and dismisses. The artist's current roster is marked with a checkmark. An "or create new" divider separates the picker from the name/color fields; Remove still clears the roster. No new endpoint — roster options are derived fromArtistsHomeViewModel.distinctRosters(already-loaded artists).RosterOptionstruct added (ArtistsHomeView.swift) — carriesname + colorfor the picker.ArtistsHomeViewModel.distinctRosterscomputed property — deduplicates + sorts by name.RosterEditorSheet— newexistingRosters: [RosterOption]param,existingRostersSectionview,orDivider,quickAssign()function,assigningRosterNamestate for per-row loading indicator.ArtistPortalContent— forwardedexistingRostersparam so the portal's roster editor also shows the picker when the caller provides the list. Files:ArtistsHomeView.swift,ArtistPortalContent.swift.
iOS: #Tags lens live (2026-06-18, Xcode agent;
BuildProjectgreen). Replaces the Wave-3#Tags · soonstub with a working lens.GET /search/tagsaggregate populates the chip; selecting a tag filters Artists (Home), Project tracklist, and Shared (With me) client-side bynode.tags. Combinable with Role / Status / Roster facets; chip stays hidden when the aggregate is empty (solo/empty invariant).SavedViewnow persiststagFilter(Codable, nil-safe for existing saves).- Models:
tags: [String]?added permissively toArtistSpace,ProjectSummary,ProjectDetail,Track— absent on older payloads decodes tonil; server strips private tags before delivery. - LensBar:
LensBarConfig.tagOptions: [String](replacesshowTagsStub: Bool);LensSelectionSavedViewcarrytagFilter: String?;hasAnyOptionsupdated.
- ViewModels:
availableTagsfetched concurrently alongside the main load inArtistsHomeViewModel,SharedViewModel, andProjectTracksViewModel; tag-filter applied in eachfiltered*/artists(applyingLens:)function. Tags fetch failure is non-fatal (bar stays hidden). Files:LensBar.swift,ArtistSpace.swift,Project.swift,Track.swift,ArtistsHomeView.swift,SharedView.swift,ProjectTracksView.swift.
- Models:
Backend: #Tags store (node tags + opt-in sharing + lens aggregate) (2026-06-18, Claude Code;
nest buildclean, 0 new lint warnings, boot-verified —/search/tagsmapped, DI resolved). Builds the backend the doc-26 Wave-3 #Tags lens was stubbed against. Honors the locked decision (owner 2026-06-14, doc-33 §Decisions): tags are per-resource, owner-authored, private by default, propagating to a recipient only via an opt-inshareTagsflag. Distinct from the recipient-sidemy-tag.- Schema:
tags: string[](default[]) +shareTags: boolean(defaultfalse) onArtistSpace,Project,Track. - Write (reuse existing PUT):
tags?/shareTags?added toArtistSpaceDTO/ProjectDTO/TrackDTO;PUT /artist|project|track/:idpersist them unchanged. Eachservice.updateruns a newnormalizeTags()util (src/shared/utils/normalize-tags.util.ts) — trim · lower-case · de-dupe · drop empties · cap ≤25 tags / ≤40 chars. - Privacy (read): new global
StripPrivateTagsInterceptor(src/shared/interceptors/strip-private-tags.interceptor.ts), registered beforeSignUrlsInterceptorso it runs last on the response (reverse order) over plain objects. It removestagsfrom any node the caller doesn't own unlessshareTags === true. No-op for owners / solo users; only affects cross-account reads. - Lens aggregate:
GET /search/tags→ distinct tags the caller can see (owned always + shared whereshareTags === true), enforced at the query level inSearchService.tags()(3 models injected intoSearchModule). Returns a sortedstring[]. - Out of scope (flagged):
Comment.tags[](doc-33 notes — Portals/notes phase); per-recipient tag sharing (v1 is resource-level all-or-nothing); strict owner-only tag editing (v1 = editor+ via PUT). - Lights up the disabled #Tags lens stub once the iOS slice wires to
GET /search/tags+node.tags. Mark doc-26 §Layer 2 #Tags + doc-33 §Backend map: node tags store shipped. Files:{artistSpace,project,track}/models/*.schema.ts+*.dto.ts,{artistSpace,project}.service.ts,track.service.ts,search.{service,controller,module}.ts,shared/utils/normalize-tags.util.ts,shared/interceptors/strip-private-tags.interceptor.ts,app.module.ts.
- Schema:
iOS: Graceful network error handling (2026-06-18, Xcode agent;
BuildProjectgreen). Eliminates the infinite spinner. Every load surface now resolves to a visible state — loaded data, a friendly empty state, or a calm error with a Retry button. No backend change.- Root cause fixed in
APIClient: removedwaitsForConnectivity = true(blocked indefinitely on unreachable host); addedtimeoutIntervalForRequest = 20so every call times out in ≤20 s. APIError.notConnected(new case):URLErrorcodes.notConnectedToInternet,.cannotConnectToHost,.timedOut,.networkConnectionLost,.cannotFindHost,.dnsLookupFailedmap to.notConnectedso the UI can distinguish "can't reach server" from "server returned an error".APIError.noTokennow maps to.unauthorizedinsend()so missing-token failures route to sign-in rather than a generic error.LoadState<T>(new —Core/LoadState.swift):.idle | .loading | .loaded(T) | .empty | .failed(APIError). Every fetch surface ends in one of these — never an endless spinner.SnErrorView+SnEmptyView(new —DesignSystem/Components/ErrorStateView.swift):SnErrorViewshows icon + calm title/message + Retry button; distinguishes "Can't reach Sonnance" (wifi.slash icon, connection copy) from "Something went wrong" (server/decoding copy).SnEmptyViewshows icon + friendly copy with no action.- Applied to all browse + shared surfaces:
ArtistsHomeViewModel,RecentsViewModel,SharedViewModel(both "With me" + "By me" segments),TeamsViewModel— all migrated fromisLoading: Bool+errorMessage: String?toloadState: LoadState<Void>. Views switch on.loadStateand renderSnErrorView/SnEmptyViewas appropriate. - DoD met: server unreachable → error state with Retry within the timeout; Retry recovers once reachable; empty states render for no-data; 401-with-failed-refresh routes to sign-in.
- Root cause fixed in
iOS doc-26 Wave 4: Teams UI (2026-06-18, Xcode agent;
BuildProjectgreen). Surfaces the backend Wave 4 team endpoints in native SwiftUI. Invariant held: lenses filter, roles gate — a team share only ever fans out to standard per-resource collaborator roles; no read-path changes.TeamModels.swift(Core/Models/):Team,TeamMember,TeamShare+ request types;TeamMember.userIduses the established dual-shape decode (plain string or populated User object).TeamsView.swift+TeamDetailView.swift(Features/Teams/): Teams list embedded as a new "Teams" segment in the Shared tab. Pending invites shown separately with inline accept/decline buttons; active teams (owned + accepted member) tap through toTeamDetailView.TeamDetailView(sheet): members list with pending/accepted status, invite-by-email (owner/admin), leave team, disband (owner only), rename (owner/admin), view + revoke shares. All 13 backend endpoints wired.PortalTeamShareSection+TeamSharePickerSheet(added toPortalSharedSections.swift): reusable portal section showing which of the user's teams have access to a given resource, with role badge + revoke (owner/admin); "Share to a team…" opensTeamSharePickerSheet(team picker + role selector). Section is self-loading (parallelGET /team/:id/sharesper team) and hides when empty for non-admins. Added to Artist, Project, and Track Portals.PlayerViewgains@Environment(AuthManager.self)to forwardcurrentUserIdintoTrackPortalContent, enabling the track-level team share section for the signed-in user.- Scoped out (per brief): break-inheritance, file/version-level team share, provenance-precise revoke, full "shared via Team Y" badge. Multi-account verification (invite→accept→share→member-sees) needs a two-account test run — flagged as the known QA gap.
Backend doc-26 Wave 4: Teams / Groups as share targets (2026-06-17, Claude Code;
nest buildclean,lint0 errors, boot-verified — all 13/teamroutes mapped). The B2B share primitive: a reusable Team (a named set of people) so an owner can share an artist/project/track to a whole group at once, across ownership boundaries. Invariant held: lenses filter, roles gate — a team is convenience over the existing collaborator gate; a share only ever lands as standard per-resource collaborator roles, and only the sharer's owned resources can be shared in.- New
teammodule (src/modules/team/):Teamschema (name,image,owner, embeddedmembers[]={ userId, teamRole: admin|member, status: pending|accepted, invitedBy }; creator seeded as an accepted admin) +TeamShareledger ({ team, resourceType, resourceId, role, sharedBy }, unique per (team, resource)) that powers reconcile-on-join and un-share. - Grant model = fan-out + reconcile on join (owner-decided). Sharing fans the grant out to every accepted member via the existing collaborator cascade; a late joiner is back-granted every
TeamShareon accept; leaving / removal / un-share revokes. No permission/read path changed. - Governance = owner + admins (owner-decided): owner can disband; owner+admins invite/remove and un-share; any accepted member can share their own resources in. Membership is by consent — invitees are
pendinguntil theyaccept(new pattern — direct collaborator invites have no accept). - 13 endpoints (
/team): CRUD (POST/GET/GET :id/PUT :id/DELETE :id), membership (POST :id/invite·:id/accept·:id/decline·DELETE :id/member/:userId), sharing (POST :id/share·GET :id/shares·DELETE :id/share/:resourceType/:resourceId). - Reused, not rebuilt: added id-based
grantCollaboratorCascade/revokeCollaboratorCascade(+getResourceMetaownership lookup) toCollaborationService— the grant mirrors the existing invite$addToSetcascade but is idempotent and never-downgrades (push-if-absent + upgrade-if- strictly-lower); revoke is$pull-based, non-throwing, notification-free.CollaborationServiceis now exported fromCollaborationModule;TeamModuleimports it (no cycle). Invites reuseuserService.createTemporaryUser; notifications addINVITATION_TO_TEAM/SHARED_TO_TEAM/REMOVED_FROM_TEAM; email adds anEmailContext.TEAMbranch (best-effort, falls back to the space template until aTEAM_INVITATION_TEMPLATE_IDis provisioned). - Scoped out (flagged, not built): SharePoint-style break-inheritance (exclude-a-child) — an orthogonal, platform-wide permissions feature; granularity for now = the level you share at. Also out: fans/consumer listening (B2B-first, doc-31), File/version-level team share, iOS UI (next slice), "shared via Team Y" badge rendering, and provenance-precise revoke (v1 revoke removes the member from each
TeamShareresource; if they also had independent direct access it's removed too — agrantedViamarker is the noted follow-up). - Mark doc-26 §Wave 4 shipped (backend). Files:
team/{team.controller,team.service,team.module}.ts,team/models/{team.schema,teamShare.schema, team.dto}.ts,collaboration.{service,module}.ts,notification.dto.ts,email/{email.service, models/email.types}.ts,app.module.ts. Reaches TestFlight backend on the next container redeploy.
- New
iOS doc-26 Wave 3: lenses & saved views (2026-06-17, Xcode agent;
BuildProjectgreen). "One graph, many lenses" backbone — a calm, collapsibleLensBaron the three browse surfaces (Home/Artists, Project tracklist, Shared). All filters are client-side over already-loaded data; no backend change. Role invariant preserved: lenses filter, roles gate — no lens surfaces a resource the user's role wouldn't already grant.LensBarcomponent (DesignSystem/Components/LensBar.swift): horizontal chip row withMenu-driven facet pickers (Role, Status, Roster, #Tags stub). Active chips fill withsnAccent; inactive chips are outlined. Trailing "Views" bookmark chip opensSavedViewsSheet.LensManager(singleton,@Observable): persists saved views toUserDefaults; seeded with two defaults ("My Work" / "Awaiting Approval"). Create, rename via save-alert, delete via swipe.SavedViewsSheet: lists savedSavedViewitems (name + SF Symbol + color), load any into the active lens, save the current active lens combination as a new named view.LensSelection+LensBarConfig: role / status / roster facets;hasAnyOptionsdrives the solo-user invariant — bar hidden when nothing meaningful to filter (solo user, one artist, uniform status).ArtistsHomeView: role lens (Mine / With me) + roster lens; both re-group the roster-sectioned grid. Bar hidden when only one artist or all share the same ownership.ProjectTracksView: status lens over the track list; shown only when 2+ distinct effective statuses exist. Animated list update (.easeInOut 200ms).SharedView(With me segment): role + roster lenses;#Tagsrendered as a disabled "soon" stub (awaits backend tags store). Lens resets on segment switch.- Fixed: status filter chips showed action labels ("Approve" / "Request Changes") instead of descriptive labels. Added
TrackReleaseStatus.filterLabel("Approved" / "Not Approved") used byProjectTracksViewModel.lensConfig;labelpreserved for the approve bar. - Motion principle: filtered content fades with
.easeInOut(0.2s)onlensSelectionchange (discrete state, doc 22 §4). Chip active/inactive transitions use.easeInOut(0.15s). - #Tags lens deferred (backend tags store not built; stub chip visible-but-disabled on Shared).
- Mark doc-26 §Build plan Wave 3 ✅. Files:
LensBar.swift,ReleaseStatus.swift,ArtistsHomeView.swift,ProjectTracksView.swift,SharedView.swift. ⚠️ iOS changes uncommitted.
Fixed
Security: WebSocket room joins are now access-gated (2026-06-19, Claude Code;
nest buildclean, boot-verified — gateway inits + DI resolves; decision logic verified against real data). The first step of doc-35 real-time sync: theEventsGatewayjoin:track/join:project/join:artisthandlers previously calledclient.join(...)with no permission check, so any authenticated socket could join any resource's room. Latent today (nothing emits to rooms yet) but a data-leak side channel the moment emit wiring lands — so gated first. AddedEventsGateway.canAccess(userId, type, id)mirroring the REST collaboration gate (owner · collaborator — denormalized down the hierarchy by the invite cascade ·sharedWithlink recipient); eachjoin:*nowawaits it and returns{ success: false, error: 'forbidden' }(no join) when denied.WebsocketModulegainsMongooseModule.forFeature([Track, Project, ArtistSpace])for the lookup. Verified on real data: owner → allowed, stranger / bad-id / no-user → denied. Emit-on-mutation + iOS client subscribe/merge remain the next doc-35 phases. Files:websocket/events.gateway.ts,websocket/websocket.module.ts.iOS DEBUG could not reach the API on a physical device (2026-06-18, Claude Code;
xcodebuildBUILD SUCCEEDED). Symptom: app stuck on an infinite loading spinner; Xcode console showedSocket SO_ERROR [61: Connection refused]to127.0.0.1:8080/::1:8080. Root cause:APIConfig.baseURLusedhttp://localhost:8080in DEBUG — fine on the Simulator (localhost = the Mac, where the local server runs) but unreachable from a physical device (localhost = the device itself). The deployed API (https://api.sonnance.ff8.dev) was healthy throughout (/health200). Fix (two parts): (a) zero-config device default —APIConfignow resolves DEBUG via#if DEBUG && targetEnvironment(simulator): Simulator-DEBUG →localhost:8080(local dev), device-DEBUG → deployed API (a device can't reach the Mac's localhost), RELEASE → deployed. So on-device debug builds work without any scheme setup. (b) a runtime env override on top —SONNANCE_API_BASE/SONNANCE_WEB_ORIGIN(Xcode scheme → Run → Environment Variables) win when set, for pointing a device at a LAN/local server. (An env-var-only first cut was insufficient — unset scheme vars fell back to localhost — hence the compile-time device default.) Also cleared thenonisolated(unsafe)"unnecessary for Sendable type" warnings inAPIConfig+KeychainStoreby switching to the safenonisolated(plain removal would have re-isolated the constants to MainActor under Xcode 26 default-isolation and broken the actor accessors).xcodebuildBUILD SUCCEEDED, 0 warnings in both files. Files:ctrl-audio-ios/.../Core/APIConfig.swift,.../Core/KeychainStore.swift. Action: rebuild & run on the device — no env var needed now.- Note: this resolves connectivity config only; the app still lacks graceful error/timeout/retry UI, so any future unreachable-server case still shows an infinite spinner. Surfacing that (loading / error / empty states + request timeout + "can't reach server" message) is a recommended follow-up iOS slice (owner's original ask) — not yet built.
iOS track covers in Recents + Shared track rows (2026-06-14, Xcode agent;
BuildProjectgreen).RecentsViewandSharedViewbuiltTrackRowwithout passingimageURL, so track covers were never shown in those lists even though theTrackmodel already carriesimage: [MediaFile]+coverThumbURL(landed with doc-34 variants). Fix: threadtrack.coverThumbURLinto everyTrackRowcall in both views —RecentsView.row(for:),SharedView.sharedTrackRow(_:)(inbound), andSharedView.byMeTrackRow(_:)(outbound). Cascade is intact: track cover → gradient fallback whencoverThumbURLis nil. Files:RecentsView.swift,SharedView.swift. ⚠️ iOS changes uncommitted.iOS image-picker controls on top + project cover refresh (2026-06-14, Claude Code — implemented directly +
xcodebuildverified, exit 0). Two owner reports. (1) Picker confirm controls hidden behind a large image.ImagePickerButton.confirmSheetwas aScrollViewwith the preview (.fit, unbounded height) above text buttons → a tall image pushed "Use this image" / "Choose again" off-screen. Reworked into aVStackwith a fixed top icon bar —arrow.uturn.backward(choose again, left) +checkmark.circle.fill(use, right), always visible — and the preview now fills the remaining space (maxHeight: .infinity+.fit) so it can never overflow the controls. Simpler (icon-only) and on-brand. (2) Project cover didn't update after a change until re-navigating. TheProjectTracksViewhero binds toprojectImageURL(@State, seeded from the route atinit); the Portal's cover upload calledonRefetch → viewModel.load(), butload()only refreshes tracks —projectImageURLwas never updated. Fix:onRefetchnow also callsfetchEditableProject()and updatesprojectImageURL/projectThumbImageURLfrom the fresh project. The blob path is stable but the re-signed SAS URL differs each fetch, soAsyncImagere-downloads the new bytes and.task(id: projectImageURL)re-samples the halo — hero + glow update in place. Files:ImagePickerButton.swift,ProjectTracksView.swift. ⚠️ iOS changes uncommitted.
Changed
- iOS UI polish: toolbar cleanup + ProjectCard square crop + image-picker full-screen confirm (2026-06-14, Xcode agent;
BuildProjectgreen). Three polish items from doc-24 §Toolbar & image UX. (1) Overflow menus removed — delete lives in the Portal.ArtistDetailViewandProjectTracksVieweach had ⓘ · + ·•••(overflow) toolbars. "Rename" was already the Portal's inline "Edit"; the only unique overflow action was Delete. The•••Menuwas removed from both toolbars → clean two-button layout (ⓘ + +). Delete was added toArtistPortalContentandProjectPortalContentas a destructive button at the bottom of the Portal (owner/admin only, gated byonDelete != nil), mirroring the existing pattern inTrackPortalContent.onDelete: () async -> Boolcallback passed from each parent; Portal callsdismiss()on success; parent's existingonChange(of: viewModel.didDelete)pops nav.ProjectPortalContentalso gainsonNameChange: (String) -> Voidso a Portal-side rename updates thelocalProjectNameinProjectTracksViewimmediately (previously only the overflowEditProjectSheetdid this). Removed:showEditArtistSheet,showDeleteConfirm/showDeleteProjectConfirmstates + theEditArtistSheetsheet + bothconfirmationDialogmodifiers from the parent views. (2)ProjectCardnow crops portrait covers to a square. Changed fromZStack { … }.aspectRatio(1,.fit)toRoundedRectangle.aspectRatio(1,.fit).overlay { AsyncImage }.clipShape(…)— the shape owns the 1:1 frame; the image is an overlay that inherits that size andscaledToFillcrops correctly without the image driving card height. Mirrors theArtworkHeropattern. (3) Image-picker confirm sheet presents at.largedetent. Changed.presentationDetents([.medium, .large])→[.large]inImagePickerButton.confirmSheetso "Use this image" / "Choose again" controls are visible without scrolling. Files:ArtistDetailView.swift,ArtistPortalContent.swift,ProjectTracksView.swift,ProjectPortalContent.swift,ProjectCard.swift,ImagePickerButton.swift.
Fixed
iOS halo: artist-page first-frame color (final nav-flash fix) (2026-06-14, Claude Code — implemented directly +
xcodebuildverified, exit 0). The prior nav-flash fix added a synchronouscachedColorread inhaloAccent, but it couldn't help the artist detail page: on the first frameviewModel.artistis stillnil(it loads async), so there's nothumbImageURLto look up →haloAccentfell through to the identityaccentuntil the artist loaded = the flash the owner still saw. Fix (mirrors the workingTrackRoutepattern):ArtistRoutenow carriesimageThumbnail; the navigating tile passesartist.thumbImageURL(home grid + both Shared feeds);ArtistDetailView.initseedsartworkAccentsynchronously fromAdaptiveColor.cachedColor(for:)of that URL — so the cover's vibrant halo paints on the first frame (the tile already sampled+cached it). Files:ArtistsHomeView.swift(ArtistRoute+ tile link + destination),SharedView.swift(2 links + destination),ArtistDetailView.swift(init seed). Back-compat (imageThumbnaildefaults nil; the create-flow push is unchanged). General principle (doc 22 §6): a detail screen's own data is async, so the cover URL must arrive via the route and seed the accent at init — a sync read in the computed property alone isn't enough. ⚠️ iOS changes uncommitted (3 files).iOS halo: kill nav flash + track→album cover cascade (2026-06-14, Xcode agent;
BuildProjectgreen). Three halo/cover bugs — pure iOS, no backend change. (1) Nav flash eliminated inArtistDetailView+ProjectTracksView. Root cause: both views blankedartworkAccent = nilat the start of their.task, then re-resolved asynchronously — even though the color was already inAdaptiveColor.colorCachefrom the previous screen's tile. Fix: removed the nil-blank; added a synchronouscachedColorcheck tohaloAccentso the first frame paints the correct vibrant color without any intermediate identity flash. (2) Player halo now resolves on the first frame. Root cause:PlayerViewModel.refreshArtworkAccentusedcover.blobUrlas the cache key — a SAS-signed URL that rotates per-request and never matches the thumb URL cached byArtistTile/ProjectTracksView. Fix: switched the sampling URL tocover.thumbnailUrl ?? cover.blobUrl(matches the cache key other screens use).PlayerView.haloAccentnow also checks the sync cache for the track thumb and project thumb before falling back to identity. (3) Track→album cover cascade in the player.PlayerViewopened fromProjectTracksViewnow receives the project cover URLs via a newTrackRoutenavigation value (replaces the plainStringpush fromProjectTracksView).PlayerViewModel.init(trackId:projectThumbImageURL:projectImageURL:)seedsartworkAccentsynchronously from the project thumb cache on init.refreshArtworkAccentcascades: track thumb → project thumb → nil.emptyVersionsStateartwork hero cascades:detail.coverMediumURL ?? viewModel.projectImageURL— a cover-less track shows the album art. Models:TrackDetailgainscoverThumbURLandcoverMediumURLcomputed props (mirror ofTrack's existing helpers).TrackRouteadded toArtistsHomeView.swiftalongsideArtistRoute/ProjectRoute;ArtistsHomeViewgainsnavigationDestination(for: TrackRoute.self). DoD:BuildProjectgreen; navigating roster→artist→track shows vibrant halo on the first frame; a track with no cover but a project cover shows album art + album halo; coverless+albumless → identity.iOS halo home-scoping + double-halo (2026-06-14, Xcode agent;
BuildProjectgreen). Two halo bugs on the Artists/Roster home grid — both pure iOS, no backend change. (1) Home screen no longer borrows a cover color.ArtistsHomeViewpreviously usedScreenBackground(accent: Color.snAccent)at full 55% intensity, compositing a strong identity ring over every artist tile's vibrant per-tile glow → visible double halo in different colors. Fixed: home now usesintensity: 0.10— a calm brand wash that doesn't compete with per-tile shadows. ThehaloAccentconditional property (which was already returningColor.snAccent) was removed; the home always shows the subtle wash. (2)ScreenBackgroundgainsintensityparameter.DesignSystem/Surfaces/ScreenBackground.swiftnow acceptsintensity: Double = 0.55(default unchanged — all detail/player screens keep full-strength). The param is forwarded toAdaptiveGradient. Grid screens pass 0.10. (3) Double-halo audit.ArtworkHerogradient fill is clipped inside the shape and fully hidden by the artwork image — no extra visible layer.ArtistDetailView/ProjectTracksView/PlayerVieware clean: theirScreenBackgroundandArtworkHero.shadowuse the same vibrant color, so they read as one coherent halo. The only double was home screen identity + tile vibrant, now resolved. DoD:BuildProjectgreen; home has its own calm identity wash independent of any artist's cover; each tile'sArtworkHero.shadowis the sole per-artist halo; no double-halo anywhere.
Added
- iOS doc-34: image variants + disk cache (2026-06-14, Xcode agent;
BuildProjectgreen). Consumes the backend's right-sized WebP variants on every cover, plus configures a 150 MB disk image cache — the primary fix for slow Home/cover loads. Models:MediaFilegains optionalthumbnailUrl(150×150) andmediumUrl(600px);ArtistSpacegainsimageThumbnail/imageMediumwiththumbImageURLandmediumImageURLcomputed URL helpers (fallback chain: variant → original → nil);ProjectSummaryandProjectDetailgain the same fields + helpers;TrackgainscoverThumbURLandcoverMediumURLvia a shared privatecoverFileresolver. Size selection: tiles/rows → thumb (ArtistTilein home grid;TrackRowin project tracklist;ProjectCardin artist detail); heroes → medium (ArtistDetailView180px circle; project hero inProjectTracksView). Fallback to original when variant is absent (pre-doc-34 images).ProjectRoutecarries bothimage(medium, for hero) andimageThumbnail(thumb, for card + track-row fallback). Halo color sampling switched to thumb URL inArtistDetailViewandProjectTracksView— faster download, same 20×20 sample quality; a cache hit from the home-tile pass makes the color synchronous on navigation (no halo flash). SynchronouscachedColorcheck added before asyncvibrantcall in both screens. Disk cache:Core/ImageCache.swiftconfiguresURLCache.sharedto 20 MB memory + 150 MB disk at app launch (SonnanceApp.init), makingAsyncImageandAdaptiveColor.vibrant'sURLSessionreads durable across sessions.
Added
- iOS doc-26 Wave 3: lenses & saved views (2026-06-14, Xcode agent;
BuildProjectgreen). Closes the doc-26 "one graph, many lenses" navigation backbone (§Layer 2 + §Build plan Wave 3). All client-side — no backend change. Invariant maintained: lenses filter; roles gate. (1)LensBardesign-system component (DesignSystem/Components/LensBar.swift): horizontal chip row withMenu-based facet pickers. Active chips filledsnAccent; inactive outlined. Appears only whenLensBarConfig.hasAnyOptions— the solo-user invariant: bar is invisible for solo users with one artist, no rosters, and no shares. Three facets: Role, Status, Roster.#Tagsrendered as a disabled stub (pending backend tags store, deferred per brief). (2)LensManager(@Observablesingleton): persists named saved-view combinations toUserDefaultsas JSON (sn.savedViews.v1). Seeded with two defaults — "My Work" (role=owned) and "Awaiting Approval" (status=Under review).SavedViewsSheetlists all saved views with swipe-to-delete and a "Save current view…" action that fires an alert for the name. (3) Applied to three surfaces:ArtistsHomeView— Role (owned/shared) + Roster lenses, bar shown only when user has BOTH owned AND shared artists; filtering applies to flat and roster-grouped layouts.ProjectTracksView— Status lens (derived viaTrack.effectiveStatus), shown only when tracks carry 2+ distinct statuses;Trackmodel gains optionalreleaseStatus: ReleaseStatus?effectiveStatus: TrackReleaseStatuscomputed property (not approved > under review > approved
no status).
SharedView— Role + Roster lenses on the "With me" segment only; lens resets on segment switch; filtering applies to all three inbound sections (artists/projects/tracks). Saved-views sheet accessible from all three surfaces via thebookmarkchip.
Fixed
iOS halo home-scoping + double-halo fix (2026-06-14, Xcode agent;
BuildProjectgreen). Two bugs re-opened by owner. (1) Home halo scoping:ArtistsHomeViewno longer borrows itsScreenBackgroundhalo from the first artist's cover. The home screen now has its own fixed brand-identity halo (Color.snAccent) that appears once artists load — neutral, brand-appropriate, and independent of any content. The per-artist glow moves down to the tile:ArtistTilegains@State private var tileAccent: Color?resolved via.task(id: imageURL)(cache hit → synchronous; miss →AdaptiveColor.vibrant(for:)), passed astinttoArtworkHero. Result: each tile glows in its own cover's vibrant color; tiles without a cover fall back to the identityaccentshadow. Home animation keyed toviewModel.artists.isEmpty(no longer URL). (2) Double halo eliminated: in all detail screens (ArtistDetailView,ProjectTracksView,PlayerView) theArtworkHero.tintwas always the identityaccent, creating a second identity-colored shadow alongside the vibrantScreenBackgroundgradient. Fix: passartworkAccent ?? accent(orviewModel.artworkAccent ?? identityAccentfor the player) astint— the artwork shadow now matches the screen halo color exactly. When a cover is loaded the shadow is vibrant (one unified halo layer); when there is no cover it is identity (also one layer). Never both.iOS halo polish — Artists/Roster home + kill load-time flash (2026-06-13, Xcode agent;
BuildProjectgreen). (1)ArtistsHomeViewvibrant halo: the Home/Roster grid now has aScreenBackground(accent:)halo keyed to the first artist that has a cover (haloAnchorURL).artworkAccent: Color?is computed viaAdaptiveColor.vibrant(for:)in a.task(id: haloAnchorURL)— fires on initial load and on cover change. Identity accent when no artist has a cover;nil(no halo) while loading. All four browse levels (home / artist / project / track) now carry the halo. (2) No load-time flash on any halo host:AdaptiveColorgains astatic var colorCache: [URL: Color]—vibrant(for: URL) asyncchecks it first (cache hit → synchronous return, no settle);cachedColor(for:)is the synchronous lookup for VM callers. All halo views (ArtistDetailView,ProjectTracksView,ArtistsHomeView) now usevibrant(for:)instead of inliningURLSession + vibrant(from:).PlayerViewModel.refreshArtworkAccentadds a synchronous cache check so re-visits are instant. (3) Animation keyed to cover ID, not color:.animation(value:)on all four screens is now keyed to the cover identifier (nonEmptyImageURL string /projectImageURL/haloAnchorURL/coverFile?.id) — the crossfade fires only when the cover changes, never on the async resolve of the same cover.haloAccent: Color?returnsnil(not identity) while a real cover's color is in-flight — identity accent is now exclusively the no-cover state.iOS cover/halo QA re-open — per-track cover + vibrant halo on all heroes (2026-06-13, Xcode agent;
BuildProjectgreen). Two findings re-opened by the owner after the prior QA slice. (1) Per-track cover in the tracklist:Trackmodel (Core/Models/Track.swift) now decodesimage: [MediaFile]andimageFinal: String?via a permissiveinit(from:)(empty-guards likeTrackDetail), and exposes acoverURL: URL?computed property (prefersimageFinal→isFinalVersion→ last entry).ProjectTracksViewpassestrack.coverURL ?? projectImageURLto eachTrackRow— each row now shows its own cover; falls back to the project art when the track has none, then to the identity gradient. (2) Vibrant halo on artist and project heroes:ArtistDetailViewandProjectTracksViewnow deriveartworkAccent: Color?from their hero image viaAdaptiveColor.vibrant(from:)using.task(id:)(fires on initial load and whenever the image URL changes; cancels in-flight on change).haloAccent=artworkAccent ?? accent; passed toScreenBackground(accent:)with an.animation(.easeInOut(duration: 0.8))crossfade — matching the patternPlayerViewalready uses. All four levels (artist / project / track / version) now read the "pulled from the art" halo consistently; cover-less nodes keep the identity-accent gradient.iOS image & identity QA (2026-06-13, Xcode agent;
BuildProjectgreen). Three TestFlight regressions. (1) Photos source regression inImagePickerButton:PhotosPickerembedded as aMenuchild doesn't present (SwiftUI dismisses the menu before the picker binding activates). Fix: Photos is now driven by a@State var showPhotosflag toggled by a plainButtoninside the menu; the picker is attached as a.photosPicker(isPresented:selection:)modifier on the component's bodyGroup. Files path unchanged. (2) Track cover in project tracklist:TrackRowgainsimageURL: URL? = nil(default nil, backward compatible). The thumb is now a ZStack —AsyncImageover the gradient/waveform fallback, clipped withclipShape(Circle()).ProjectTracksViewpassesprojectImageURL(threaded fromProjectRoute.imagein the previous slice) so all rows in a project show the project cover art; gradient fallback for cover-less projects. (3) Intelligent accent halo:AdaptiveColor.vibrant(from:)added toAdaptiveGradient.swift— downsamples the cover to 20×20, scans all pixels in HSB, drops near-grey (saturation < 0.25), near-black (brightness < 0.10), near-white (brightness > 0.92), and picks the pixel with the highestsaturation × brightness(chroma proxy). Returnsnilfor muted art → identity-accent fallback.PlayerViewModel.refreshArtworkAccentnow callsAdaptiveColor.vibrantinstead of the mean-basedaverage; the halo should read as pulled-from-the-art rather than a grey smudge.
Added
- Backend — image-delivery variants (doc 34, the cure for slow covers) (2026-06-14, Claude Code;
tscclean). The Sharp variant machinery (thumb 150² / medium 600px / original, all WebP) existed but was bypassed — cover uploads used plainuploadFileand onlyblobUrlwas persisted, so clients downloaded full originals into 150px tiles. Now every cover upload generates + persists right-sized variants: (1)Fileschema gainsthumbnailUrl?/mediumUrl?; (2)ArtistSpace+ProjectgainimageThumbnail?/imageMedium?(the entityimagestring is what tiles/heroes read). (3) Upload paths switched touploadImageWithVariants+ persist the variant URLs: track image (createTrack), track cover version-replace (image branch only — audio still plain), project image (create + update; wereuploadFile), artist image (already used variants — now the returned URLs are saved instead of dropped). (4) Auto-signed: the globalSignUrlsInterceptorsigns any key containing "url"/"image", so the new fields sign on every read with zero extra wiring. (5) Backfill —scripts/backfill-image-variants.tsrebuilds each image's deterministic key from its refs (createFileLink/…Space/…Project— no URL parsing), re-runsuploadImageWithVariants, updates the File and mirrors space/project covers onto their entity. Dry-run by default; only mutates withAPPLY=true. ✅ APPLIED 2026-06-14 vs the cloud DB: 31 of 33 images now carry variants (File.thumbnailUrl/mediumUrl, entityimageThumbnail/imageMedium; blobUrls re-pointed to cleanimage.webp). 2 left un-fixable: 1 orphan (no blobUrl), 1 with a corrupt source blob (bad seek— truncated original; Sharp can't read) — both fall back to original/gradient on the client. Bug found + fixed mid-apply: existing File docs store a barecontentType("png"/"webp"), butuploadImageWithVariantsneeds a full MIME ("image/png") or it silently falls back to a plain upload (no variants, blob namedimage.undefined); the script now normalizes to a real MIME (toMime) — the earlier bad run'simage.undefinedblobs are orphaned junk (harmless), blobUrls corrected on re-run. Also needstsconfig-paths/register(imported at top). No CDN. ⚠️ Reaches TestFlight on the next container redeploy. iOS follow-up (queued): readthumbnailUrl/imageThumbnailfor tiles/rows,mediumUrl/imageMediumfor heroes, + a disk image cache. Decisions resolved (doc 34): explicit fields · batch backfill (dry-run-gated) · S3 variants deferred (Azure is live). - iOS image-picker polish — confirm + Files source (2026-06-13, Xcode agent;
BuildProjectgreen). NewDesignSystem/Controls/ImagePickerButton.swift— a reusable two-source picker that replaces the barePhotosPickerin all three portal image-upload paths. (1) Sources: aMenupresents "Photos" (PhotosPicker) and "Files" (.fileImporter,allowedContentTypes: [.image]). Both sources load image data, write a temp file, and decode aUIImagepreview; the security-scoped URL from Files is unwrapped during the read. (2) Confirm step: after picking, a glass sheet shows the selected image (aspect-fit,RoundedRectangleclipped), the filename, and two buttons: "Use this image" (commits) + "Choose again" (clears). Temp file is cleaned up by the component on cancel/drag-dismiss or by the upload Task on confirm — no leaks. (3) Progress: while the caller's upload is in flight, an inlineProgressViewreplaces the menu — the progressDouble?is still owned by the portal, unchanged. (4) Wired into:ArtistPortalContent(artist image section),ProjectPortalContent(album art section),TrackPortalContent(cover section). Each portal's oldPhotosPickerItemstate,handleImagePicked/handleCoverPickedfunction, and.onChange(of:)modifier are removed; the upload logic is extracted into a focuseduploadXImage(fileURL:filename:mimeType:)method called fromImagePickerButton.onUpload.imageSubtitle/coverSubtitleupdated to say "Choose from Photos or Files." instead of "photo library".
Envisioned
doc-33 Notes — 3 decisions locked (owner 2026-06-14). (1) explicit
NOTECommentType(not inferred). (2) renameTrack.notes→Track.description— a distinct concept from NOTE-kind notes, so it gets its own name (additive copy migration). (3) tags-scope decided (resolves the doc-26 + doc-33 open question):tags[]are per-resource, owner-authored, private by default, propagating to a recipient only via an opt-in "share tags" check at share time (or with a shared workspace) — not a global vocabulary; distinct from the always-private recipient-sidemy-tag. Decisions 4–6 (note status, pin/elevate, roll-up filter) stay open for build time. Updated:33-NOTES-ANNOTATIONS.md§Decisions + backend map,26-IDENTITY-SHARING-NAVIGATION.mdWave-3 note. Still envision-only; nothing built.Image delivery & performance —
34-IMAGE-DELIVERY.md(2026-06-14, Claude Code; owner-raised). Owner: covers load slowly on the Home grid; wants a low-cost alternative to a CDN. Finding: the cure is already built and unused. Verified the Sharp variant machinery exists (image.service.ts: thumb 150² / medium 600px / original, all WebP;azure/blob-storage.service.ts → uploadImageWithVariantsuploadskey-thumb/key-mediumand returns their URLs) — but the track/project/artist cover uploads call the plainuploadFile(originals only, no variants) andFilepersists onlyblobUrl(variant URLs dropped). So the client downloads a full-res original to fill a 150px tile = the lag; the doc-22 §6 halo, derived from it, resolves late too. Approach (no CDN): right-size at the source + cache at the client — (1) route cover uploads throughuploadImageWithVariants; (2) persistthumbnailUrl?/mediumUrl?onFile(additive;signUrlsInObjectsigns them — and must persist, since per-blob SAS tokens can't be client-suffix-swapped); (3) client picks thumb for tiles/rows, medium for heroes; (4) disk-backed image cache (AsyncImagehas none). CDN (doc 05) stays a later scale lever. Backend-first, near-term; 4 decisions flagged (storage shape — rec explicit fields · backfill existing originals — rec batch job · S3 parity — defer · thumb shape). Queued innext-brief.md(backend slice, awaiting storage-shape green-light). Cross-refs: roadmap doc-map, doc 22 §6, doc 05.Notes as first-class annotations —
33-NOTES-ANNOTATIONS.md(2026-06-13, Claude Code; owner-raised). Owner: the Track Notes field should become real — many notes, each with who wrote it + when, surfaced in other parts of the hierarchy by tag, and maybe a status "if it makes sense." Grounded finding: the rich entity already exists — it'sComment. VerifiedCommentTypealready includesVERSION/PROJECT(non-timeline anchors), andCommentalready hasowner+timestamps(author + date/time) + threadedresponses+isResolved+ file/track/project/section refs. So a Note is "aCommentwithout atimeStart" — the singleTrack.notesstring is the only un-modeled annotation. Doc envisions generalizingCommentinto the universal annotation surface (no new collection — the doc-26/28/32 "don't rebuild" discipline): add aNOTEkind + anartistSpaceanchor +tags[](the cross-hierarchy surfacing the owner asked for = the same doc-26 #Tags lens, surfaced not copied); status is opt-in resolve (not a default field), elevating to a doc-32{status,by,at}approval only for genuine sign-off notes (else the approval model would lie). Notes vs Comments = one entity, two flavors (node-anchored editorial in the Portal vs moment-anchored feedback in the Wave). Envision now, rides the Portals-everywhere phase; cheap early taste = addtags[]toComment(serves notes and the doc-26 tag lens at once). Five decisions flagged. Cross-refs wired: roadmap doc-map, doc 24 §Notes + §Image picker. Also captured the owner's image-picker asks (confirm-selected-picture · pick from Files not only Photos · crop-as-a-later-component, owner-confirmed) into doc 24 +next-brief.mdas the next iOS slice. Extended 2026-06-13 (same doc): added the eagle-level roll-up — comments "disappeared" because the Portal renders no annotation band; the fix (grounded in "Notes are Comments") gives every Portal two bands, Anchored-here + Rolled-up-from-below, filtered (unresolved · tagged · status) so artist/project/roster show a less-but-louder subtree digest while a version keeps today's comments (owner: "enough"). Needs a backend subtree-aggregate read; rides the Portals-everywhere phase. Sixth decision flagged (roll-up default filter). Also refined22-IOS-DESIGN-SYSTEM.md§6 (owner-locked): the artwork halo must be intelligent, not averaged — read the most-vibrant accent (highest-chroma dominant cluster, drop near-grey/black/white), not the mean (which muddies to grey). Three iOS QA items queued innext-brief.md(Photos-picker regression —PhotosPicker-in-Menugotcha · track cover missing in tracklist rows · intelligent halo). Cross-refs wired into doc 24 (§Identity halo, §Comments-in-Portals).Approval as a first-class primitive —
32-APPROVAL-MODEL.md(2026-06-13, Claude Code; owner-raised). Owner connected several TestFlight observations into one: "approval is only in Wave; cover belongs in the Single/Project/CD area; approval should also cover lyrics, tracklist & order, edit/production phases, moreover mastering — ground this in the whole vision, use cases, actors and objectives." New envisioning doc generalizes doc-29 audio-version approval into "approval is a dimension, not a screen": one primitive{ status, by, at }per (node, dimension) pair, scoped to where the thing lives (audio→File, cover→Project/release, lyrics→Track/version, tracklist→Project, master→phase gate), surfaced in Portals (doc 26 Layers 2 & 4) — the Wave keeps only the listen-bound audio verdict. Adds a production-phase ladder (Writing→Recording→Mixing→Mastering→Release, hybrid marker gated by approvals), a Project-level release-gate rollup (feeds the doc-12 distribution validation), per-dimension review history, and an actors×dimensions sign-off matrix (manager/A&R/legal sign-offs = the B2B value, doc 31). Grounds the codebase seed:ReleaseDimension { title, version, cover }already abstracts approvable axes; doc 29 already proved one axis can be re-homed onto its real node. Folds in the cover thread (§7): crop→frame (near-term client crop; iOS-27 generative resize/gen deferred to post-beta), hero-display (iOS wiring gap, not backend), cover-history CRUD (setCoverImage= promote ✅; delete-from-history + browser missing), cover-approval home → Project/Track Portal. Envision now, build after the doc-26 Shared waves; three cheap ride-alongs flagged (cover approval in the existing Project Portal · cover-history delete verb · hero cover display). Six decisions flagged for build-time. Cross-refs wired: roadmap doc-map, doc 29 (Future → generalized here), doc 28 §Cover art, doc 24 §Cover art (backlog items),next-brief.md(QA item).
Fixed
iOS cover display QA — doc-32 §7 ride-along (2026-06-13, Xcode agent;
BuildProjectgreen). Closes the remaining hero-display gaps after slice 9: (1) Project hero (ProjectTracksViewheader) now shows the project cover —ProjectRoutegainsimage: String?(default nil, backward-compatible init);ArtistsHomeViewandSharedViewpassproject.nonEmptyImagein the route;navigationDestinationhandlers in both views threadroute.image → URL → projectImageURLintoProjectTracksView.init, stored as@Stateand passed toArtworkHero.imageURL. (2) Track empty-state hero (PlayerView.emptyVersionsState) now shows the track cover viadetail.coverFile.flatMap { URL(string: $0.blobUrl) }. (3) Shared tab artist tiles (sharedArtistTile,byMeArtistTile) and project tiles (sharedProjectTile,byMeProjectTile) all thread theirnonEmptyImageintoArtworkHero.imageURL/ProjectCard.imageURL. Crop is already correct:scaledToFill()+clipShapewere baked intoArtworkHeroin slice 9. Gradient fallback unchanged for cover-less nodes. No backend changes.iOS browse surfaces now display uploaded artist/project images (2026-06-13, Xcode agent;
BuildProjectgreen). Slice-8 wired the upload path but left the display half out. Fix: (1)ArtworkHerogainsimageURL: URL? = nil— showsAsyncImageover the gradient when set, gradient + placeholder icon as fallback on nil/loading/failure; shape-clipped correctly (Circle for artists, RoundedRectangle for projects). (2)ArtistTile(home grid) andArtistDetailViewhero both threadartist.nonEmptyImage→ URL →ArtworkHero.imageURL. (3)ProjectCardgainsimageURL: URL? = nil— artwork square becomes a ZStack withAsyncImageon top, gradient underneath; clip applied.ArtistDetailViewproject grid passesproject.nonEmptyImage. (4)ProjectPortalContentempty portal fix: initial state wasisLoadingDetail = falseso the body rendered nothing and.task { loadDetail() }never fired; changed totrueso the loading view shows on first render and kicks off the fetch.Backend — uploaded images rotated 90° (EXIF orientation) (2026-06-13, Claude Code;
tscclean). Owner saw a portrait photo come out sideways on one artist but not another — the classic EXIF tell: phone photos store landscape pixels + an orientation flag, and Sharp's.resize()/.webp()drop the flag without applying it, so only images carrying the flag rotate. Fix:image.service.tsnow calls.rotate()(no args = auto-orient from EXIF, baked into pixels) before resize in all three variants (createThumbnail,createMedium,optimizeOriginal). New uploads are correct; ⚠️ reaches TestFlight on the next container redeploy, and already-uploaded sideways images stay rotated (re-upload to fix). Investigated the "empty Project Portal" report too:GET /project/:idserializes fully (refs resolved,_idstringified) and the iOSProjectDetaildecoder is fully tolerant (every field optional/try?), so the backend is healthy — handed to the Xcode agent as a render/self-fetch QA item (seenext-brief.md).
Added
iOS Track Portal share + per-version share — slice 9 (doc-26 Wave-2) (2026-06-13, Xcode agent;
BuildProjectgreen). Closes the Track Portal's dead "Share" button and adds per-version share links. (1)TrackPortalContentdeadGhostButton("Share")replaced by aPortalShareSectionreusing the same component from slice 8; endpoints:GET /collaboration/link/track/:trackId,POST /collaboration/share/track { trackId, role }, PUT role, DELETE revoke — owner/admin only (gate:onSave != nil). (2) New "VERSIONS" section inTrackPortalContentlists each audio file with a per-row share button → setsfileShareTarget→ presents aPortal { PortalShareSection }sheet forGET/POST /collaboration/link/file/:fileId+POST /collaboration/share/file { fileId, role }. (3)CreateTrackShareLinkRequestandCreateFileShareLinkRequestadded toProjectArtistCRUDRequests.swift. Doc-26 §Build plan item 9 ✅.iOS Artist + Project Portals — slice 8 (doc-26 Wave-2) (2026-06-13, Xcode agent;
BuildProjectgreen). Generalises the Track Portal pattern to the entire browse hierarchy. (1)CollaboratorUserDetailadded toArtistSpace.swift;ArtistCollaboratorgains a customCodablethat normalisesuserIdarriving as either a plain string or a populated User object (live since the backend fix shipped today). NewentryIdfield carries the collaborator entry's own_idfor the role-change PUT path. (2)ProjectDetailinProject.swift— full project shape fetched for the portal, with collaborators, owner (polymorphic decode),artistSpace, anddescription.ProjectParentArtistfor the parent label. (3)APIConfig.webOriginconstant (sonnance.ff8.dev/ localhost) for share-link URL construction. (4)APIClient.uploadFilegainsmethod: String = "POST"so artist/project image uploads can usePUT. (5)CollabLink(renamed fromShareLinkto avoid SwiftUI name collision) added toSharedItems.swift— full link document decoded from the portal's share-link endpoints. (6) Collaboration request types added toProjectArtistCRUDRequests.swift:UpdateCollaboratorRoleRequest,InviteToArtistRequest,InviteToProjectRequest,CreateArtistShareLinkRequest,CreateProjectShareLinkRequest,UpdateShareLinkRoleRequest. (7)PortalTeamSection(PortalSharedSections.swift) — collaborator list with per-row avatar (image or initials), name, role; owner/admin gets a Menu per row (change role → PUT; remove → DELETE +confirmationDialog) plus an inline Invite row (email + role picker → POST). (8)PortalShareSection(same file) — fetches existing link on appear; "Generate share link" GhostButton when none; with a link: URL + copy + SwiftUIShareLink, role picker (PUT → refetch), opens/last-access metadata, destructive Revoke (DELETE +confirmationDialog). (9)ArtistPortalContent(Features/Browse/) — header with Edit/Save name, Roster row (taps →RosterEditorSheet; owner/admin only), Projects count, Artist image section (PhotosPicker→ multipart PUT, owner/admin only), Team section, Share section, My-tag row (non-owners). (10)ProjectPortalContent(sibling) — self-fetchingProjectDetailon appear; metadata (name, type, description editable; parent artist read-only); Album art section (this is the canonical cover per doc-28); Team, Share, My-tag. Both portals presented via a newinfo.circletoolbar button inArtistDetailViewandProjectTracksViewrespectively.MyTagChip,TagEditorSheet, andRosterEditorSheetpromoted fromprivatetointernalfor cross-file reuse. Doc-26 §Build plan item 8 ✅.
Changed
- Backend —
GET /artist/:idnow populatescollaborators.userId(2026-06-13, Claude Code;tscclean both repos). Groundwork for Wave-2 slice 8 (Artist/Project Portals need collaborator names + avatars). The artist list (GET /artist) and both project reads populated all along — only the artist single reads (findOneById/findByIdentifier, all three lookup branches) returned bare id strings, so this makes the artist API self-consistent rather than introducing a new shape.ownerintentionally stays a plain id (the controller serializes it with.toString()). Web front:getCollaboratorRoleArtist(src/lib/collaborators/index.tsx) compareduserId === session.user._idand would have silently downgraded everyone to viewer on the populated shape — now accepts both string and populated-objectuserId. Same TestFlight dependency: live after the next container redeploy.
Fixed
- Backend —
rosterwas silently dropped byPUT /artist/:id(2026-06-13, Claude Code;tscclean). Doc-26 Wave-1 item 1 addedroster?: { name, color }to the ArtistSpace schema + DTO and documented it as "accepted via the existing PUT", but the controller's field-by-field update whitelist (artistSpace.controller.tsupdate()) never copiedrosterinto the payload — so setting a roster was a no-op on both the JSON and image-upload branches. Both branches now passrosterthrough (!== undefined, so{ "roster": null }clears it; the service spread persists it as-is). Caught while writing the Wave-2 slice-7 (Roster UI) brief, before any client consumed the broken contract. Same TestFlight dependency as the rest of Wave 1: live after the next container redeploy.
Added
iOS Roster UI — name+color on artists; Artists home groups by roster (2026-06-13, Xcode agent;
BuildProjectgreen). Doc-26 Wave-2 slice 7 — roster as a soft grouping facet onArtistSpace. Solo-user invariant strictly preserved: zero new chrome until a roster actually exists. (1)ArtistSpacemodel extended withArtistRoster { name?, color? },ArtistCollaborator { userId?, role? }, and correspondingroster,owner,collaboratorsfields (all permissive optionals). New helperisOwnerOrAdmin(_ userId:)checks whether the current user is the artist's owner or has"admin"in the collaborators array — used to gate the context menu item. (2)UpdateArtistRosterRequestadded toProjectArtistCRUDRequests.swift:.set(name:color:)encodes{ "roster": { "name": ..., "color": ... } };.clear()encodes{ "roster": null }viaencodeNil(forKey:)(explicit null signals the backend whitelist to clear the field; omitting the key is a no-op). (3)ArtistsHomeViewupdated:ArtistsHomeViewModelgainshasAnyRoster,rosterGroups: [ArtistRosterGroup],updateRoster(artistId:name:color:), andclearRoster(artistId:). The grid switches between flat layout (no rosters anywhere) and sectioned layout (one section per roster name with a small color-dot header + artist count, plus a quiet "Unassigned" section at the end). Each artist card in both layouts gets a long-press context menu; "Roster…" appears only whenartist.isOwnerOrAdmin(currentUser.id)is true. The menu opensRosterEditorSheet— a glass sheet (.thinMaterial, same presentation asTagEditorSheet) with a nameTextField, 8-swatch palette (MyTag.paletteHex/Theme.versionColor), "Save roster" / "Update roster" primary button, and a "Remove from roster" ghost button when a roster already exists. Success → refetch + dismiss. (4) NewRosterCapsuledesign-system component (struct, notprivate) — color dot + name in the palette color, used as the artist tile's subtitle when a roster is set (replacing the plain "Artist" label). (5)SharedView("With me" segment) — inboundsharedArtistTilenow shows aRosterCapsulein place of "Artist" whenentry.space.rosteris present; read-only for recipients (no context menu on this side). Out of scope: roster as a filter lens (Wave 3); Artist Portal (slice 8 — will replace the context menu as the canonical roster-edit surface). Doc 26 §Build plan Wave-2 item 7 ✅. ⚠️ Same TestFlight dependency as Wave 1: live after the next container redeploy.iOS Shared screen v2 — "By me" outbound segment (2026-06-13, Xcode agent;
BuildProjectgreen). Doc-26 Wave-2 slice 6 — the team-lead / B2B half of the Shared tab, riding the liveGET /collaboration/shared-by-meaggregate. (1) Models — extendedSharedItems.swift:SharedByMe { spaces, projects, tracks }; each section's entries flatten the underlying resource alongsideshareSummaryviainit(from:)(same pattern asSharedArtist). NewShareSummary(collaborators / sharedWithCount / links — every field permissive, derivedtotalOpens,linkCount,hasFileVersionLink),SummaryCollaborator(Identifiable soForEachis stable even whenuserId._idis missing),SummaryUser(_id, username, fullName, image, email — all permissive),ShareLinkSummary(id, role, resourceType, timesOpened, lastAccessAt, expiresAt, optionalfilefor per-version share links). (2)SharedViewgains a segmentedPickerat the top of the screen — "With me" / "By me" — using the system.segmentedstyle (consistent with iOS; styling work parked until the rest of the doc-26 IA settles). Default is "With me" (recipient-side is what most users open the tab for). The two feeds are independent: a single.taskfires both inbound + outbound in parallel viaasync letso the segment switch is instant, and pull-to-refresh reloads only the active segment (the other cache stays warm). (3)SharedViewModelgainedbyMe: SharedByMe?+isLoadingByMe+byMeError+loadByMe()(single fetch, 401 → signOut, error surfaced only when the segment is empty). (4) Outbound sections reuse the sameArtworkHero/ProjectCard/TrackRowtiles via dedicatedbyMe…Tile/Rowhelpers, swapping the my-tag chip slot for a newShareSummaryLine: overlapping initials avatars for up to 3 collaborators (Theme.accent(forKey:)per-user, identity-stable;+Npill when more than 3) + caption "N link(s) · M opens" + a smallwaveformcapsule chip whenshareSummary.hasFileVersionLink(a specific version is shared). Initials fall throughfullName → username → email; the chip and caption read as quiet metadata, never a CTA. (5) Navigation reuses the existingArtistRoute/ProjectRoute/String(trackId →PlayerView) destinations from inbound. (6) Empty state for "By me" uses apaperplaneglyph + outbound-specific copy ("Resources you share — by adding collaborators or generating a link — land here with their open counts."). Tag editing context menu stays on the inbound segment only (outbound's focal metadata is the share summary; tags-on-own resources would be noise — parked behind doc-26 lenses/saved views). Out of scope: link management (revoke / expiry editing — Portal work, later slice); roster UI; identity badges outside the Shared tab; lenses. Doc 26 §Build plan Wave-2 item 6 marked ✅. ⚠️ Lands on TestFlight after the next container redeploy (same dependency as Wave-1 backend).iOS Shared screen v2 — inbound feeds + my-tag chips (2026-06-12, Xcode agent;
BuildProjectgreen). Doc-26 Wave-2 slice 5 — the first iOS consumer of the Wave-1 backend contracts. Replaces the 2026-06-07SharedViewthat was pulled while the model was undecided. (1) Models — newCore/Models/SharedItems.swift:SharedArtist(custominit(from:)because/collaborator/spacesflattensrole+myTagonto theArtistSpaceobject),SharedProjectSharedTrack(synthesized Decodable — wire shape is{ project|track, role, myTag }), a permissiveMyTag { label?, color? }withisPresentso the backend's{ label: "", color: "" }empties don't render chips, andUpsertMyTagRequest(manualencode(to:)sonilis omitted rather than null'd).SharedResourceTypeenum mirrors the backendSharedResource("ArtistSpace"/"Project"/"Track"exact strings). (2)SharedView+SharedViewModel: three parallel inbound fetches (per-feed try/catch — a partial outage doesn't blank the tab; aggregated 401 routes throughAuthManager.signOut); sectioned artwork-forward layout — Artists (2-col grid,ArtworkHero(.circle)+ name + role caption), Projects (2-col grid,ProjectCard), Tracks (verticalTrackRows). Identity colors viaTheme.accent(forKey:). Navigation pushes the existingArtistRoute/ProjectRoute/String(trackId →PlayerView) destinations through a per-tabNavigationPath. (3) My-tag chips render as a smallCapsule(tag-color fill at 0.18, text in tag color) — same pill language as the player's V# bubble, intentionally quiet so it reads as metadata. Color-only tags get a•placeholder. Unparseable hex falls back tosnForegroundSecondary. (4)TagEditorSheet(one component reused for all three types): labelTextField+ an 8-swatch palette (Theme.versionColor(for: 0..<8)visuals →MyTag.paletteHexon the wire), Save / Update / Remove buttons. Sheet is opened via a.contextMenu { Button "Tag…" }on every row/tile (long-press); save/remove route throughPUT/DELETE /collaboration/my-tagand reload the feed. (5)RootTabsView: the Shared tab is back — Home / Shared (tray.and.arrow.down) / Recents — this time grounded by the doc-26 model so it stays. Out of scope: outbound "Shared by me" (next slice — endpoint already live); roster UI on Artist surfaces; identity badges on non-Shared screens; lenses/filter bar; Portals-everywhere. Doc 26 §Build plan Wave-2 item 5 marked ✅. ⚠️ Lands on TestFlight after the next container redeploy (doc 27 step 2 — same dependency as the Wave-1 backend).
Doc-26 Wave 1 — backend implemented (2026-06-12, Claude Code;
tscclean + Nest boot-verified, all five routes mapped). (1) Roster tag:roster?: { name, color }on the ArtistSpace schema (typed subdoc,_id:false) +ArtistSpaceDTO.roster— set via the existingPUT /artist/:id, returned by all artist reads. (2) Recipient-side share tags: newShareTagcollection (unique per user × resourceType × resourceId) +PUT /collaboration/my-tag(upsert,UpsertMyTagDto) +DELETE /collaboration/my-tag/:resourceType/:resourceId; the three inbound reads (/collaboration/collaborator/{spaces,projects,tracks}) now returnmyTag: { label, color } | nullper entry (batch-resolved via one$inquery). (3) Outbound aggregate:GET /collaboration/shared-by-me→{ spaces, projects, tracks }, each entry = the owned resource +shareSummary(external collaborators populatedusername fullName image email,sharedWithCount, link list with role / timesOpened / lastAccessAt / expiresAt; File links roll up to their parent track and keep thefileref). Backed by newLinkService.findAllLinksByOwner. (4) Per-version share links:SharedResource.Fileadded;POST /collaboration/share/file(makeFileShareableDto { fileId, role }) validates file→track, raisestrack.sharedLinkRole(null-safegetHighestRole), and creates the link withresourceId = fileIdplustrack+filedeep-link refs (extendedcreateShareResourceLink);GET /collaboration/link/file/:fileIdlists;addUserToLinkgained the File branch — grants access on the parent track and returns{ track, file }so clients land on the exact version. Invariant held: tags/lenses are organizational only; roles gate. ⚠️ Reaches TestFlight on the next container redeploy (doc 27 step 2). Doc 26 §Build plan marked Wave 1 ✅; Wave-2 slice 5 (Shared screen v2 inbound) queued in.agent/next-brief.md.Doc-26 phase kickoff — build plan + Wave-1 backend contracts sketched (2026-06-12, owner: "go doc-26"). Grounded the contracts against the real modules first:
Linkalready has afileref andresourceTypeis a loose string (per-version links are cheap); collaboration has full invite/ collaborator/link CRUD at all three levels; real gaps = the outbound aggregate, the recipient-tags store, and the roster field. §Build plan added to26-IDENTITY-SHARING-NAVIGATION.md: Wave 1 backend (roster{name,color}on ArtistSpace via existing PUT · newshareTagcollection +PUT/DELETE /collaboration/my-tag+myTagmerged into inbound reads ·GET /collaboration/ shared-by-meaggregate ·SharedResource.File+POST /collaboration/share/filegranting the role on the parent track with a version deep-link ref); Wave 2 iOS slices (Shared v2 inbound → outbound → roster UI → Portals-everywhere incl. Project album art → identity badges + share-this-version); Wave 3 lenses/saved views client-first. Invariant: lenses filter, roles gate. Owner also reconfirmed cover-as-release-art ("more from the project rather the track") — noted in doc 28 §Cover art; the hierarchy work lands in this phase. Roadmap doc-26 row → build phase; contracts awaiting owner veto/green-light before Wave-1 code.iOS — artwork-derived adaptive halo (doc 22 §6's personalization signature, finally wired) (2026-06-12, Claude Code, feel work;
xcodebuildgreen). The player'sScreenBackgroundhalo now derives from the cover's average color:PlayerViewModel.artworkAccentdownloads the cover once per file id (not URL — SAS signatures rotate per fetch), samples it with the long-dormantAdaptiveColor.average(from:)(CIAreaAverage), and the halo crossfades at hero pace (0.8s ease) from the track's identity accent to the artwork color — quiet and inevitable per doc 22 §4. Fallback chain preserved: artwork → identity accent (no cover / load failure); recomputes on cover change (upload or set-cover → refetch). PlayerBar fallback gradient + empty-state hero stay identity-colored. Prod note: owner redeployed the container — set-cover + the per-version review endpoint are live on TestFlight; the cover-history rail shipped (Xcode agent) the same day. With this, the cover model is complete except Project/album art, which rides the doc-26 phase. Next: the doc-26 sharing/Rosters/lenses phase kickoff (backend-contract-first, per the routing rule).iOS cover history — "Previous covers" rail in the Portal (2026-06-12, Xcode agent;
BuildProjectgreen). Mechanical slice from.agent/next-brief.md, follow-up to the cover model unblocked by the new backend set-cover verb. (1)PlayerViewModel.setCover(fileId:)—PATCH /track/:trackId/file/:fileId/set-cover(no body),APIEmptyResponse, thenfetchDetail()refreshes the populated detail; mirrors theupdateVersionReviewStatushandshake. NewisSettingCoversetCoverErrorstate surfaces in-flight + failure to the Portal. Inherits the 401-signs-out, http-body-decode error path from the audio uploader. (2)TrackPortalContentgainsisSettingCover/setCoverError/onSetCoverparams and a new "Previous covers" rail rendered under the existing cover preview whendetail.image.count > 1. The rail is a horizontalScrollViewof ~56pt rounded-control thumbs (AsyncImageon each image'sblobUrl;Color.snSurfaceplaceholder- hairline border) showing every entry in
detail.imagewhose id isn't the currentcoverFile's (natural insertion order — matchesversionSwitcherSheet). Tapping a thumb sets apendingSetCoverstate which drives aconfirmationDialog("Use this as the cover?" / "The current cover stays in history."); confirming callsonSetCover(fileId)→ backend → refetch — main preview, PlayerBar artwork, and the rail itself all rerender. Rail disabled (50% opacity) while either upload-new or set-cover is in flight, so the user can't double-fire.setCoverErrorjoins the existing pick/upload error line. (3)PlayerViewthreads the new state + closure through to the Portal. Out of scope: deleting cover-history entries, per-cover review status, Project/album art (doc-26 phase), artwork-derived screen halo (Claude Code, feel — next). ⚠️ Lands on TestFlight after the next container redeploy (doc 27 step 2 — same dependency as the backend set-cover verb).
Backend — set-cover verb + cross-type final-flag bug fix (2026-06-12, Claude Code;
tscclean). (1) NewPATCH /track/:trackId/file/:fileId/set-cover(no body) — promotes an existing image version to be the track's cover: de-finals the current final image (type-scoped), marks the chosen file final, pointstrack.imageFinalat it; cover history stays intact inimage[](doc 28 §Cover art). Guards:isObjectId, file-belongs-to-track (404), image-only (400). No role gate beyond track membership — consistent with cover upload, which has none. Unblocks the iOS "choose among uploaded covers" rail (queued in.agent/next-brief.mdfor the Xcode agent). (2) Latent bug found & fixed while grounding:createNewFile's de-final filter (fileService.findAndUpdate=updateMany) had notypeconstraint — uploading an audio version also cleared the final image's flag and vice versa, corrupting per-file final state across types (display survived viatrack.audioFinal/imageFinalfallbacks, which masked it). Filter now scoped{ type: fileType }; same scoping used in the new set-cover verb. A dry-run repair script against the live DB found 0 corrupted flags (the fallbacks had kept refs truthful), so no data repair was needed. ⚠️ Set-cover reaches TestFlight on the next container redeploy. Next per the confirmed plan: iOS cover-history slice (Xcode agent) → artwork- derived player halo (Claude Code, feel) → doc-26 sharing/Rosters phase.iOS track metadata completeness — Portal editor covers ISRC / UPC / notes / mainArtists (2026-06-12, Xcode agent;
BuildProjectgreen). Mechanical slice from.agent/next-brief.md, targeting the24-UX-BACKLOG.md"Metadata fields — completeness + sync" item. The editable Track Portal already covered name / label / genre / releaseDate; this slice fills in the rest of the release-metadata strings the backendTrackDTOaccepts (ISRC,UPC,notes,mainArtists). (1)UpdateTrackRequestgainedisrc,upc,notes,mainArtists(all optional) — explicit named init so call sites stay parameter-order-tolerant;CodingKeysmapisrc → "ISRC"/upc → "UPC"(uppercase on the wire pertrack.dto.ts); manualencode(to:)still omitsnilso partial PUTs never clobber unrelated fields withnull. (2)TrackDetaildecodes the matching optionals (uppercaseISRC/UPCCodingKeys, permissivedecodeIfPresent). (3)TrackPortalContentedit mode adds rows below release date — Main artists / ISRC / UPC as single-lineeditableRows (ISRC =.charactersautocap + autocorrect off; UPC =.neverautocap- autocorrect off; both with
number/barcodeglyphs) and Notes as a newmultilineEditableRow(TextField(axis: .vertical),lineLimit(2...6), label-on-top to avoidMetadataRow's trailing-overflow). Read mode shows the new fields viaMetadataRow(hiding empties), with Notes in a verticalnotesReadBlockso long notes don't get clipped. Drafts seeded fromdetail.*; save routes through the existingPUT /track/:id+ refetch handshake usingnormalizedField(empty + previously-non-empty → explicit""to clear; unchanged →nilto omit). Out of scope: structuredcredits(later — needs a contributors editor), validation/format checks, version-scoped metadata, sharing, cover history. Backlog item struck in24-UX-BACKLOG.md.
- autocorrect off; both with
Prod redeployed + QA round closed · PlayerBar tap-through fix → bar-tap opens the Portal (2026-06-12). Owner redeployed the container (doc 27 step 2) and tested TestFlight: the per-version review endpoint is now live in prod (version chip works end-to-end), lock-screen playback verified (audio continues when locked; lock-screen transport works), halo/gesture/Link-id passed earlier, ring feel approved "for the moment". One bug found: the floating PlayerBar didn't consume touches on its non-control areas — tapping the title hit comments scrolled behind the glass pill. Fix (Claude Code,
xcodebuildgreen):.contentShape(RoundedRectangle)over the whole pill +.onTapGesture { onPortal() }— every touch on the bar is consumed, and tapping anywhere that isn't a control now opens the track's Portal (the owner's expectation, and the precursor of doc-30 Phase 2's mini-player tap-to-expand; child play/Portals buttons keep priority). Accessibility hint added. Plan confirmed unchanged: metadata-completeness slice (Xcode agent, queued innext-brief.md) → set-cover verb + artwork halo (Claude Code) → doc-26 sharing/Rosters phase.iOS — PlayerBar artwork control: breathing-ring playback signal · cover/release envisioning (2026-06-12, Claude Code in-house;
xcodebuildgreen). Owner QA on the cover slice: the.ultraThinMaterialdisc + glyph read as a "layer on top" covering the artwork. Redesigned per the owner's vision: paused = artwork unobstructed except a small white play glyph legible via shadow (no covering disc); playing = the glyph fades out (0.25s) and the circle carries a breathing light ring — a hairlinewhite(0.7)stroke on the edge plus a slow sonar-quiet pulse (TimelineView-driven outer ring, 2.4s cycle, expanding ×1.3 while fading from 0.4 → 0) — the owner's "wave signal out of the circle". Reduce Motion → static stroke only; the whole thumb remains the play/pause button (accessibility label carries the state). Consistent with doc 22 §4 (quiet, inevitable transitions). Cover questions envisioned in doc 28 §"Cover art & releases": covers already version (image[]/imageFinalmirroraudio[]/audioFinal; history UI waits on the queued set-cover-from-existing verb); cover ownership follows the release — Single → track art, Album/Compilation → Project art via the Project Portal (Portals-everywhere phase), display preference track → project → gradient; with Option C multi-membership a track shows that release's art in context. Per-cover approval slot already exists (releaseStatus.cover), can adopt the doc-29 per-file pattern later. Doc 24 cover-model item cross-linked. QA still pending from the owner: lock-screen playback test.iOS cover model — Portal manages, PlayerBar displays (2026-06-12, Xcode agent;
BuildProjectgreen). Mechanical slice off.agent/next-brief.md, settled design from24-UX-BACKLOG.md("The cover model"). (1) Models:TrackDetailnow decodesimage: [MediaFile](custominit(from:)so the field is permissive — defaults[]for older payloads) andimageFinal: String?, plus acoverFile: MediaFile?helper that resolvesimageFinal→isFinalVersion == true→ last upload (mirrors howaudioFinalis consumed for versions). (2) Portal — Cover section (TrackPortalContent): rounded-square ~96pt preview (AsyncImageoncoverFile.blobUrl; identity-accent gradient placeholder), with aPhotosPicker(matching: .images)"Add / Change cover" button below. Picked items resolve a realimage/*MIME fromPhotosPickerItem.supportedContentTypes(neverapplication/octet-stream, which the backend would 400), get written to a temp file, and go up through the existingAPIClient.uploadFile(fieldName: "file", …)multipart path — same plumbing slice-5 uses for audio versions; the backend's existing image branch flipsimageFinalautomatically and the parent refetch surfaces the new cover everywhere. Local upload-error + pick-error UI matches theUploadVersionSheetpattern. (3) PlayerBar artwork: new optionalartworkURL: URL?; when present,AsyncImagerenders under the existing.ultraThinMaterialdisc + play/pause glyph (artwork stays focal, glyph stays legible on any cover color). Falls through to the identity-accent gradient onnil/ loading / failure — no placeholder squares, no flicker. (4) Wiring:PlayerViewpassesidentityAccentfor the placeholder and threadsviewModel.uploadCover(…)(mirrorsuploadVersionbut no version-switch on success — refetch is enough). Out of scope (queued): picking among already-uploaded images (needs a backend set-cover-from-existing verb — Claude Code, next backend round); artwork-derived adaptive halo on the player (Claude Code, feel work).Docs — the cover model settled + slice queued (2026-06-11). Owner asked where covers (and brand images) are added/reviewed/edited and whether they appear on the wave page. Grounded against the backend:
Track.image[]+imageFinalexist,GET /track/:idalready populatesimage, and animage/*upload through the existingPOST /track/:id/fileautomatically becomes the cover (final-flipping is generic; the doc-29 reviewStatus/notification additions were correctly audio-only). Design locked in24-UX-BACKLOG.md("The cover model"): manage in the Portal, display on the wave — Portal gets a Cover section (current cover + Change cover → PhotosPicker → existing multipart plumbing); the wave shows the cover only as supporting elements (PlayerBar thumb artwork now; artwork-derived adaptive halo later, feel work). Gap found: no set-existing-image-as-cover verb — small backend addition (Claude Code, next backend round); upload-new works today. Artist/Project brand images = same pattern via their own Portals, landing with the doc-26 Portals-everywhere phase. Mechanical iOS slice written to.agent/next-brief.md(models decodeimage[]→ Portal cover section → PlayerBar artwork), gated on the pending player QA round.iOS scroll-linked halo + id relocation · workspace doc audit ·
31-PRODUCT-VISION-360.md(2026-06-11, Claude Code;xcodebuildgreen). (1) Halo transition is now scroll-linked (owner: the no-halo→halo moment is a brand moment): the binaryisWaveformPinnedtoggle + 0.18s animation becamepinProgress(0…1 over a 56pt approach band, computed inonScrollGeometryChange) — the halo opacity rides the user's own gesture, the nav title follows withpinProgress²(arrives a beat later), nothing pops; the principle is codified in doc 22 §4 "Motion & state transitions" (scroll-linked states interpolate with the gesture; discrete changes = quiet 150–250ms; "is there a visible instant where the UI flips?"). (2) Logical id off the wave page: the slug/shortId line under the player title removed — it now lives in the Track Portal's Label Copy table as a "Link id" row (ids belong with sharing/metadata, not hero content). (3) Workspace-wide doc audit (Explore agent sweep of all *.md, 4 repos) — fixes applied:ctrl-audio-back/CLAUDE.md"Known gaps" was actively misleading (still claimed no approval endpoint / Apple Sign In / refresh / APNs) → replaced with current Status + real remaining gaps; frontdocs/12-UI-UX-DESIGN-SYSTEM.mdre-branded WAVIC→Sonnance + scoped to web + reciprocal link to doc 22; archive/supersession banners added to infra docs 15, 16 and a live-API note to 03 (api.sonnance.ff8.dev, no/v1); doc 23's parked Sharing note now points at doc 26 directly. Remaining recommendations (README↔CLAUDE sync) parked. (4)31-PRODUCT-VISION-360.md— owner-locked brand/business layer: audience = professional-led ("every user is a music business"; B2B center of gravity — teams/labels/studios — with solo pros as the bottom-up adoption engine; doc-26 sharing/Rosters therefore = next major build after polish); monetization deferred entirely (Stripe stays stubbed, archived tiers not commitments); brand pillars = craft & reverence (sanctuary) + professional calm (anti-chaos), with fair credit & attribution as the standing later-phase commitment (doc 28 keeps preparing the data); north star = the collaboration OS of music, expanding into the creator business hub (credits, contracts, splits, royalties) as the differentiated B2B destination. Doc-31 row added to00-ROADMAP.md.iOS — QA round 3: waveform halo + gesture intent + background audio; doc 30 envisioning (2026-06-11, Claude Code in-house;
xcodebuildgreen). (1) Waveform backing, take 3: the gradient scrim still drew a visible "section" edge below the title (white in light mode) — replaced with a feathered halo owned by the waveform: a blurred (blur(18)) roundedsnBackground.opacity(0.88)fill hugging the bars; no hard edges, no band. (2) Gesture intent state machine (Waveform): a touch is undecided until ~10pt of travel — horizontal commits to scrubbing, vertical = cancelled (the user wanted to scroll, not the timeline; no seek fires), and >80pt vertical pull mid-scrub bails out (slide-off-to-cancel, firesonScrubCancelled). Lifting while undecided = tap (comment-marker hit-test, else tap-to-seek). Fixes the reported "tap waveform then scroll accidentally moves the timeline". (3) Background audio (owner ask):UIBackgroundModes: audioadded toInfo.plist(alongsideremote-notification);PlayerViewModelnow publishes Now Playing metadata (MPNowPlayingInfoCenter— version name / track / duration / position, updated on state changes only) and registers remote transport (MPRemoteCommandCenter: play, pause, toggle, scrub) so the lock screen / Control Center / headphones control playback; command targets + now-playing teardown live in theObserverHoldernon-isolated deinit. Scoped to the wave page — leaving it ends the session by design until the global player. (4)30-GLOBAL-PLAYBACK.md— the owner's "Sonnance as a music player" vision, phased: Phase 1 ✅ (the above); Phase 2PlaybackManagerextraction (app-scoped player that survives navigation) + a floating glass mini player above the tab bar (cover-as- play/pause + marquee + accent progress hairline; tap → wave page; swipe → dismiss; zero chrome when idle); Phase 3 favorites-as-queue (a doc-26 lens + small backend favorites store), loop, continuous play, AirPlay/Live-Activity stretch. Doc-30 row added to00-ROADMAP.md; doc-24 QA-round-3 section added.Version-scoped approval, end-to-end (
29-VERSION-SCOPED-APPROVAL.md) + waveform scrim fix (2026-06-11, Claude Code in-house; backendtscclean, iOSxcodebuildgreen ×2 — pending owner visual QA). Owner decisions locked: the unit of approval is the File (per-versionreviewStatus), and a new upload resets to Under review. Backend:File.reviewStatus/reviewStatusBy/reviewStatusAt(plain-string enum to avoid a file→track type dependency, default'No status'); newPATCH /track/:trackId/file/:fileId/review-status(UpdateFileReviewStatusDto) →updateFileReviewStatus(owner/admin gate, file-belongs-to-track check, collaborator notification "set V{n} of {track} to {status}"); mirror — when the file is final,releaseStatus.versionis updated too, so web/legacy clients stay truthful;createNewFile/createTracknow start fresh audio at Under review and reset the mirror ($push +releaseStatus.versionin one update). Data backfill (one-off script, applied to the live Atlas DB — additive/safe for the not-yet-redeployed prod image): 33 audio files; finals inherited their track'sreleaseStatus.version, others'No status'. iOS:MediaFile.reviewStatus(permissive),UpdateFileReviewStatusRequest,ApproveBar.versionStatusOverride, andPlayerViewModel.updateVersionReviewStatus— the player's Version chip now shows and sets the verdict of the exact version being heard (switching versions switches the chip); Title/Cover keep the track-level PATCH. ⚠️ TestFlight needs a container redeploy (doc 27 step 2) before the new endpoint exists on prod. Waveform scrim (owner QA): the pinned header's.ultraThinMaterialband read as an off-brand gray strip — replaced with a top-anchoredsnBackgroundgradient scrim (0.94 → 0.82 → 0) so comments dissolve under the waveform; hairline removed. Doc 29 added to the00-ROADMAP.mdmap; doc-24 version-approval item struck; doc-28 row marked Option C confirmed.iOS — collapsing sticky waveform + stable identity colors, implemented in-house by Claude Code (2026-06-11; build verified green via
xcodebuild, pending owner visual QA — checklist in.agent/next-brief.md). Context: the Xcode agent's build of the collapsing-header brief was reverted by the owner ("didn't work as expected"). Root cause found in code review:Waveformattached its scrubber with a plain.gesture(DragGesture), which loses to the ScrollView pan the moment the waveform moves inside a scroll view — exactly what scroll-then-stick requires. This class of layout/feel work is now routed to Claude Code directly (new "Routing" section in.agent/workflows/ios-feature-slice.md); the Xcode agent keeps mechanical slices + visual QA. (1) Collapsing header (PlayerView): singleScrollView+LazyVStack(pinnedViews: [.sectionHeaders])— title/version/timecode scroll away (height measured viaonGeometryChange), the waveform is the pinned section header (.ultraThinMaterialbacking + hairline fade in when pinned, driven byonScrollGeometryChangeagainst the measured threshold), and the track title fades into the nav bar as a brand-font principal toolbar item. (2) Waveform gesture rework:.highPriorityGestureso scrubbing beats the scroll pan; comment markers are now visual-only and their taps are resolved by hit-testing in the drag'sonEnded(near-zero translation + top marker band + nearest marker within 16pt) with a newonScrubCancelledcallback so a marker tap doesn't leave the scrub preview stuck (a high-priority gesture would have starved the old markerButtons). (3) Stable identity colors:Theme.accent(forKey:)— FNV-1a over the entity id (replacing index-seeding and launch-randomizedString.hashValue) — applied to artist + project + track across ArtistsHomeView, ArtistDetailView (header + project grid), ProjectTracksView (header + track rows), RecentsView rows, and the player (screen halo, PlayerBar, empty-state hero). Semantic now explicit inTheme: identity color = who/what it is (accent(forKey:)) vs version color = which revision (versionColor(for:)— version pill, version cards, waveform bars, Portal version dots, all unchanged). (4) ApproveBar reposition (owner decision, same session): the owner chose status chips in the scrolling header over a PlayerBar pill or Portal-only placement.ApproveBargained aStyle(.barhistoric glass strip /.inlinecompact hugging chips); the player renders.inlinechips under the version line (they scroll away with the title — approval is an occasional act, not a constant control), and the bottom stack is now just composer + PlayerBar. Backlog reposition item struck; the "reads as status, not action" affordance note stays open for post-QA review. Secondxcodebuildrun green.Docs — third TestFlight feedback round triaged (player header + color scope + comment timing). Player interaction polish shipped; new feedback captured in
24-UX-BACKLOG.mdand seeded as the next slice (.agent/next-brief.md): (1) collapsing sticky waveform — the player header should scroll up with the comments until the waveform reaches the top + safe-area, then pin (nativeLazyVStack(pinnedViews: [.sectionHeaders])with the waveform as the pinned section header), and the track title becomes a native.navigationTitleheader (large → inline on scroll); ApproveBar reposition still held. (2) Accent-color consistency extended — the queued stable-hashTheme.accent(forKey:)fix must cover artist + project + track (artist may already be aligned; extend to project/track), queued behind the waveform slice. (3) Comment timing precision[P2]— capturing a comment uses the instantaneous playhead (too coarse); future item to let users fine-tune the exact second (stepper / type-the-timecode at compose time + edit-after).iOS — Player interaction polish slice (all four TestFlight items shipped). Pure iOS, no backend. (1) End-of-track reset:
PlayerViewModelnow observesAVPlayerItem.didPlayToEndTimeNotificationon the current item, re-registers on version swap, and tears down via a smallObserverHoldersidecar (Swift 6 strict concurrency blocksnonisolatedmutable storage on@MainActor @Observableclasses, and a class deinit can't read main-actor-isolated state — the holder owns the observer tokens and removes them in its own non-isolated deinit). On end-of-track: pause,isPlaying = false, seek to.zero, so the control returns to the play glyph and the track is immediately re-playable. (2) Composer above keyboard (core-loop blocker):CommentComposertakes an externalFocusState<Bool>.Binding(with internal-fallback when none is passed);PlayerViewdrivesisComposerFocusedand, while focused, hidesApproveBar+PlayerBarso the composer is the only bar above the keyboard. Default SwiftUIsafeAreaInset(edge: .bottom)avoidance lifts the inset, and the commentsScrollViewadds.scrollDismissesKeyboard(.interactively)so a swipe closes the keyboard. (3) Tap a comment → seek:CommentCardrows gain an.onTapGesturethat callsviewModel.seek(to: comment.timeStart ?? 0)— the inverse of the existing waveform-dot → scroll-to-comment direction; context-menu / swipe actions still work. (4) Waveform-as-scrubber + pinned: the separateSlideris gone;WaveformexposesonScrubChanged/onScrubEndeddriven byDragGesture(minimumDistance: 0)(taps and drags share one recognizer). The host suppresses periodic-time-observer writes mid-drag viaisScrubbingand seeks on release.loadedContentwas restructured so title / version label / focal timecode / waveform sit in a pinned VStack above the commentsScrollView— the waveform (and the moving playhead) stays visible while comments scroll. Per-comment dots + section strips still work; comment-marker taps still scroll-to-comment via the capturedScrollViewReaderproxy. The Title/Version/Cover ApproveBar reposition is still held (24-UX-BACKLOG.md) for a deliberate player-layout design pass. Backlog items struck in24-UX-BACKLOG.md.BuildProjectgreen.Docs — doc-28 decisions confirmed · doc-27 gotchas folded · player feedback triaged. Owner confirmed doc 28 = Option C (variant enum + bidirectional
relations+ secondaryappearsInProjects[]; not a full Release remodel) — built backend-first, post-polish/sharing (envision-only;variantTypenot pulled forward). Folded the two live-deploy gotchas into27-TESTFLIGHT-BACKEND-DEPLOY.md: app stuckrunningStatus: Stopped→ start viaaz restARM action (thecontainerapp1.2.0b4beta lacksstart/stop);ImagePullBackOff→ refresh stale ACR admin creds viaaz containerapp registry set; plus a Squarespace 3-labelasuidDNS note. Second round of TestFlight feedback captured in24-UX-BACKLOG.mdand re-grouped into a "Player interaction polish" slice (.agent/next-brief.md): (1) playback resets to the play glyph at end-of-track; (2) comment composer lifts above the keyboard (core-loop blocker — text was hidden); (3) tap a comment → seek to its time; (4) waveform becomes the scrubber (continuousDragGesture, drop the separateSlider) and stays visible while comments scroll. The Title/Version/ Cover ApproveBar reposition is held for a deliberate player-layout design pass (owner's call). The cross-screen accent-color fix moved to "queued behind".00-ROADMAP.mddoc-28 row updated (decisions confirmed).Docs — variants/releases envisioning (
28-RECORDINGS-VARIANTS-RELEASES.md) + TestFlight-feedback triage. TestFlight is live with real users syncing. Triaged the owner's first round of feedback. iOS bugs/polish captured in24-UX-BACKLOG.mdand seeded as the next slice (.agent/next-brief.md): (1) play button stuck on pause after a track ends (observeAVPlayerItem.didPlayToEndTimeNotification→ reset); (2) accent colors out of sync across screens — root-caused: the same entity is seeded inconsistently (ArtistsHomeViewusesversionColor(for: index)vsArtistDetailViewusesversionColor(for: abs(artistId.hashValue)), and SwiftString.hashValueis randomized per launch) → fix is one stable-hashTheme.accent(forKey:)used everywhere for identity color, keepingversionColor(for: index)for the V1/V2/V3 palette; (3) cover-selection + version-sheet refine, now unblocked (image upload acceptsimage/*). New envisioning doc 28 explores modeling remixes / radio-edits / explicit-clean + the same recording appearing on multiple albums/compilations- cross-reference navigation. Found the real gaps:
Trackconflates song-vs-recording (itsFile[]are production revisions, not variants) andTrack.projectis single-valued (multi-album membership unrepresentable). Frames the industry Work/Recording/Release model and recommends Option C (pragmatic): avariantTypeenum + bidirectionalrelations+ secondaryappearsInProjects[]on Track, with navigation riding doc-26's "one graph, many lenses" (variant/label/relation as tags + lenses + cross-link chips) — over a full Release remodel (B) or labels-only (A). Envision-now/build-later; 3 decisions to confirm. Doc-27 row updated (API live; TestFlight running); doc-28 added to the00-ROADMAP.mdmap.
- cross-reference navigation. Found the real gaps:
Fixed
- Backend — one orphaned track 500'd the whole project list.
findAllByProject(theGET /track/project/:idpath) resolves every track viaPromise.all(findOneById…), andfindOneByIdthrowsNotFoundExceptionwhenartistSpacedoesn't resolve — so a single corrupt track (the orphan from thePOST /trackbug above) made the entire project fail to load (Home → Artist → Project). Two-part fix: (1) data repair — a one-off script found the single orphaned test track ("New Client Track") and reset itsartistSpaceto its project's space; (2) hardening —findAllByProjectnow usesPromise.allSettled, dropping +console.warn-ing any track that fails to resolve instead of rejecting the whole batch. (findAllByProjectIdshares the samePromise.allpattern — left as-is for now; harden if it ever bites.) - Backend —
POST /trackorphaned new tracks (JSON create from iOS).createTracktookartistSpacefrom the request body, but the iOS create sheet sends only{ name, project, label?, genre? }. WithartistSpaceundefined,new Types.ObjectId(undefined)mints a random id, so the track saved pointing at a non-existent space. The track's own detail fetch (GET /track/:id→findOneById) then threwNotFoundExceptionon itsif (!track.artistSpace || !track.project)guard — surfacing in the app asSonnance.APIErrorright after the create sheet ("page didn't load"). Fix: deriveartistSpacefrom the project (projectData.artistSpace?._id ?? projectData.artistSpace), the source of truth — same pattern asowner. Clients now only need to sendproject; no iOS change required. (Note: any test tracks created before this fix carry a bogusartistSpaceand will 404 on detail — they can be ignored or trashed.) (1)CreateCommentDto/CreateResponseDto/UpdateCommentDto(andCreateSectionDto/UpdateSectionDto) had no class-validator decorators, so the globalValidationPipe(whitelist+forbidNonWhitelisted) rejected every field with400 "property X should not exist". Added proper decorators. (2)SignUrlsInterceptor.signUrlsInObjectrecursed into Mongoose/bsonObjectId/Datevalues and collapsed them to{}— corrupting every_id/createdAtin raw-doc responses (the track endpoint escaped this only because it pre-stringifies viaserializeTrack). Now leavesObjectId/Date/Bufferuntouched. (3)createCommentByFilereturned the unpopulated saved doc (owneras a raw id); now returns the populated + lean shape matchingGET /comment/file/:idso clients decode both responses consistently. Also guardedFileService.findById+ comment/section file-id queries withisObjectId(null /[]instead of 500 CastErrors). Verified end-to-end: POST201+ GET200, string_ids, populatedowner. - Backend — dead CDN in stored
blobUrls. Azure Front Door (sonnance-images…azurefd.net) was deleted, so audio/image URLs 404'd. DisabledCDN_URLin.envand rewrote the 22 storedblobUrls to the direct blob-storage host (sonnancewebappstorage00.blob.core.windows.net); SAS tokens intact. Audio plays. - iOS — Swift concurrency in
APIClient(Xcode 26 Approachable Concurrency). Six MainActor-isolation warnings (Swift 6 errors) flagged by Xcode where the non-MainActorAPIClientactor reaches intoAPIConfig.baseURL,KeychainStore.accessToken/refreshToken/clear()— all of which were being inferred@MainActorunder the project's default isolation. Per-member fix:nonisolated(unsafe)on the static stored + computed properties (URL constant, keychain getters/setters which delegate to thread-safeSecItem*APIs) andnonisolatedon the methods (clear(), plus the privateread/write/baseQueryhelpers). Matches thenonisolated struct MediaFilepattern already in use across the wire-format models. Lint clean; no behavior change.
Added
- Backend — first live deploy to Azure Container Apps (runbook doc-27 steps 1–3 executed). Image built in ACR (
az acr build→sonnanceacr.azurecr.io/ctrl-audio-backend:20260610-170319) and thectrl-audio-backendContainer App (RGSonnance-WebApp, envsonnance-env) pointed at it. The API is now live on the default hosthttps://ctrl-audio-backend.thankfulsmoke-fb049d11.westeurope.azurecontainerapps.io— verified/api-docs→200,/user(unauth) →401(routing + guard + Atlas all alive). Env corrected on the running app to match the documented fixes: removedCDN_URL(it still pointed at the deleted Front Door — would 404 all audio/images, same bug fixed locally), addedAPPLE_CLIENT_ID=com.sonnance.app(primary TestFlight auth, was missing), addedJWT_REFRESH_SECRET(generated key, stored as a Container App secret) +JWT_REFRESH_EXPIRATION=30d/JWT_EXPIRATION=30d, addedSTORAGE_PROVIDER=azure.MONGODB_URI,JWT_SECRET,AZURE_STORAGE_*, Google OAuth secrets were already present from a prior setup;ORIGINleft as-is (already a correct web-CORS list). Two infra gotchas the runbook missed (fold into doc-27): (1) the Container App was inrunningStatus: Stopped— every probe returned a fast edge404with 0 replicas and no logs; start it withaz containerapp start(the locally-installedcontainerappextension1.2.0b4is a beta that lacksstart/stop, so use the ARM action directly:az rest --method post --url .../containerApps/<app>/start?api-version=2024-03-01). (2) After starting, the replica hitImagePullBackOff— the stored ACR pull password (admin creds,passwordSecretRef, no managed identity) was stale; refresh withaz containerapp registry set --server sonnanceacr.azurecr.io --username sonnanceacr --password $(az acr credential show -n sonnanceacr --query passwords[0].value -o tsv). Steps 4–5 done too: custom domainapi.sonnance.ff8.devbound with a free managed cert (SniEnabled, certmc-sonnance-env-api-sonnance-ff8-9515) — DNS isCNAME api.sonnance → app FQDN+TXT asuid.api.sonnance → <verifyId>on Squarespace-hostedff8.dev(the editor balks at the 3-labelasuid.api.sonnancehost but it does save);https://api.sonnance.ff8.dev/api-docs→200over TLS. And the iOSAPIConfig.swiftrelease URL was swappedapi.sonnance.com→api.sonnance.ff8.dev(debug stayslocalhost:8080). Remaining: step 6 full-loop smoke test from a TestFlight/release build (Apple Sign In → browse → play → comment → approve → create → upload). - Docs — TestFlight backend deploy runbook (
27-TESTFLIGHT-BACKEND-DEPLOY.md). TestFlight builds point athttps://api.sonnance.ff8.dev(release) notlocalhost, so the API needs a public host. Confirmed the data tier is already cloud (Atlasmongodb+srv+ Azure Blobsonnancewebappstorage00) — only the NestJS process is local, so this is a deploy, not a rearchitecture (CloudKit explicitly rejected, per doc 21). Runbook reuses the existingsetup-azure.sh(RGSonnance-WebApp/ ACRsonnanceacr/ envsonnance-env/ctrl-audio-backendContainer App on :8080) + the Container-App-readyDockerfile, and fills the gaps:az acr build+containerapp update --image; the current env/secret list (supersedes the staleconfigure-env.sh— corrects storage vars toAZURE_STORAGE_ACCOUNT_NAME/KEY/CONTAINER_NAME, addsJWT_REFRESH_SECRET,APPLE_CLIENT_ID=com.sonnance.app, APNs); theapi.sonnance.ff8.devcustom domain (CNAME +asuidTXT on the ff8.dev zone) with a free managed cert; and the one-line iOSAPIConfigrelease-URL swap. Decision recorded: deploy via bash +azCLI (matches existing scripts); Azure Container App over PaaS; domain on ff8.dev (sonnance.com not yet held). Added doc-27 row to00-ROADMAP.mdmap. - iOS — Record audio → upload as a version (slice-5 fast-follow, first polish slice of the post-CRUD / pre-TestFlight pass per
24-UX-BACKLOG.md). Slice 5 shipped audio upload via file-pick; recording was explicitly deferred. This adds the record path so a user can capture audio in-app (mic → AAC) and upload it as a new version — completing the upload story. Pure iOS, no backend change — the recorded.m4aflows through the exact sameAPIClient.uploadFile(...)plumbing as file-pick, including the progress / cancel / error UI onUploadVersionSheetand the post-upload "jump to the new final version" handshake onPlayerViewModel.RecordAudioSheet(Features/Review/) — new glass sheet matchingUploadVersionSheet/CreateTrackSheetstyling (.thinMaterial, drag indicator,presentationCornerRadius(SnRadius.sheet),.medium/.largedetents). Three states plus a permission stage: (a) permission-pending — spinner whileAVAudioApplication.requestRecordPermission()is in flight (iOS 17+ async overload); (b) permission-denied — on-brand.snCardwithmic.slashglyph and aPrimaryButton"Open Settings" deep-link viaUIApplication.openSettingsURLString(the brief's denial-handling requirement); (c) idle —micglyph + "Start recording"PrimaryButton; (d) recording — pulsing amber dot + "RECORDING" caption + hugemm:ssdisplay-font timer driven by a 100 msTask.sleeploop (not aTimer— keeps Sendable-clean under Approachable Concurrency) + "Stop"PrimaryButton; (e) recorded —.snCardwith waveform glyph, take duration, accent-tinted play/pause preview button (usesAVAudioPlayeragainst the local file — simpler thanAVPlayerfor a single-file playback preview, and no streaming round-trip), then "Use this recording"PrimaryButton- "Re-record"
GhostButton.AVAudioRecorder→.m4atoFileManager.default.temporaryDirectory.appendingPathComponent("sonnance-rec-<UUID>.m4a")withAVFormatIDKey: kAudioFormatMPEG4AAC,AVSampleRateKey: 44_100,AVNumberOfChannelsKey: 1,AVEncoderAudioQualityKey: AVAudioQuality.high. Audio session is set to.playAndRecord(.defaultToSpeaker+.allowBluetoothHFP— the new spelling that replaces the iOS-8-deprecated.allowBluetoothraw constant) while the sheet is active; on disappear we restore.playbackso the mainPlayerViewAVPlayerkeeps the silent-switch / lock-screen behaviour shipped in slice 1. Re-record stops + deletes the previous temp take before starting a new one (no orphan files left intmp/). The recording stage is gated against interactive dismissal (interactiveDismissDisabled(phase == .recording)) so a drag-down can't silently drop a take.UploadVersionSheet— the only existing-file touch in this slice: the idle state's "Choose audio file"PrimaryButtonnow sits above a sibling "Record a new take"GhostButtonwith amic.circleglyph; tapping it presentsRecordAudioSheet(.sheet(isPresented:)from inside the upload sheet — iOS 26 nests sheets natively, no 250 ms hop needed here because we're not swapping sibling sheets at the same level). On the record sheet's "Use this recording" callback we just setpickedURL/pickedFilenameexactly the way the file-importer does, so the existing picked-state + upload-state + error-state flow runs unchanged — zero duplication of progress / cancel / error UI. Filename:"Recording yyyy-MM-dd HH-mm-ss.m4a"(POSIX locale to avoid month-name surprises in non-Latin locales). MIME: handed to the existingAudioMIMEType.from(url:)helper —.m4aresolves toaudio/mp4via UTType, satisfying the backend'saudio/*MIME gate (NOTapplication/octet-stream, which the slice-5 backend gotcha note already calls out). Both entry points from slice 5 still work unchanged — the empty-version "No versions yet" card and the in-player version-switcher header "Add version" button both present the sameUploadVersionSheet, which now offers both sources.Info.plist: addedNSMicrophoneUsageDescription— without it the system terminates the app on mic access. New file:Sonnance/Features/Review/RecordAudioSheet.swift. Touched:Sonnance/Info.plist,Sonnance/Features/Review/UploadVersionSheet.swift.XcodeRefreshCodeIssuesInFileclean on every touched file;BuildProjectgreen. Out of scope per the brief: waveform-while-recording, trim / edit, multi-take, backend change (the multipart endpoint already acceptsaudio/*), attachments / artwork. With this slice the upload story is closed — file-pick + record are both shipped and ride the same upload pipe. Self-check vs the rest of the upload surface: typography (display 24 black header, body / caption / display-32-black focal time), spacing tokens,.snCardfor grouped content,PrimaryButton/GhostButtonactions, accent driven byTheme.versionColor(for:)from the parent — all reused, no rawVStackchrome.
- "Re-record"
- iOS — Audio upload (new version) (slice 5 of
25-IOS-CRUD-FIRST-RELEASE.md). Final slice of the first-release CRUD push — the create → review → approve loop now closes fully on-device. A user can pick an audio file from Files / iCloud Drive and upload it as a new version of the track; on success the player reloads and jumps to the new (now-final) version. Recording is deferred as a fast-follow — file-pick is shipped here. JSON-only verbs (slices 2–4) needed no client multipart capability; this slice is where it lands. Net-new inAPIClient:uploadFile(path:fileURL:fieldName:filename:mimeType:progress:)— builds the multipart body into a temp file on disk (header + file bytes streamed in 64 KB chunks withFileHandle+ footer, so the audio is never loaded into memory wholesale), then uploads withsession.upload(for:fromFile:delegate:)and a per-taskUploadProgressDelegatethat bridgesurlSession(_:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend:)to a Sendable(Double) -> Voidprogress closure. Bearer auth attached the same as JSON verbs; 401 throwsAPIError.unauthorized(caller signs out) — deliberately no refresh-then-retry dance here because audio uploads can take minutes and a mid-stream refresh would re-stream the whole body.request.timeoutInterval = 600. ⚠️ Backend gotchas designed around (verified intrack.controller.tsL344 +track.service.tsL1017): (1) the part name MUST be exactlyfile(file[0].mimetype.split('/')[0]has no null guard — wrong part name = 500); (2) the MIME must start withaudio/(orimage//video/) — anything else returns aBadRequestExceptionbody (not a thrown 400), so we surface themessagefield if present.AudioMIMETypehelper (Core/AudioMIMEType.swift) — UTType lookup first (usingUTType(filenameExtension:).preferredMIMETypeand only acceptingaudio/*), with an extension fallback (mp3→audio/mpeg,m4a→audio/mp4,wav→audio/wav,aac/flac/aiff/ogg/mp4likewise). Final fallback isaudio/mpeg— neverapplication/octet-stream.UploadVersionSheet(Features/Review/) — glass sheet matchingCreateTrackSheet/CommentEditSheetstyling (.thinMaterial, drag indicator,presentationCornerRadius(SnRadius.sheet),.medium/.largedetents). States: idle (choose-audio-filePrimaryButton,tray.and.arrow.upplaceholder), picked-not-uploading (filename pill + "Upload" / "Choose different file"), uploading (determinateProgressView(value:)tinted with the per-version accent, inline % readout, CancelGhostButton), error (inline caption + Retry implied via the existing buttons)..fileImporterrestricted to[.audio, .mp3, .wav, .mpeg4Audio, .aiff]; the security-scoped URL is retained by the sheet, andAPIClient.uploadFileopens/closes the scope around the streaming read.Cancelcancels the wrappingTask, which propagates through toURLSessionTask.cancel(). The sheet setsinteractiveDismissDisabled(isUploading)so a drag-down can't kill the upload silently, and dismisses itself on success.PlayerViewModel.uploadVersion(fileURL:filename:mimeType:)runs the upload with per-progress@Published-equivalent (@Observable) state (isUploading+uploadProgress(0…1) +uploadError), and on success callsfetchDetail()and then jumps to the new final version (defaultIndex(for: updated)) since the user just uploaded it — keepingfetchDetail's existing "preservecurrentIndex" behavior intact for non-upload refresh paths. Errors are split:CancellationErroris swallowed silently,APIError.unauthorized→signOut,APIError.http(_, body)decodes the JSONmessagefield if present (covering the "File is not valid." MIME-gated path), everything else surfaceserror.localizedDescription. Entry points (two, both as the brief required): (a) the "No versions yet" empty-state card now leads with an "Add version"PrimaryButton(Edit details demoted to aGhostButton); (b) the in-player version-switcher sheet header gets an "Add version"GhostButtonthat dismisses the switcher and presents the upload sheet (with a 250 ms hop so SwiftUI doesn't race two.sheetmodifiers in the same frame). Auto-notifies collaborators (NEW_AUDIO_VERSION_UPLOAD) is the backend's responsibility — no client action needed. New files:Sonnance/Core/AudioMIMEType.swift,Sonnance/Features/Review/UploadVersionSheet.swift. Touched:Sonnance/Core/APIClient.swift(upload method +UploadProgressDelegate),Sonnance/Features/Review/PlayerView.swift(upload state on the VM +showUploadSheetsheet + entry points).XcodeRefreshCodeIssuesInFileclean on every touched file;BuildProjectgreen. Deferred: recording (AVAudioRecorder-backed record sheet →.m4a) is a fast-follow — file-pick is the must-have shipped here. Out of scope per the brief: attachments (Stems / Lyrics / Contracts — different endpoint), version delete UI (DELETE /:trackId/file/:fileIdexists but no UI), image / cover artwork upload (audio-only this slice), waveform regeneration concerns, subscription / version-cap enforcement (commented out server-side).After this slice: all five slices of
25-IOS-CRUD-FIRST-RELEASE.mdare complete — the full create → review → approve loop runs on-device. Next horizon is the sharing / identity / lens layer (26-IDENTITY-SHARING-NAVIGATION.md, envision-only) and / or web parity (Phase 2). Re-scope before picking —next-brief.mdis due for a refresh. - iOS — Comment edit / delete / resolve (slice 4 of
25-IOS-CRUD-FIRST-RELEASE.md). Closes the review loop in the player — a user can now resolve/unresolve, edit the text of, and delete comments without leavingPlayerView. JSON-only; no replies / threading (thePOST /comment/:id/responsesendpoint exists but threading is deferred to a later slice). Actions are wired aroundCommentCardinPlayerView'scommentsList(the card stays presentational): each row gets a.contextMenuand mirrored.swipeActions(.leadingresolve toggle insnStatusGreen;.trailingauthor-only edit in accent + delete in destructive red), so the same set of verbs is reachable via long-press or swipe. Resolve / unresolve is allowed on any visible comment (workflow action — backend even fires a "completed your comment" notification when a non-owner resolves someone's comment). Edit + delete are author-only — gated client-side viacomment.owner?.id == AuthManager.shared.currentUser?.idbecause the backend'supdate/deleteignore the caller (hardening that with a server-side ownership check is a reasonable future follow-up). Edit sheet: newCommentEditSheet(private toPlayerView.swift) — Sonnance glass sheet matchingCreateTrackSheetstyling (.thinMaterialbackground, drag indicator,presentationCornerRadius(SnRadius.sheet),.medium/.largedetents); header shows the comment's@m:ssbadge in the per-version accent (or "Untimed note"); body is a multi-line pillTextField(lineLimit(3...8));PrimaryButtonSave disabled until the text changes. Delete: destructive.confirmationDialog("This permanently deletes the comment.") wired via apresenting:binding so the action holds the right comment id even if the dialog races with a list reload — important becauseDELETE /comment/:idis a hard delete on the backend (no soft-delete / restore, unlike Artist/Project/Track). ⚠️ Backend wire-shape gotcha (verified incomment.service.ts):PUT /comment/:idreturns the comment unpopulated —owneris a raw ObjectId string, not the populated{ _id, username, … }object thatGET /comment/file/:idreturns. So the iOS mutators don't try to decode the PUT response asTrackComment(it would type-mismatch onowner: CommentOwner?); instead all three verbs use the existingAPIEmptyResponseno-op decoder and reload vialoadCommentsAndSections()to get the populated, correct shape (also clean becauseTrackCommentis an immutableletstruct — no in-place mutation). New file: none — theCommentEditSheetis private toPlayerView.swiftto keep it scoped to the player feature. New model:UpdateCommentRequestinCore/Models/Comments.swiftwith the same manual omit-nilencode(to:)asTrackCRUDRequests— important here becauseUpdateCommentDtois a validated class (whitelist+forbidNonWhitelisted), so anullcontent during a resolve toggle (or vice-versa) would either blank the field or 400. PlayerViewModel additions:mutatingCommentId: String?(per-row in-flight gate — disables a row's swipe/menu and dims it to 50% while its PUT/DELETE is pending so a row can't be acted on twice), plus three async methods:setResolved(_:_:),editComment(_:content:),deleteComment(_:)— each runs the request asAPIEmptyResponse, callsawait loadCommentsAndSections()on success, and routes 401s throughAuthManager.signOut(). PlayerView additions:editingComment: TrackComment?(.sheet(item:)for the edit surface) +pendingDeleteComment: TrackComment?(drives the confirmation dialog) +commentRow(_:)helper composingCommentCardwith the menu/swipe/disable. NoAPIClientchange —.put,.delete, andAPIEmptyResponseall already exist; noCommentCardchange — the resolved checkmark already renders oncomment.isResolved == true.XcodeRefreshCodeIssuesInFileclean on every touched file;BuildProjectgreen. Out of scope per the brief: replies / threading, likes, section CRUD, audio upload (slice 5), sharing badges / lenses / Portals-on-Artist-Project (doc 26 envision-only), backend ownership enforcement on edit/delete (future hardening). - iOS — Project & Artist CRUD (create / rename / edit / delete) (slice 3 of
25-IOS-CRUD-FIRST-RELEASE.md). The two hierarchy levels above Track from slice 2 — the whole tree can now be built on the phone. JSON-only; cover-image upload still deferred to slice 5. Create artist:ArtistsHomeViewgains a+toolbar item presentingCreateArtistSheet— minimal glass sheet, single Name field,POST /artistwith name-only body (⚠️ArtistSpaceDTOis a validated class withwhitelist+forbidNonWhitelisted, so any extra key 400s —CreateArtistRequestsends onlyname). On success the grid reloads and the parentNavigationStackpushes into the new artist viapath.append(ArtistRoute(id:)). Create project:ArtistDetailViewgains a+toolbar item presentingCreateProjectSheet(artistId:, artistName:)— Name field + segmented Type picker (Single / EP / LP);POST /projectwith{ name, artistSpace, type }. Threads the same$pathbinding (now propagated throughArtistRoute's destination —ArtistDetailViewaccepts@Binding var path: NavigationPath) so on success wepath.append(ProjectRoute(id, name))straight into the new project. ⚠️ The backend auto-creates a default empty"Track 01"(name: "",versions: 0) for every new project, soProjectTracksView'sTrackRownow renders"Untitled track"as a display fallback whentrack.nameis empty (no server rename — just the UI). Edit project / artist: overflowMenu(ellipsis) added as a second.topBarTrailingtoolbar item — inProjectTracksView"Edit project" fetches a freshGET /project/:idand presentsEditProjectSheetseeded from those values (name + type),PUT /project/:idwith an omit-nil partial; inArtistDetailView"Rename artist" presentsEditArtistSheet,PUT /artist/:idpartial. Same manualencode(to:)pattern asTrackCRUDRequestsso absent fields aren't sent asnull(the backendObject.assign-merges updates, and we don't want to clobber unrelated fields). After a successful project rename, the parent view's local title is updated so the header reflects the change without a hard reload. Delete project / artist: destructiveconfirmationDialog("This soft-deletes the project (and its tracks). It can be restored from the web app's Trash." / equivalent for artist) →DELETE /project/:idorDELETE /artist/:id(soft-delete; web Trash handles restore — no iOS restore UI this slice). Reuses the slice-2didDelete→.onChange→dismiss()pattern so the navigation stack pops back correctly. New files:Core/Models/ProjectArtistCRUDRequests.swift(CreateProjectRequest+UpdateProjectRequest+CreateArtistRequest+UpdateArtistRequest, all with manualencode(to:)omit-nil);Features/Browse/CreateArtistSheet.swift(hosts bothCreateArtistSheetandEditArtistSheet);Features/Browse/CreateProjectSheet.swift(hosts bothCreateProjectSheetandEditProjectSheet, plus theProjectTypeenum:Single/EP/LP);Features/Browse/CRUDSheetComponents.swift(sharedSheetTextFieldpill +SheetSegmentedFieldso the four sheets stay visually consistent without bleeding sheet-specific chrome intoDesignSystem/). Touched:ArtistsHomeView(new+toolbar item +paththreaded intoArtistRoutedestination);ArtistDetailView(now takes@Binding var path, gains+and ellipsis menu, hosts create-project / rename / delete + soft-delete view-model state);ProjectTracksView(gains ellipsis menu beside the existing+, edit + delete view-model state, the empty-nameTrackRowfallback, local project-name shadow so post-rename header updates).APIEmptyResponsereused for thePUT/DELETEresponses;APIClient.Methodalready had.put+.delete. Build green;XcodeRefreshCodeIssuesInFileclean on every touched file. Out of scope per the brief: cover-image / artwork upload (slice 5), Track CRUD changes (slice 2 shipped), comment edit / delete / resolve (slice 4), collaborators editing, sharing / badges / lenses, Portals-on-Artist/Project (doc 26 envision-only), restore / trash UI. - iOS — Track CRUD (create / rename / edit metadata / delete) (slice 2 of
25-IOS-CRUD-FIRST-RELEASE.md). A user can now make and manage tracks on the phone — JSON-only, no audio upload yet (slice 5). Create:ProjectTracksViewgains a+toolbar button (and aPrimaryButtonin the empty state) presentingCreateTrackSheet— minimal glass-bg sheet with Name (required) / Label / Genre fields composed fromDesignSystem/(no stockForm).POST /trackis sent with{ name, project, label?, genre? }; on success the project list reloads and the parentNavigationStackpushes straight into the new track'sPlayerView(trackId:)via a sharedNavigationPath(ArtistsHomeViewnow owns@State path = NavigationPath()and threads$pathdown through theProjectRoutedestination). Rename / edit metadata: the TrackPortal(TrackPortalContent) gains an Edit affordance — the existing read-only metadata block flips four rows (Name, Label, Genre, Release date) intoTextFields + a toggleableDatePicker, with a glass Save / Cancel pair in the header. On Save,PUT /track/:idis sent as a partial body (only changed fields — bothCreateTrackRequestandUpdateTrackRequestimplement a manualencode(to:)sonilis omitted, not emitted asnull); the player refetches the populated detail to refresh the Portal (fetchDetailnow preservescurrentIndexon refresh — only the first fetch picks the default version, so editing metadata doesn't jump the player back to "final"). Delete: a destructive button at the bottom of the Portal triggers aconfirmationDialog("This soft-deletes the track. It can be restored from the web app's Trash."), callsDELETE /track/:id, setsviewModel.didDelete = true;PlayerViewobserves that via.onChangeand dismisses, popping back to the project. Empty-version player state: freshly-created tracks (audio: []) render a graceful "No versions yet".snCardwith aPrimaryButtonopening the Portal — the bottom glass stack (PlayerBar / ApproveBar / CommentComposer) is suppressed so an audio-less track doesn't pretend to be playable. New:Core/Models/TrackCRUDRequests.swift(CreateTrackRequest+UpdateTrackRequest);Core/Models/APIEmptyResponse.swift(permissive no-opDecodablefor endpoints whose response body we don't need to read);Features/Browse/CreateTrackSheet.swift.APIClient.Methodgains.put.TrackDetailextended with permissivelabel/genre/releaseDate(ISO8601 String, parsed viareleaseDateValue: Date?). Build green;XcodeRefreshCodeIssuesInFileclean on every touched file. Out of scope per the brief: audio upload, Project / Artist CRUD, comment edit / delete / resolve, sharing badges or lenses, restore / trash UI. - Docs — identity / sharing / navigation envisioning (
26-IDENTITY-SHARING-NAVIGATION.md). Synthesizes the owner's vision (Workspace that holds one-or-many Rosters; outbound sharing; recipient-arrangeable inbound shares with label/dot/bubble/color/team-avatar identity down to the version; Portals on every element; and many ways to reach a track-version — hierarchy, role, internal/external, share-direction, #tags, custom views) against the real actors (doc 12) and the real backend. Backbone principle: "one graph, many lenses" — every navigation axis is a saved filter over one object graph, with the invariant lenses filter, roles gate. Documents the proposedWorkspace → Roster(s) → ArtistSpace → …container model (Roster collapses to a single default for solo users), the access-lens table mapped to actors, the two-part sharing identity (sharer-side fixed vs recipient-side arrangeable), Portals-as-universal-inspector, and a reuse-vs- net-new backend map (no Workspace/Roster/Team/Tag/internal-external concepts exist today;SharedResource= Space/Project/Track only). Ends with 3 decisions to confirm (Roster weight · scope/sequencing · version sharing granularity). Nothing built yet — CRUD slices 2–5 (doc 25) proceed independently.00-ROADMAP.mddoc map + doc 25 open-question updated to point here. Decisions confirmed 2026-06-07: (1) Roster = a label/color on ArtistSpaces, not a new container (no hierarchy change); (2) envision-only — build CRUD slices 2–5 next, sharing/lens system after; (3) true per-version sharing —SharedResourceenum +linkextend to File granularity (built with the sharing layer, post-CRUD).next-brief.mdadvanced to slice 2 (Track CRUD). - iOS — collapsed the version page → straight-to-player + inline version switcher (slice 1 of
25-IOS-CRUD-FIRST-RELEASE.md). Tapping a track now opensPlayerViewdirectly from every entry point (Recents, Browse → Artist → Project → Tracks).PlayerViewgains a second initializerPlayerView(trackId: String)that fetchesGET /track/:iditself (logic previously inTrackDetailViewModel) and defaults the active version to the final (audioFinalid match, thenisFinalVersion == true) else the last entry inaudio. The historicPlayerView(detail:startIndex:)init is kept working internally; on-brand loading/error states (ProgressViewtinted.snForeground;exclamationmark.triangleblock) cover the fetch. Inline version switcher: the "V# {name}" line in the player is now aButtonwith a trailingchevron.downthat opens a glass sheet (.presentationDetents([.medium, .large]),.thinMaterial, drag indicator + corner radius matchingPortal) listing all versions asVersionCards — selecting one calls the existingselectVersion(at:)so audio / waveform / comments / sections reload exactly as the old rail did. The version-card rail insidePlayerViewis gone. The Portals button onPlayerBarnow re-presentsTrackPortalContentfor the player's loadeddetail(no behavior change). Retired:Features/Review/TrackDetailView.swiftdeleted (nothing references it;TrackPortalContent/VersionCard/ArtworkHerokept — reused).navigationDestination(for: String.self)updated inArtistsHomeView+RecentsViewto mountPlayerView(trackId:). UX-backlog "version switching via the track-name line" item struck. Build green;XcodeRefreshCodeIssuesInFileclean on every touched file. - iOS — Shared tab pulled (re-scope, not a feature). Per
25-IOS-CRUD-FIRST-RELEASE.md, the inbound Shared surface is parked pending a Roster / workspaces / actors decision — keeping it in the tab bar implied a feature commitment that hasn't been made.RootTabsViewis back to two tabs (Home / Recents). DeletedFeatures/Shared/SharedView.swift, the now-emptyFeatures/Shared/group, andCore/Models/SharedItems.swift(theSharedProject/SharedTrackwrappers had no other consumers). Backend/collaboration/*endpoints untouched — only the iOS surface is pulled, so re-introducing later is a view-layer add. Build green. - Docs — first-release scope upgrade + Shared course-correction (
25-IOS-CRUD-FIRST-RELEASE.md). Review feedback established that a review-only app isn't a shippable first release — first release now targets the create → review → approve loop. Verified against the controllers that the backend already exposes full CRUD for the whole hierarchy (Artist/Project/Track/Comment/Section create-update-delete + restore; the only gap is uploading a new audio version from the device). New doc 25 scopes + sequences the iOS CRUD work (slices: 1 inline version-switch → 2 Track CRUD → 3 Project/Artist CRUD → 4 comment edit/delete/resolve → 5 staged audio upload). Two course-corrections recorded: (a) Shared pulled — the inbound Shared tab was premature ("Shared" depends on the undecided Roster/workspaces/actors model); it's parked pending an actors-grounded decision (doc 23 Sharing section + doc 24 marked parked). (b) Version page removed — the version-card rail between Track and the player is redundant; version switching moves into the player.00-ROADMAP.md§5 + doc map updated. Also adopted a file-based agent handoff (.agent/next-brief.md) so slice briefs live on disk, not in chat — seeded with slice 1 (collapse version page + pull Shared tab). - iOS — Shared (inbound) screen. Build-order item 4 from
23-IOS-PHASE2-IA.md— the "Shared with me" surface. NewFeatures/Shared/SharedView.swift: fans out three inbound endpoints in parallel (GET /collaboration/collaborator/{spaces,projects,tracks}), renders sectioned artwork-forward groups — circularArtworkHerotiles for Artists, 2-colProjectCardgrid for Projects,LazyVStackofTrackRows for Tracks. Per-feed failures tolerated independently; a 401 from any source signs out. Taps reuse the Phase 1 / Phase 2 destinations (ArtistRoute → ArtistDetailView,ProjectRoute → ProjectTracksView,String → TrackDetailView) — no Phase 1 changes. On-brand empty state ("Nothing shared with you yet" +tray.and.arrow.down). New Codable wrappers inCore/Models/SharedItems.swift:SharedProject({ project: ProjectSummary, role: String? }) andSharedTrack({ track: Track, role: String? }); spaces decode straight into the existingArtistSpaceCodable.RootTabsViewis now three tabs — Home / Shared / Recents. Outbound ("Shared by me") deliberately out of scope (waits on the/collaboration/shared-by-mebackend aggregate). Build green; touched files lint-clean. - iOS — Player polish pass (three
[quick]UX-backlog items). Targeted edits inPlayerBar,PlayerView,VersionCard— no data-flow or Phase 2 changes. (1) Portals icon swapped toImage(systemName: "rectangle.righthalf.inset.filled")to match the web's side-panel affordance. (2) Play-over-cover — the circular cover thumbnail inPlayerBaris now the play/pause control: artworkButtonwraps aZStack { LinearGradient circle → .ultraThinMaterial disc → play.fill/pause.fill glyph in snForeground }with a small optical-centering offset for the play triangle, so the glyph reads on any cover while keeping the artwork the focal element. The separate play/pause button is removed. (3) "V#" bubble —currentVersionLabelinPlayerViewnow renders the version number in aCapsule().fill(accent.opacity(0.18))with accent-color text, separated from the filename (display regular).VersionCardmirrors the same pattern beneath the accent underline. Items struck in24-UX-BACKLOG.md. Build green; touched files lint-clean. 24-UX-BACKLOG.md— running list of iOS UX/polish refinements (tagged[quick]/[P2]/[P3]) so review feedback isn't lost in chat: play-button-over-cover, Portals icon (rectangle.righthalf.inset.filled), "V#" bubble, version-switch-via-name, cover selection at Portal level, pinned/PCM waveform, clearer Approve affordance. Naming locked: future grouping above ArtistSpace = "Roster" (not "Label"); doc 23 updated.- iOS — Phase 2 browse hierarchy (Artists → Projects → Tracks).
RootTabsViewmakesArtistsHomeViewthe primary signed-in surface and demotes Recents to a sibling tab — matches the23-IOS-PHASE2-IA.mdIA. NewFeatures/Browse/directory:ArtistsHomeView(GET /artist→ 2-colLazyVGridof circularArtworkHerotiles + display name, profile menu in toolbar, ownsnavigationDestinationforArtistRoute+ProjectRoute+StringtrackId so the whole chain reuses one stack),ArtistDetailView(parallelGET /artist/:id+GET /project/artist/:artistId; circular hero + display name + "Artist" subtitle + 2-colProjectCardgrid — matchesmobile-front--artis-projects.png),ProjectTracksView(GET /track/project/:projectId→LazyVStackof existingTrackRow, taps pushString(trackId) so Phase 1TrackDetailView/PlayerViewchain runs unchanged). New Codable models:ArtistSpace+ProjectSummary(bothnonisolated, permissive — onlyid+namerequired,nonEmptyImagehelper for backend's""defaults).ContentViewnow routes signed-in users toRootTabsViewinstead of straight toRecentsView. Phase 1 player/comment/approve surfaces deliberately not touched. Build green; touched files lint-clean. 23-IOS-PHASE2-IA.md— Phase 2 spec: the Artist→Project→Track artwork-forward browse hierarchy (replacing the flat Recents feed), shared inbound/outbound, and Portals on Artist/Project. Grounded in real endpoints (GET /artist,/project/artist/:id,/track/project/:id,/collaboration/collaborator/*); flags that "Label" is not a backend entity and the "shared-by-me" aggregate is a backend gap. Phase 1 review loop stays unchanged.- iOS — Waveform tap-to-seek + defensive fileId guard.
Waveformgains anonSeek: ((Double) -> Void)?callback (normalized 0…1 position) wired toviewModel.seek(to: fraction * duration)so tapping anywhere on the waveform jumps the player to that region — a coarse companion to the precisionSliderunderneath. Implemented via.contentShape(Rectangle())+.onTapGesture(coordinateSpace: .local) { CGPoint };CommentMarkerbuttons retain tap priority within their hit target so dot taps still go toonTapComment. Separately,PlayerViewModel.loadCommentsAndSections()andpostComment()now early-return whencurrentFile.idis empty or"0"— defensive while that origin is still being chased on the backend (which now returns a clean 400 for these). - iOS — Comments-at-timestamp + ApproveBar slice. Player gains the full review loop. Waveform overlays per-comment 6pt accent dots at
timeStart/duration × width(tap →ScrollViewReader.scrollTo(commentId, anchor: .center)+ 1.8s accent ring on the matchingCommentCard) and per-section colored capsule strips along the bottom using eachTrackSection.color(newColor(hex:)parser tolerates malformed input).CommentCard(initials avatar in per-version accent, display name fromusername→fullNamefallback,sonnanceTimecodebadge for timestamped comments, resolved checkmark) andCommentComposer(current player position chip pre-fillstimeStart, glass-wrapped text field, async send) are new DesignSystem components.ApproveBar— three pills (Title · Version · Cover) coloured bySnStatus.from(backendReleaseStatus:); tap →confirmationDialogwith the four backend statuses;PATCH /track/:id/release-statusthen prefers the server-returnedreleaseStatusand falls back to a local mutation if absent.PlayerViewmounts the three glass widgets stacked via.safeAreaInset(edge: .bottom). New Codable models:TrackComment+CommentOwner+TrackSection+CreateCommentRequest(Core/Models/Comments.swift);ReleaseStatus+ReleaseDimension+TrackReleaseStatus+UpdateReleaseStatusRequest+ permissiveTrackReleaseStatusResponse(Core/Models/ReleaseStatus.swift).TrackDetail.releaseStatusswitched tovarso the ApproveBar can mutate without re-decoding the whole track.TrackComment.responsesdecoded tolerantly (backend doesn't populate at/comment/file/:id). Build green; touched files lint-clean. - iOS — Player slice (AVPlayer + focal glass
PlayerBar).PlayerViewpresented viaNavigationLinkfrom eachVersionCardinTrackDetailView(same stack, swipe-back,TrackDetail+ start index passed in — no re-fetch).PlayerViewModel(@MainActor @Observable) owns anAVPlayer, setsAVAudioSession.playbackso the silent switch doesn't mute, usesaddPeriodicTimeObserver(0.1s) for position,asset.load(.duration)(async) for total, suppresses observer writes while scrubbing. Composition matchesweb-front--wave-player.png: display title, interactiveVersionCardrail (tap to switch versions, selected gets a per-version-color ring), centered "V{n}. {name}" display line, big focal00:00 — 03:45timecode (sonnanceDisplay+.monospacedDigit()),Waveform(new — deterministic sine-bar placeholder, played portion in accent) +Sliderscrubber. The focal Liquid Glass surface per spec §5 —PlayerBar— sits in.safeAreaInset(edge: .bottom)with circular artwork, tail-truncated title block, mini-timecode (sonnanceTimecode), play/pause, and a Portals button that re-presents the metadata sheet.TrackPortalContentextracted toFeatures/Review/so the same Portal body powers bothTrackDetailViewandPlayerView. New DesignSystem components:PlayerBar,Waveform. Build green; touched files lint-clean. - iOS — DesignSystem/ (Swift) + screen retrofits. Native implementation of
22-IOS-DESIGN-SYSTEM.md— every product surface now composes from these. Theme (semanticColor.sn*via trait-adaptiveUIColor— no asset catalog needed, light/dark automatic) with status palette (SnStatus.{noStatus,preProduction,underReview,approved,notApproved}) and per-version palette (Theme.versionColor(for:), 8-color wrap). ThemeManager (@Observable,SnAppearance.{system,light,dark}, persisted toUserDefaults, applied at root via.preferredColorScheme). Typography helpers (.sonnanceDisplay,.sonnanceTitle,.sonnanceBody,.sonnanceCaption,.sonnanceTimecode). Tokens (SnSpacing,SnRadius). Surfaces:ScreenBackground(accent:),AdaptiveGradient(+ a workingAdaptiveColor.average(from:)helper for future image-driven halos),GlassSurface/.glassCard()/.snCard()— glass honors Reduce Transparency. Controls:PrimaryButton,GhostButton,AppearancePicker. Components:SectionHeader,StatusPill,TrackRow,ProjectCard,ArtworkHero(circle/rounded-square),VersionCard(vinyl-disc motif + version-color underline),MetadataRow,LabelCopyTable,Portal+PortalHeader(.sheetw/.presentationDetents([.medium, .large])+ glass bg + drag indicator). Screen retrofits:SignInWithAppleView(display SONNANCE wordmark + tagline + Apple button),RecentsView(sectioned feed w/TrackRows + on-brand empty/error states +AppearancePickerin toolbar menu),TrackDetailView(ArtworkHero+ adaptive gradient halo + display title + horizontalVersionCardrail + a "Portals" toolbar button presenting metadata + LabelCopyTable in aPortal). PPMonumentExtended-Black/Regular/Light .otf copied toSonnance/Sonnance/Resources/Fonts/and registered inInfo.plistUIAppFonts. Build green; touched screens lint-clean. - iOS design system foundation (docs + workflow).
22-IOS-DESIGN-SYSTEM.md— the iOS design source of truth: light/dark/system theming, semantic color tokens, per-version color palette, per-status pills, typography (PPMonumentExtended + Lexend), Liquid Glass usage, adaptive gradient, the Portal pattern (glass sheet w/ detents), and a component inventory — grounded inctrl-audio-front/docs/canva-refs/. Added.agent/workflows/ios-feature-slice.md(slice checklist + Definition of Done) and a prominent "build on the design system" rule inctrl-audio-ios/CLAUDE.md. (SwiftDesignSystem/+ screen retrofits follow in the Xcode agent.) - iOS — landing feed + Track Detail slice.
RecentsViewnow fans out toGET /track/recent-access,GET /track/shared, andGET /trackin parallel and renders three sectioned groups (Recent / Shared with me / Your tracks), de-duplicated in priority order so a new user's feed isn't empty. Per-endpoint failures are tolerated independently; a 401 from any source signs the user out. NewTrackDetailViewreachable viaNavigationLink(value: trackId)+navigationDestination(for: String.self)— callsGET /track/:id, listsaudio[]versions with a "Final" badge (driven byMediaFile.isFinalVersionoraudioFinalmatch). New Codable models:MediaFile(mirrors backendFile;blobUrlarrives pre-signed viaSignUrlsInterceptor) andTrackDetail(populated shape fromfindOneById, kept separate from list-rowTracksince list endpoints returnaudioas ObjectId strings). Static — no player yet. - iOS — walking skeleton (auth + Recents). First functional slice of the iOS app:
APIClient(URLSession actor, bearer auth, single-flight 401 →POST /user/refresh-token→ one-shot retry),KeychainStore(access + refresh tokens, after-first-unlock),AuthManager(@Observable, restores session viaGET /user/detailon launch),SignInWithAppleView(ASAuthorization→POST /user/auth/apple),RecentsView(GET /track/recent-access). Auth-gated root inContentView. Codable models:User,AuthResponse,RefreshResponse,Track(minimal — controller serializer is loose; expand as detail screens land). Project builds clean. - Backend + iOS — SIWA nonce verification. Backend
verifyAppleTokennow accepts the raw nonce from the client, SHA-256-hashes it, and passes it toappleSignin.verifyIdToken(replay-attack prevention).AppleLoginDtocarries an optionalnoncefield (backward-compatible — no nonce = check skipped). iOSSignInWithAppleViewgenerates a 32-byte random nonce per request, setsrequest.nonce = SHA256(rawNonce)viaCryptoKit, and forwards the raw nonce inAppleLoginRequest.AuthManager.signInWithAppleandAuthModels.AppleLoginRequestupdated accordingly. - Backend —
scripts/seed-demo-data.ts(fixed + hardened). Creates Demo Space → Seed Session → 3 tracks and uploads real audio frominfrastructure/testAssets/. Switched to Node 18+ globals (fetch/FormData/Blob) — fixesform_data_1.default is not a constructor(noesModuleInterop). Now idempotent and resumable (uploads audio only to tracks missing it); verified end-to-end. Run:npx ts-node scripts/seed-demo-data.ts yo@ff8.dev(backend running). 21-DATA-ARCHITECTURE-AND-IDENTITY.md— data source-of-record (server, not CloudKit), data-ownership ladder (hold-all → export → BYO storage → self-host), and the Apple/bundle-id scheme.- iOS app (
ctrl-audio-ios) — native SwiftUI project (iOS 26, Liquid Glass) created in Xcode + agent scaffolding (CLAUDE.md, README, mirrored.claudesettings), wired into the workspace; GitHub repo + git version control initialized. - Backend — APNs push —
POST /notification/register-device+ APNs sender (apns2) auto-fired from every notification (best-effort, no-op untilAPNS_*env set). - Backend — refresh tokens —
POST /user/refresh-token;refreshTokenadded to all auth responses (existingtokenunchanged, web-safe). - Backend — Sign in with Apple —
POST /user/auth/apple(native identity-token verify → app JWT) viaapple-signin-auth. - Backend — track approval workflow —
PATCH /track/:id/release-status(approve / request changes),TrackReleaseStatus.APPROVED,TRACK_RELEASE_STATUS_UPDATEnotification event. - AI-native workspace — root + per-repo
CLAUDE.md,AGENTS.md,00-ROADMAP.md(product source of truth),19-IOS-MVP-REVIEW-APPROVE.md,20-UI-SIMPLIFICATION.md; agent rooting policy + cross-agent coordination via this changelog. AZURE-RESOURCES.md— dedicated Azure resource inventoryCDN-FRONTDOOR.md— CDN/Front Door architecture documentationCHANGELOG.md— this file.agent/workflows/changelog.md— workflow rule for consistent changelog maintenanceTECHNICAL-DEBT.md— centralized technical debt catalogRBAC-DESIGN.md— team/permissions planning document
Known gaps (iOS design system)
- PPMonumentExtended .otf needs to be added to the Xcode target. Files live at
Sonnance/Sonnance/Resources/Fonts/but MCP can't mutate the resources build phase — drag theResourcesfolder into Xcode and tick the Sonnance target so the .otf bundle. Until then,Font.custom("PPMonumentExtended-Black")silently falls back to SF Pro. - Lexend .ttf is not bundled (not in workspace; web pulls from Google Fonts at runtime). Typography helpers reference
Lexend-Bold/Regular/Lightso they pick up automatically when the .ttf files are dropped intoResources/Fonts/and added to the target. Body/caption fall back to SF Pro until then.
Changed
- iOS — stripped Xcode template's SwiftData + CloudKit. Removed
Item.swift, theModelContainerplumbing inSonnanceApp.swift, the iCloud entitlement keys, and the SwiftData query inContentView.swift. The backend (MongoDB + Azure Blob) is the source of record; any future local persistence is cache-only (per21-DATA-ARCHITECTURE-AND-IDENTITY.md). The iCloud capability still needs to be toggled off in Xcode > Signing & Capabilities to prevent re-add. - Branding standardized to Sonnance (legacy aliases: Ctrl Audio, WAVIC).
- Platform strategy → iOS-first (native Swift/SwiftUI, Liquid Glass); web app to follow to parity. One app / one dashboard / role-driven content — per-user-type homes deferred to a post-MVP customization layer.
[2026-02-21] — Sidebar Frosted Glass & ArtistSpace Serialization
Fixed
- Sidebar frosted glass effect (Desktop): Moved
SidebarExpandableoutside the transformed<aside>(as a Fragment sibling) — CSS spec requiresposition:fixedelements to not be inside transformed ancestors forbackdrop-filterto work against the viewport. Addedclip-path:inset(0)to prevent WebKit blur edge bleeding. - Sidebar frosted glass effect (Mobile): Unified mobile drawer styling with same
bg-gray-0/90/dark:bg-gray-100/80background and explicitWebkitBackdropFilterinline styles to match Profile Menu appearance. - ArtistSpace serialization in project controller: Fixed
findAllandfindAllByArtistendpoints that called.toString()on potentially populatedartistSpaceobjects, which would return[object Object]and destroy artist data (name, image). Now uses the same defensivetypeof === 'object'check asfindById. scripts/migrate-s3url-to-bloburl.js— database migration script- Sentry integration token for error monitoring setup
Changed
- BREAKING: Renamed
s3Url→blobUrlacross backend (6 files) and frontend (26 files) — requires DB migration - Updated
API-DESIGN.mdwith shortId/slug URL identifier system - Updated
DATA-MODEL.mdwith shortId/slug fields andblobUrlrename - Reorganized infrastructure docs: numbered 01-14, replaced CI/CD-only README with proper index, fixed cross-references
- Moved
04-SCALABILITY.mdfrom backend to infrastructure as14-SCALABILITY.md - Removed
SENTRY_PERSONALTOKEN.md(secret — should not be in docs) - Updated
PLATFORM-VISION.mdto reflect current infrastructure state - Fixed frontend doc numbering (
10-FEATURES-ROADMAP.md→15-FEATURES-ROADMAP.md) - Fixed corrupted table in Features Roadmap
- Updated feature roadmap infrastructure checkboxes (Container Apps ✅, CDN ✅, Blob Storage ✅)
- Updated frontend and backend doc READMEs with product name TBD note
Removed
- Cosmos DB references from active planning (decision: stay with MongoDB Atlas)
[2026-02-15] — Console Cleanup, Error Handling & Skeleton Polish
Fixed
- Console.log cleanup: Removed ~30 debug console.logs from core navigation flow (dashboard, project, artist headers, wave-form, socket provider, url-helpers, upload modals, track columns)
- Image src errors: Fixed empty string passed to Next.js
<Image>in wave-header, sidebar, search columns/mobile-drawer; madeAvatarCard.srcoptional - WaveSurfer AbortError: Added proper AbortError suppression (unhandledrejection listener + .on('error') filter) to all three wave-form components (wave-form.tsx, flash/wave-form.tsx, project-wave.tsx)
- Nested
<button>hydration error: Fixed<PopoverButton>wrapping<ActionIcon>in new-track-columns.tsx by usingas="div"on PopoverButton - Uncontrolled-to-controlled warning: Suppressed benign Headless UI Select warning in desktop-player.tsx via recharts-console-error.ts filter
- next-auth debug warning: Disabled NextAuth debug mode in auth-options.ts
- Font preload warnings: Set
preload: falseon PPMonumentExtended local font (only used on specific pages) - Backend addRecentAccess 500: Added ObjectId validation, try-catch, and optional chaining in user.service.ts
Changed
- Loading skeletons enhanced for artist and project views with improved layout consistency
- Skeleton shimmer effect unified across components
MainComponentlayout simplified
[2026-02-06] — Production Bug Fix
Fixed
- Resolved 500 errors on project track access caused by BSONError in identifier resolution
TrackControllerandTrackServicenow correctly handle shortId/slug identifiers
[2026-02-04] — Performance & CDN
Added
- Azure Front Door CDN for image and audio delivery (
sonnance-cdnprofile) setup-cdn.shinfrastructure scriptAzureImagefrontend component for CDN-aware image loading- ReactQuery caching strategy (
staleTime: 5min,gcTime: 30min)
Changed
- Image processing pipeline: Sharp generates thumb/medium/original WebP variants
- Backend
CDN_URLenv var set on prod and dev Container Apps
[2026-01-27] — URL Identifier System
Added
- ShortId system using nanoid (6-char alphanumeric codes)
- Slug support for premium/user-customized URLs
findByIdentifier()resolution: slug → shortId → ObjectId- Database migration script for existing entities
[2026-01-XX] — Initial Deployment
Added
- Azure Container Apps backend (prod + dev)
- Azure Static Web Apps frontend (prod + dev)
- Azure Container Registry (
sonnanceacr) - GitHub Actions CI/CD workflows (
deploy.yml,mirror-to-azure.yml) - MongoDB Atlas integration
- Google OAuth authentication
- Stripe payment integration (test mode)