Skip to content

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 the DesignSystem/ components defined here — never raw List/VStack chrome 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 in ctrl-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 tokenLightDarkUsage
background#f9fafb#08090ePage canvas
surface#ffffff#171717Cards, sheets, rows
surfaceElevated#f0f0f0#383d3f (80%)Hover/elevated cards
foreground#111111#f1f1f1Primary text / headings
foregroundSecondary#484848#dfdfdfBody / secondary text
border#e3e3e3rgba(72,72,72,.2)Hairlines
primary#111111#f1f1f1Primary actions
secondary (accent)#4e36f5#4e36f5Brand 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:

StatusColor
No Statusgray
Pre Production / In progressamber
Under reviewpurple (secondary)
Approvedgreen (#11a849)

Version colorseach 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-2xl feel); 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 (pinProgress in PlayerView).
  • 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 ?? identityAccent paints 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 each ArtistTile's ArtworkHero.shadow in its own tileAccent (vibrant). On detail screens these are the same color (screen haloAccent = 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 = nil and only fill it from the async .task, even though the color is already in AdaptiveColor.colorCache. Fix: carry the cover URL through the navigation route (routes already carry image) and seed artworkAccent synchronously from AdaptiveColor.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 .task sync-check is NOT enough): the synchronous cachedColor read must live in the computed haloAccent property (evaluated on the first frame), not inside .task (which runs after the first frame) — and the screen must never set its accent to nil on 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 (TrackDetail has no project image; serializeTrack collapses project to an id), so pass the project cover URLs into the player from ProjectTracksView (which has them); a backend option to embed the project cover in GET /track/:id is the robust universal fix (any entry point) — flagged, later.


7. Component inventory (Sonnance/Sonnance/DesignSystem/)

ComponentPurposeRef
ScreenBackgroundDark/light canvas + optional AdaptiveGradientall mobile refs
ArtworkHeroLarge rounded artwork (circle=artist, rounded-square=project/track) + gradientmobile refs
SectionHeaderBold label + optional accent pill ("Projects ▪")mobile artist/projects
ProjectCardArtwork tile + title (display) + type badge + status dot + ⋯mobile artist/projects
TrackRowPill card: circular thumb + bold title + "N versions" + StatusPill + handlemobile project/tracks
StatusPillPer-status colored pill (§2)portals refs
VersionCardVinyl-disc motif + comment count + star/lock + version colorportals refs
PortalGlass sheet (detents) for contextual detail — see §8portals-ref-1/2
MetadataRowIcon + label + value (Team/Status/Project/Type/Versions)portals refs
LabelCopyTableRelease-metadata rows with status pillsportals refs
PlayerBarFloating glass transport (artwork, title marquee, controls, waveform toggle, share)wave-player
PrimaryButton / GhostButtonPrimary (filled) / text actionsdoc 12 §4.2
AppearancePickerLight/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 inScreenBackground + display wordmark "SONNANCE" + tagline + system Apple button (branded surround).
  • Landing feed (RecentsView) — ScreenBackground, SectionHeaders (Recent / Shared / Yours), artwork-forward TrackRows. (See IA note.)
  • Track detailArtworkHero + adaptive gradient + display title + VersionCards + StatusPill; Label Copy / metadata via a Portal.
  • PlayerAVPlayer on MediaFile.blobUrl, waveform + scrub + version selector, the floating glass PlayerBar; 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

Ctrl-Audio Platform Documentation