22 — iOS Design System (native SwiftUI · iOS 26 Liquid Glass)
Active spec. Governed by
00-ROADMAP.md. Last updated 2026-06-04. The iOS source of truth for visual design. Every Sonnance iOS screen composes from theDesignSystem/components defined here — never rawList/VStackchrome for product surfaces.
Source of truth & how to use this doc
- Visual targets: the images in
ctrl-audio-front/docs/canva-refs/—mobile-front--artis-projects.png,mobile-front--project-tracks.png,web-front--wave-player.png,web-front--home-dashboard-idea.png,web-front--portals-ref-1.png,web-front--portals-ref-2.png. Look at them before building a screen. - Identity origin: the web design system,
ctrl-audio-front/docs/12-UI-UX-DESIGN-SYSTEM.md, and the color tokens inctrl-audio-front/src/app/globals.css. This doc translates that identity to native iOS — it does not replace it. - Liquid Glass principles:
19-IOS-MVP-REVIEW-APPROVE.md§4.
1. Theming — light + dark + system-sync
Dark is the primary design surface (studio context, artwork pops), but all three modes ship. A ThemeManager (@Observable, persisted to UserDefaults) exposes an Appearance override — Light / Dark / System (default System) — applied at the root via .preferredColorScheme(...). All components read semantic tokens (never hard-coded hex) so both modes are automatic.
| Semantic token | Light | Dark | Usage |
|---|---|---|---|
background | #f9fafb | #08090e | Page canvas |
surface | #ffffff | #171717 | Cards, sheets, rows |
surfaceElevated | #f0f0f0 | #383d3f (80%) | Hover/elevated cards |
foreground | #111111 | #f1f1f1 | Primary text / headings |
foregroundSecondary | #484848 | #dfdfdf | Body / secondary text |
border | #e3e3e3 | rgba(72,72,72,.2) | Hairlines |
primary | #111111 | #f1f1f1 | Primary actions |
secondary (accent) | #4e36f5 | #4e36f5 | Brand accent (sparing) |
Implement as a color asset catalog (Any/Dark appearances) surfaced through Color extensions (Color.snBackground, .snSurface, .snForeground, .snAccent, …).
2. Status colors & per-version colors
Status (release/workflow) — consistent across rows, Portals, and the approve bar:
| Status | Color |
|---|---|
| No Status | gray |
| Pre Production / In progress | amber |
| Under review | purple (secondary) |
| Approved | green (#11a849) |
Version colors — each track version maps to a distinct color (the wave-player ref's multi-colored V2/V3/V4 pills). Expose Theme.versionColor(for index: Int) -> Color returning from a fixed, ordered palette (e.g. teal, amber, purple, pink, blue, green, orange, indigo — wrapping). Used on version pills/tabs, VersionCard, and waveform accents so a version is identifiable at a glance.
3. Typography
- PPMonumentExtended (licensed, embedded) — all-caps display for artist / project / track names and hero moments. Weights: Black/Regular/Light.
- Lexend (embedded) — section headings (Bold), body & labels (Regular), captions (Light).
- SF Pro (system, tabular) — timecodes/numerals in the player (
00:00 — 03:45).
Register both via the Xcode project Resources + Info.plist UIAppFonts. Surface as Font helpers: .sonnanceDisplay(_:), .sonnanceTitle, .sonnanceBody, .sonnanceCaption. Respect Dynamic Type. Scale (mobile): Display 34–44pt, Title 22–28, Body 15–17, Caption 12–13.
4. Spacing · Radius · Shadow · Motion
- Spacing: 4 / 8 / 16 / 24 / 32 (
SnSpacing.xs…xl). Card padding 16; section gaps 24. - Radius: row/control 16, card 20, sheet/Portal & FAB 28 (
rounded-2xlfeel); avatars/discs full. - Shadow: subtle on dark (rely on surface contrast + glass), soft elevation in light.
- Motion: spring, content-driven; 200–400ms UI, 500ms+ for hero. Comment pins animate onto the waveform at their timestamp. Honor Reduce Motion.
Motion & state transitions — a brand principle (owner-locked 2026-06-11)
The moment a state changes is a brand moment — it must feel inevitable, not triggered.
- Scroll-linked states interpolate with the gesture. When a state depends on scroll position (a header pinning, a halo appearing, a title migrating to the nav bar), its visual transition is driven by the scroll offset itself over a fade range (~48–64pt) — never a boolean toggle + animation at a threshold. The user's own motion is the animation; scrubbing back and forth through the transition point must feel continuous in both directions. Canonical example: the player's waveform halo + nav-title fade (
pinProgressinPlayerView). - Discrete state changes (data arriving, selection, mode flips) use short, quiet fades/springs (150–250ms) — no bounces on state, no attention-grabbing entrances for chrome.
- Apply this check to every new surface: "is there a visible instant where the UI flips?" If yes, either bind it to the gesture or soften it until the flip is imperceptible.
5. Liquid Glass
Use native iOS 26 materials — .glassEffect / glassBackgroundEffect, system toolbar/tab-bar glass. Do not fake with manual blurs. Glass is reserved for floating/overlay surfaces: the player bar (focal), the Portal sheet, toolbars, the FAB. Content (artwork, waveform) supplies the color the glass refracts. Reduce Transparency → degrade to solid surface. Verify both light & dark.
6. Adaptive gradient (personalization signature)
Behind hero artwork, paint a top-down gradient (the "halo") from the artwork's accent color → fading into background (the green halo behind SOBRENATURAL, the warm glow behind the artist). Implement AdaptiveGradient(artworkURL:) — downsample the UIImage, extract the color, animate in.
Halo must be intelligent, not averaged (owner-locked 2026-06-13). A plain mean/average of the pixels muddies to grey-brown and reads as a dead halo. Read the artwork's most vibrant / representative accent color instead: downsample to a tiny thumbnail, bucket pixels into clusters, drop near-grey / near-black / near-white, and pick the highest-chroma (most saturated) dominant cluster — or a tight mix of the top accent cluster — as the halo color. Cache per image (keyed by URL). This is the same instinct as the per-version accent (
Theme.versionColor): the halo should feel pulled from the art, inevitable, never a smudge. Cover-less nodes keep today's identity-accent gradient.
Halo settles to ONE color, no intro crossfade (owner-locked 2026-06-13). The halo must show one color — the picture's — from first paint. Today it flashes:
haloAccent = artworkAccent ?? identityAccentpaints the identity color first, then the async vibrant extraction swaps it in, and because the animation is keyed to the color (.animation(value: haloAccent)) that identity→art settle crossfades on every appearance. That's a "pop" — it violates the motion principle (§4: nothing pops; transitions interpolate with a real state change). Required behavior:
- Key the crossfade to the cover identity, not the color —
.animation(_, value: coverURL/fileId), so the transition fires only when the cover actually changes (e.g. a new upload), never on load or on the async settle of the same cover.- Cache the extracted accent per cover URL (static memo in
AdaptiveColor) so revisits are synchronous — no async settle, no flash.- Never paint identity in front of a real cover. While a cover's color is resolving (uncached), hold the previous/neutral halo and set the art color without animation on first resolve — don't fall back to identity. The identity-accent color is only the no-cover state; once a cover exists it no longer applies.
Applies to all halo hosts —
PlayerView,ArtistDetailView,ProjectTracksView, and the Artists/Roster home (ArtistsHomeView), which still has no vibrant halo.
One halo per element — never a screen halo competing with a tile halo (owner 2026-06-14, still open). On the home grid two glows still composite: the screen
ScreenBackground(accent: .snAccent)(identity, broad) and eachArtistTile'sArtworkHero.shadowin its owntileAccent(vibrant). On detail screens these are the same color (screenhaloAccent= the cover's vibrant), so they read as one; on home they're identity vs vibrant → a visible double halo around a single tile. Resolution: on a multi-item grid the per-tile glow is the only halo — the home screen background must be neutral/ambient (no strong second accent), not a competing identity glow. "Home has its own halo" means a calm brand wash, not a second colored ring around each artist.
No "moment-of-creation" flash when navigating — seed the accent synchronously (owner 2026-06-14, still open). When you navigate to a screen whose cover you already saw on the previous one, the destination briefly shows the identity color (
Theme.accent(forKey:), the record's creation-time accent) before the vibrant color resolves — because detail screens start@State artworkAccent = niland only fill it from the async.task, even though the color is already inAdaptiveColor.colorCache. Fix: carry the cover URL through the navigation route (routes already carryimage) and seedartworkAccentsynchronously fromAdaptiveColor.cachedColor(for:)at init / first computation, so a cache hit paints the vibrant halo on the first frame. Identity is only ever shown for a genuinely cover-less record — never as a transient before a known color.Precise mechanism (root-caused 2026-06-14 — a
.tasksync-check is NOT enough): the synchronouscachedColorread must live in the computedhaloAccentproperty (evaluated on the first frame), not inside.task(which runs after the first frame) — and the screen must never set its accent tonilon appear (blanking a cached color reintroduces the flash). Pattern:haloAccent = artworkAccent ?? cachedColor(thumbURL) ?? (hasCover ? nil : identity).
Cover (and halo) cascade for tracks: track → album → identity (owner 2026-06-14). A track with no cover of its own but whose project/album has art must show the album cover and its halo color, not the identity default — for both the artwork and the sampled accent. The player can't see the album today (
TrackDetailhas no project image;serializeTrackcollapsesprojectto an id), so pass the project cover URLs into the player fromProjectTracksView(which has them); a backend option to embed the project cover inGET /track/:idis the robust universal fix (any entry point) — flagged, later.
7. Component inventory (Sonnance/Sonnance/DesignSystem/)
| Component | Purpose | Ref |
|---|---|---|
ScreenBackground | Dark/light canvas + optional AdaptiveGradient | all mobile refs |
ArtworkHero | Large rounded artwork (circle=artist, rounded-square=project/track) + gradient | mobile refs |
SectionHeader | Bold label + optional accent pill ("Projects ▪") | mobile artist/projects |
ProjectCard | Artwork tile + title (display) + type badge + status dot + ⋯ | mobile artist/projects |
TrackRow | Pill card: circular thumb + bold title + "N versions" + StatusPill + handle | mobile project/tracks |
StatusPill | Per-status colored pill (§2) | portals refs |
VersionCard | Vinyl-disc motif + comment count + star/lock + version color | portals refs |
Portal | Glass sheet (detents) for contextual detail — see §8 | portals-ref-1/2 |
MetadataRow | Icon + label + value (Team/Status/Project/Type/Versions) | portals refs |
LabelCopyTable | Release-metadata rows with status pills | portals refs |
PlayerBar | Floating glass transport (artwork, title marquee, controls, waveform toggle, share) | wave-player |
PrimaryButton / GhostButton | Primary (filled) / text actions | doc 12 §4.2 |
AppearancePicker | Light/Dark/System control (profile) | doc 16 |
8. The Portal pattern
A Portal is slide-out contextual detail that overlays the current view (progressive disclosure) — the "PORTALS" affordance on a track row opens it. Per portals-ref-1/2 it contains:
- Header: title (display) +
StatusPill(e.g. "Pre Production") + Download/Share. - Metadata list:
MetadataRows — Team (collaborator avatars), Status, Project name, Type, Total versions. - Version cards: horizontal
VersionCards (disc motif, comment counts, version color), plus a "+" to add. - Label Copy:
LabelCopyTable— Track title / version / cover release (each with a status pill), Release date, Artist, Genre.
iOS adaptation: a .sheet with .presentationDetents([.medium, .large]) + .glassEffect background — not a separate pushed screen. The MVP TrackDetailView may present its richer detail as a Portal.
9. Screen intent (MVP)
- Sign in —
ScreenBackground+ display wordmark "SONNANCE" + tagline + system Apple button (branded surround). - Landing feed (
RecentsView) —ScreenBackground,SectionHeaders (Recent / Shared / Yours), artwork-forwardTrackRows. (See IA note.) - Track detail —
ArtworkHero+ adaptive gradient + display title +VersionCards +StatusPill; Label Copy / metadata via a Portal. - Player —
AVPlayeronMediaFile.blobUrl, waveform + scrub + version selector, the floating glassPlayerBar; spatial comment avatars; time in tabular SF.
10. IA note (flag, don't force)
The mobile refs depict artist → projects → tracks artwork-forward browsing, not a flat "Recent / Shared / Yours" list. The MVP keeps the review feed (fastest path to the approve loop) but adopts this visual language; the artist/project browse hierarchy is the natural next IA step (post-MVP, aligns with 20-UI-SIMPLIFICATION.md's one-dashboard model).
11. Cross-references
- Web identity & rationale:
12-UI-UX-DESIGN-SYSTEM.md - MVP flow & endpoints:
19-IOS-MVP-REVIEW-APPROVE.md - IA / simplification:
20-UI-SIMPLIFICATION.md - Data/identity:
21-DATA-ARCHITECTURE-AND-IDENTITY.md - Build workflow:
.agent/workflows/ios-feature-slice.md