feat: Update text color to improve readability across multiple authentication views
This commit is contained in:
81
Rosetta/DesignSystem/Components/GlassModifier.swift
Normal file
81
Rosetta/DesignSystem/Components/GlassModifier.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Glass Modifier (5-layer glass that works on black)
|
||||
//
|
||||
// Layer stack:
|
||||
// 1. .ultraThinMaterial — system blur
|
||||
// 2. black.opacity(0.22) — dark tint (depth on dark mode)
|
||||
// 3. white→clear gradient — highlight / light refraction, blendMode(.screen)
|
||||
// 4. double stroke — outer weak + inner stronger = glass edge
|
||||
// 5. shadow — depth
|
||||
|
||||
struct GlassModifier: ViewModifier {
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
|
||||
if #available(iOS 26, *) {
|
||||
content
|
||||
.background {
|
||||
shape.fill(.clear)
|
||||
.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius))
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.background {
|
||||
ZStack {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
shape.fill(Color.black.opacity(0.22))
|
||||
shape.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
shape.stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
shape.stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View Extension
|
||||
|
||||
extension View {
|
||||
/// 5-layer frosted glass background.
|
||||
func glass(cornerRadius: CGFloat = 24) -> some View {
|
||||
modifier(GlassModifier(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
/// Glass capsule — convenience for pill-shaped elements.
|
||||
@ViewBuilder
|
||||
func glassCapsule() -> some View {
|
||||
if #available(iOS 26, *) {
|
||||
background {
|
||||
Capsule().fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
}
|
||||
} else {
|
||||
background {
|
||||
ZStack {
|
||||
Capsule().fill(.ultraThinMaterial)
|
||||
Capsule().fill(Color.black.opacity(0.22))
|
||||
Capsule().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
Capsule().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,24 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - Glass Effect ID Modifier (iOS 26+)
|
||||
|
||||
private struct GlassEffectIDModifier: ViewModifier {
|
||||
let id: String
|
||||
let namespace: Namespace.ID?
|
||||
|
||||
nonisolated func body(content: Content) -> some View {
|
||||
if #available(iOS 26, *), let namespace {
|
||||
content.glassEffectID(id, in: namespace)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab
|
||||
|
||||
enum RosettaTab: CaseIterable {
|
||||
enum RosettaTab: CaseIterable, Sendable {
|
||||
case chats
|
||||
case settings
|
||||
case search
|
||||
@@ -41,26 +56,33 @@ struct TabBadge {
|
||||
}
|
||||
|
||||
// MARK: - RosettaTabBar
|
||||
/// Figma spec:
|
||||
/// Container: padding(25h, 16t, 25b), gap=8
|
||||
/// Main pill: 282x62, r=296, padding(4h, 3v), glass+shadow
|
||||
/// Each tab: 99x56, icon 30pt, label 10pt
|
||||
/// Selected: #EDEDED rect r=100, icon+label #008BFF, label bold
|
||||
/// Unselected: icon+label #404040
|
||||
/// Search pill: 62x62, glass+shadow, icon 17pt #404040
|
||||
|
||||
struct RosettaTabBar: View {
|
||||
let selectedTab: RosettaTab
|
||||
var onTabSelected: ((RosettaTab) -> Void)?
|
||||
var badges: [TabBadge] = []
|
||||
|
||||
@Namespace private var glassNS
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 26, *) {
|
||||
GlassEffectContainer(spacing: 8) {
|
||||
tabBarContent
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 4)
|
||||
} else {
|
||||
tabBarContent
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var tabBarContent: some View {
|
||||
HStack(spacing: 8) {
|
||||
mainTabsPill
|
||||
searchPill
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,145 +90,314 @@ struct RosettaTabBar: View {
|
||||
|
||||
private extension RosettaTabBar {
|
||||
var mainTabsPill: some View {
|
||||
// Content on top — NOT clipped (lens can pop out)
|
||||
HStack(spacing: 0) {
|
||||
ForEach(RosettaTab.allCases.filter { $0 != .search }, id: \.self) { tab in
|
||||
tabItem(tab)
|
||||
TabItemButton(
|
||||
tab: tab,
|
||||
isSelected: tab == selectedTab,
|
||||
badgeText: badges.first(where: { $0.tab == tab })?.text,
|
||||
onTap: { onTabSelected?(tab) },
|
||||
glassNamespace: glassNS
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 3)
|
||||
.padding(.bottom, 3)
|
||||
.frame(height: 62)
|
||||
.applyGlassPill()
|
||||
// Background clipped separately — content stays unclipped
|
||||
.background {
|
||||
mainPillGlass
|
||||
}
|
||||
}
|
||||
|
||||
func tabItem(_ tab: RosettaTab) -> some View {
|
||||
let isSelected = tab == selectedTab
|
||||
let badgeText = badges.first(where: { $0.tab == tab })?.text
|
||||
|
||||
return Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
onTabSelected?(tab)
|
||||
@ViewBuilder
|
||||
var mainPillGlass: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
ZStack {
|
||||
// 1. Material
|
||||
Capsule().fill(.ultraThinMaterial)
|
||||
// 2. Dark tint
|
||||
Capsule().fill(Color.black.opacity(0.22))
|
||||
// 3. Highlight
|
||||
Capsule().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
// 4a. Outer stroke
|
||||
Capsule().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
// 4b. Inner stroke
|
||||
Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
}
|
||||
} label: {
|
||||
// 5. Shadows
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Item Button
|
||||
|
||||
private struct TabItemButton: View {
|
||||
let tab: RosettaTab
|
||||
let isSelected: Bool
|
||||
let badgeText: String?
|
||||
let onTap: () -> Void
|
||||
var glassNamespace: Namespace.ID?
|
||||
|
||||
@State private var pressed = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(spacing: 1) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: isSelected ? tab.selectedIcon : tab.icon)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93)))
|
||||
.foregroundStyle(tabColor)
|
||||
.frame(height: 30)
|
||||
|
||||
if let badgeText {
|
||||
badgeView(badgeText)
|
||||
Text(badgeText)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, badgeText.count > 2 ? 4 : 0)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.background(Capsule().fill(RosettaColors.error))
|
||||
.offset(x: 10, y: -4)
|
||||
}
|
||||
}
|
||||
|
||||
Text(tab.label)
|
||||
.font(.system(size: 10, weight: isSelected ? .bold : .medium))
|
||||
.foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93)))
|
||||
.foregroundStyle(tabColor)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 7)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if isSelected {
|
||||
if isSelected && !pressed {
|
||||
RoundedRectangle(cornerRadius: 100)
|
||||
.fill(RosettaColors.adaptive(
|
||||
light: Color(hex: 0xEDEDED),
|
||||
dark: Color.white.opacity(0.12)
|
||||
))
|
||||
.padding(.horizontal, -8)
|
||||
.padding(.vertical, -6)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// Lens: padding → glass bubble → scale → lift
|
||||
.padding(14)
|
||||
.background {
|
||||
if pressed {
|
||||
lensBubble
|
||||
.transition(.scale(scale: 0.8).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.scaleEffect(pressed ? 1.12 : 1)
|
||||
.offset(y: pressed ? -28 : 0)
|
||||
.shadow(color: .black.opacity(pressed ? 0.45 : 0), radius: 22, y: 14)
|
||||
.shadow(color: Color.cyan.opacity(pressed ? 0.12 : 0), radius: 20, y: 0)
|
||||
.animation(.spring(response: 0.34, dampingFraction: 0.65), value: pressed)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
if !pressed {
|
||||
pressed = true
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
}
|
||||
.onEnded { _ in pressed = false }
|
||||
)
|
||||
.modifier(GlassEffectIDModifier(id: "\(tab)", namespace: glassNamespace))
|
||||
.accessibilityLabel(tab.label)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
|
||||
private var tabColor: Color {
|
||||
isSelected
|
||||
? RosettaColors.primaryBlue
|
||||
: RosettaColors.adaptive(
|
||||
light: Color(hex: 0x404040),
|
||||
dark: Color(hex: 0x8E8E93)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Lens Bubble
|
||||
|
||||
@ViewBuilder
|
||||
private var lensBubble: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Circle()
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular.interactive(), in: .circle)
|
||||
} else {
|
||||
ZStack {
|
||||
// 1. Material
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
// 2. Dark tint
|
||||
Circle().fill(Color.black.opacity(0.22))
|
||||
// 3. Highlight (top→bottom, screen blend)
|
||||
Circle().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
).blendMode(.screen)
|
||||
// 4a. Outer stroke
|
||||
Circle().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
// 4b. Inner stroke
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
// 6. Rainbow (thin, subtle, screen blend)
|
||||
Circle().stroke(
|
||||
AngularGradient(
|
||||
colors: [
|
||||
Color.cyan.opacity(0.55),
|
||||
Color.blue.opacity(0.55),
|
||||
Color.purple.opacity(0.55),
|
||||
Color.pink.opacity(0.55),
|
||||
Color.orange.opacity(0.55),
|
||||
Color.yellow.opacity(0.45),
|
||||
Color.cyan.opacity(0.55),
|
||||
],
|
||||
center: .center
|
||||
),
|
||||
lineWidth: 1.4
|
||||
).blendMode(.screen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search Pill
|
||||
|
||||
private extension RosettaTabBar {
|
||||
var searchPill: some View {
|
||||
let isSelected = selectedTab == .search
|
||||
SearchPillButton(
|
||||
isSelected: selectedTab == .search,
|
||||
onTap: { onTabSelected?(.search) },
|
||||
glassNamespace: glassNS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
onTabSelected?(.search)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isSelected ? "magnifyingglass" : "magnifyingglass")
|
||||
private struct SearchPillButton: View {
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
var glassNamespace: Namespace.ID?
|
||||
|
||||
@State private var pressed = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93)))
|
||||
.frame(width: 54, height: 54)
|
||||
.background {
|
||||
if isSelected {
|
||||
Circle()
|
||||
.fill(RosettaColors.adaptive(
|
||||
light: Color(hex: 0xEDEDED),
|
||||
dark: Color.white.opacity(0.12)
|
||||
))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(
|
||||
isSelected
|
||||
? RosettaColors.primaryBlue
|
||||
: RosettaColors.adaptive(
|
||||
light: Color(hex: 0x404040),
|
||||
dark: Color(hex: 0x8E8E93)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(4)
|
||||
// Lens
|
||||
.padding(14)
|
||||
.background {
|
||||
if pressed {
|
||||
searchLensBubble
|
||||
.transition(.scale(scale: 0.8).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.scaleEffect(pressed ? 1.15 : 1)
|
||||
.offset(y: pressed ? -28 : 0)
|
||||
.shadow(color: .black.opacity(pressed ? 0.45 : 0), radius: 22, y: 14)
|
||||
.shadow(color: Color.cyan.opacity(pressed ? 0.12 : 0), radius: 20, y: 0)
|
||||
.animation(.spring(response: 0.34, dampingFraction: 0.65), value: pressed)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
if !pressed {
|
||||
pressed = true
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
}
|
||||
.onEnded { _ in pressed = false }
|
||||
)
|
||||
.frame(width: 62, height: 62)
|
||||
.applyGlassPill()
|
||||
// Background clipped separately
|
||||
.background { searchPillGlass }
|
||||
.modifier(GlassEffectIDModifier(id: "search", namespace: glassNamespace))
|
||||
.accessibilityLabel("Search")
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Glass Pill
|
||||
// MARK: Lens for search
|
||||
|
||||
private struct GlassPillModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
@ViewBuilder
|
||||
private var searchLensBubble: some View {
|
||||
if #available(iOS 26, *) {
|
||||
content
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
Circle()
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular.interactive(), in: .circle)
|
||||
} else {
|
||||
content
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(RosettaColors.adaptive(
|
||||
light: Color.white.opacity(0.65),
|
||||
dark: Color(hex: 0x2A2A2A).opacity(0.8)
|
||||
))
|
||||
.shadow(color: RosettaColors.adaptive(
|
||||
light: Color(hex: 0xDDDDDD).opacity(0.5),
|
||||
dark: Color.black.opacity(0.3)
|
||||
), radius: 16, y: 4)
|
||||
)
|
||||
ZStack {
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
Circle().fill(Color.black.opacity(0.22))
|
||||
Circle().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
).blendMode(.screen)
|
||||
Circle().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
Circle().stroke(
|
||||
AngularGradient(
|
||||
colors: [
|
||||
Color.cyan.opacity(0.55),
|
||||
Color.blue.opacity(0.55),
|
||||
Color.purple.opacity(0.55),
|
||||
Color.pink.opacity(0.55),
|
||||
Color.orange.opacity(0.55),
|
||||
Color.yellow.opacity(0.45),
|
||||
Color.cyan.opacity(0.55),
|
||||
],
|
||||
center: .center
|
||||
),
|
||||
lineWidth: 1.4
|
||||
).blendMode(.screen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func applyGlassPill() -> some View {
|
||||
modifier(GlassPillModifier())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension RosettaTabBar {
|
||||
func badgeView(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, text.count > 2 ? 4 : 0)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.background(Capsule().fill(RosettaColors.error))
|
||||
.offset(x: 10, y: -4)
|
||||
}
|
||||
|
||||
var safeAreaBottom: CGFloat {
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = scene.windows.first(where: \.isKeyWindow) else { return 0 }
|
||||
return window.safeAreaInsets.bottom
|
||||
@ViewBuilder
|
||||
private var searchPillGlass: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Circle().fill(.clear).glassEffect(.regular, in: .circle)
|
||||
} else {
|
||||
ZStack {
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
Circle().fill(Color.black.opacity(0.22))
|
||||
Circle().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
Circle().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,12 +405,12 @@ private extension RosettaTabBar {
|
||||
|
||||
#Preview {
|
||||
ZStack(alignment: .bottom) {
|
||||
RosettaColors.Adaptive.background.ignoresSafeArea()
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("Content here")
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
Text("Hold a tab to see the lens")
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
Spacer()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user