Skip to content

Commit e4dfa52

Browse files
committed
Add shared CardMetrics system for reusable card styles
Centralise all card sizing tokens (corner radii, insets, spacing, shadows, avatars) into a single CardMetrics enum, inspired by Plozz's shared-metrics approach. Key changes: - New CardMetrics.swift: single source of truth for all card layout constants - Concentric radius pattern: outer = inner + cardInset (even glass border) - More generous spacing: cardSpacing 20→24, railVerticalPadding 20→24 - Grid cards: radii bumped 16→26 outer, 12→14 inner (more aggressive) - All existing card views (StreamChannelCard, CategoryCardView, MediaContentCard, ChannelPageView, HomeView) now derive from CardMetrics - ChannelRailLayout and AppLayout delegate to CardMetrics - Grid content shapes in Browse/Search/Following use shared metrics - Focus shadow parameters (radius, offset, opacity) centralised
1 parent 3d158fb commit e4dfa52

12 files changed

Lines changed: 159 additions & 47 deletions

.impeccable/hook.cache.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"version":1,"sessions":{}}

Twozz/Shared/CardMetrics.swift

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import CoreGraphics
2+
3+
/// Centralised card sizing tokens. Every card surface in the app derives its
4+
/// corner radii, internal padding, and spacing from here so changes propagate
5+
/// everywhere at once and the visual system stays coherent.
6+
///
7+
/// Inspired by Plozz's shared-metrics approach: a single source of truth that
8+
/// any card view can reference, with the concentric-radius pattern (outer glass
9+
/// radius = inner media radius + card inset) guaranteeing an even-width border
10+
/// at every size.
11+
enum CardMetrics {
12+
13+
// MARK: - Card Inset (glass edge → content artwork)
14+
15+
/// The visual gap between the outer glass surface and the inner content/media.
16+
/// Corner radii are concentric: outerRadius = innerRadius + cardInset, so the
17+
/// glass border renders at a constant width around the artwork.
18+
static let cardInset: CGFloat = 12
19+
20+
// MARK: - Corner Radii (inner media radii; outer derived concentrically)
21+
22+
/// Standard media/artwork corner radius used for rail cards, category art,
23+
/// content cards, and most surfaces.
24+
static let mediaCornerRadius: CGFloat = 18
25+
26+
/// Tighter media corner radius for compact grid cards.
27+
static let gridMediaCornerRadius: CGFloat = 14
28+
29+
/// Outer card corner radius: concentric with the standard media radius.
30+
static var cardCornerRadius: CGFloat { mediaCornerRadius + cardInset } // 30
31+
32+
/// Outer card corner radius for compact grid cards.
33+
static var gridCardCornerRadius: CGFloat { gridMediaCornerRadius + cardInset } // 26
34+
35+
/// Outer corner radius for hero/featured surfaces (slightly smaller than a
36+
/// full card, matching the larger single-item presentation).
37+
static let heroCornerRadius: CGFloat = 28
38+
39+
// MARK: - Focus Insets (breathing room for scale-up + internal padding)
40+
41+
/// Horizontal/vertical inset between the glass edge and content for standard
42+
/// cards. Doubles as the focus breathing room for the scale-up animation.
43+
static let focusInset: CGFloat = 18
44+
45+
/// Internal content padding for compact grid cards.
46+
static let gridContentInset: CGFloat = 14
47+
48+
/// Internal padding for category (box-art) cards.
49+
static let categoryInset: CGFloat = 10
50+
51+
// MARK: - Spacing Between Cards
52+
53+
/// Base horizontal gap between cards in rails and grids. Subtly tightens
54+
/// at higher card counts via ``spacingScale(forVisibleCardCount:)``.
55+
static let cardSpacing: CGFloat = 24
56+
57+
/// Vertical padding above/below a card rail row.
58+
static let railVerticalPadding: CGFloat = 24
59+
60+
/// Gap between the media thumbnail and the text/metadata below it.
61+
static let captionSpacing: CGFloat = 10
62+
63+
/// Gap between text lines within a card's caption area (e.g. title → game).
64+
static let captionLineSpacing: CGFloat = 4
65+
66+
/// Gap between the avatar and the text block in cards with a profile image.
67+
static let avatarTextSpacing: CGFloat = 10
68+
69+
/// Vertical gap between full rail sections on the Home tab.
70+
static let sectionSpacing: CGFloat = 72
71+
72+
// MARK: - Screen Layout
73+
74+
/// Horizontal page gutter (matches ``AppLayout.horizontalPadding``).
75+
static let screenPadding: CGFloat = 24
76+
77+
// MARK: - Focus & Animation
78+
79+
/// Uniform scale applied to a focused card.
80+
static let focusedScale: CGFloat = 1.07
81+
82+
/// Focus shadow opacity for dark/OLED themes.
83+
static let focusShadowOpacity: CGFloat = 0.36
84+
85+
/// Focus shadow opacity for light theme.
86+
static let focusShadowOpacityLight: CGFloat = 0.12
87+
88+
/// Focus shadow blur radius.
89+
static let focusShadowRadius: CGFloat = 20
90+
91+
/// Focus shadow vertical offset.
92+
static let focusShadowY: CGFloat = 10
93+
94+
// MARK: - Avatars
95+
96+
/// Channel avatar diameter in rail cards.
97+
static let railAvatarSize: CGFloat = 62
98+
99+
/// Channel avatar diameter in grid cards.
100+
static let gridAvatarSize: CGFloat = 68
101+
102+
// MARK: - Adaptive Spacing
103+
104+
/// Subtly tightens the gap between cards as more fit across the screen.
105+
/// Full base spacing at 2-across, easing to ~68 % at 6-across.
106+
static func spacingScale(forVisibleCardCount count: Int) -> CGFloat {
107+
let clamped = CGFloat(min(max(count, 2), 6))
108+
return 1.0 - (clamped - 2) * 0.08
109+
}
110+
}

Twozz/Shared/CategoryCardView.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ struct CategoryCardView: View {
1313

1414
/// Match the stream cards: aggressive outer glass rounding with the inner box
1515
/// art rounded to the same radius the stream card media uses.
16-
private let outerCornerRadius: CGFloat = 30
17-
private let artCornerRadius: CGFloat = 18
16+
private var outerCornerRadius: CGFloat { CardMetrics.cardCornerRadius }
17+
private var artCornerRadius: CGFloat { CardMetrics.mediaCornerRadius }
1818
private let artRatio: CGFloat = 285.0 / 380.0
1919

2020
var body: some View {
21-
VStack(alignment: .leading, spacing: 10) {
21+
VStack(alignment: .leading, spacing: CardMetrics.captionSpacing) {
2222
CachedAsyncImage(url: category.boxArtURL) { img in
2323
img.resizable().scaledToFill()
2424
} placeholder: {
@@ -27,7 +27,7 @@ struct CategoryCardView: View {
2727
.modifier(ArtSizing(width: width, artRatio: artRatio))
2828
.clipShape(RoundedRectangle(cornerRadius: artCornerRadius, style: .continuous))
2929

30-
VStack(alignment: .leading, spacing: 4) {
30+
VStack(alignment: .leading, spacing: CardMetrics.captionLineSpacing) {
3131
Text(category.name)
3232
.font(.subheadline.weight(.semibold))
3333
.foregroundStyle(usesLiftFocusedText ? palette.liftPrimaryText : Color.primary)
@@ -46,10 +46,10 @@ struct CategoryCardView: View {
4646
.hidden()
4747
}
4848
}
49-
.padding(.horizontal, 10)
50-
.padding(.bottom, 12)
49+
.padding(.horizontal, CardMetrics.categoryInset)
50+
.padding(.bottom, CardMetrics.cardInset)
5151
}
52-
.padding(10)
52+
.padding(CardMetrics.categoryInset)
5353
.modifier(CardSizing(width: width))
5454
.twozzLiquidGlassCard(
5555
cornerRadius: outerCornerRadius,
@@ -71,7 +71,7 @@ struct CategoryCardView: View {
7171

7272
/// The corner radius callers should use for the focus/hit-test content shape so
7373
/// it matches the card's outer rounding.
74-
static let contentShapeCornerRadius: CGFloat = 30
74+
static var contentShapeCornerRadius: CGFloat { CardMetrics.cardCornerRadius }
7575

7676
private var usesLiftFocusedText: Bool {
7777
twozzUsesLiftFocusedText(isFocused: isFocused, glassDisabled: glassDisabled)

Twozz/Shared/ChannelRailMetrics.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,20 @@ enum ChannelRailLayout {
2222
/// rail scrolls.
2323
static let peekCardFraction: CGFloat = 0.08
2424
/// Breathing room reserved on each side of a card for its focus halo/scale.
25-
static let focusHorizontalInset: CGFloat = 18
25+
static var focusHorizontalInset: CGFloat { CardMetrics.focusInset }
2626
static let minMediaWidth: CGFloat = 220
2727
static let maxMediaWidth: CGFloat = 900
2828

2929
/// Base gap between cards before the size-aware scale is applied. Shared by
3030
/// the Browse grid so rails and grids tighten in lockstep.
31-
static let baseCardSpacing: CGFloat = 20
31+
static var baseCardSpacing: CGFloat { CardMetrics.cardSpacing }
3232

3333
/// Subtly tightens the gap between cards as they get smaller (more cards
3434
/// across). Smaller cards don't need as wide a gutter, and reclaiming that
3535
/// space lets each card render a touch larger in the same width. Full base
3636
/// spacing at 2-across, easing down to ~68% at 6-across.
3737
static func spacingScale(forVisibleCardCount count: Int) -> CGFloat {
38-
let clamped = CGFloat(min(max(count, 2), 6))
39-
return 1.0 - (clamped - 2) * 0.08
38+
CardMetrics.spacingScale(forVisibleCardCount: count)
4039
}
4140

4241
/// Solve the per-card width so `visibleCardCount` full cards (plus a peek of

Twozz/Shared/Theme.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import SwiftUI
66
/// There should be no per-page horizontal padding overrides — use these.
77
enum AppLayout {
88
/// The single horizontal page gutter shared by every top-level view.
9-
static let horizontalPadding: CGFloat = 24
9+
static var horizontalPadding: CGFloat { CardMetrics.screenPadding }
1010

1111
/// The single focus/hover zoom applied to every interactive content card
1212
/// (channels, categories, search results, etc.) so the scale is consistent
1313
/// everywhere instead of varying per surface.
14-
static let focusedCardScale: CGFloat = 1.07
14+
static var focusedCardScale: CGFloat { CardMetrics.focusedScale }
1515

1616
/// Shared focus/hover animation used by interactive cards.
1717
static var focusScaleAnimation: Animation? {

Twozz/Views/BrowseView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ struct CategoryStreamsView: View {
189189
onWatch: { selectedChannel = $0 },
190190
onGoToChannel: { channelPageTarget = ChannelPageTarget(channel: $0) }
191191
)
192-
.contentShape(RoundedRectangle(cornerRadius: 16))
192+
.contentShape(RoundedRectangle(cornerRadius: CardMetrics.gridCardCornerRadius))
193193
.focusable(true)
194194
.focused($focusedStreamID, equals: channel.id)
195195
.focusEffectDisabled()

Twozz/Views/ChannelPageView.swift

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,11 @@ struct ChannelPageView: View {
3939
private let avatarSize: CGFloat = 96
4040
private let tileWidth: CGFloat = 360
4141
private var tileMediaHeight: CGFloat { tileWidth * 9 / 16 }
42-
// Match the rail-card metrics used across the rest of the app (HomeView).
43-
private let focusHInset: CGFloat = 18
44-
private let focusVInset: CGFloat = 18
45-
private let cardCorner: CGFloat = 30
46-
private let mediaCorner: CGFloat = 18
47-
private let heroCorner: CGFloat = 28
42+
private var focusHInset: CGFloat { CardMetrics.focusInset }
43+
private var focusVInset: CGFloat { CardMetrics.focusInset }
44+
private var cardCorner: CGFloat { CardMetrics.cardCornerRadius }
45+
private var mediaCorner: CGFloat { CardMetrics.mediaCornerRadius }
46+
private var heroCorner: CGFloat { CardMetrics.heroCornerRadius }
4847
/// Full-bleed banner height. The hero identity card overlaps its bottom edge by
4948
/// half, and a mirrored, blurred reflection fills the rest of the page below it.
5049
private let bannerHeight: CGFloat = 380

Twozz/Views/FollowingDirectoryView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ struct FollowingDirectoryView: View {
105105
onWatch: { selectedChannel = $0 },
106106
onGoToChannel: { channelPageTarget = ChannelPageTarget(channel: $0) }
107107
)
108-
.contentShape(RoundedRectangle(cornerRadius: 16))
108+
.contentShape(RoundedRectangle(cornerRadius: CardMetrics.gridCardCornerRadius))
109109
.focusable(true)
110110
.focused($focusedID, equals: channel.id)
111111
.focusEffectDisabled()

Twozz/Views/HomeView.swift

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@ import SwiftUI
33
struct HomeView: View {
44
let deepLinkRouter: DeepLinkRouter
55

6-
private let channelRailVerticalPadding: CGFloat = 20
7-
private let focusVerticalInset: CGFloat = 18
8-
/// Mirror of the shared rail inset so the card-render sites keep one name.
9-
private var focusHorizontalInset: CGFloat { ChannelRailLayout.focusHorizontalInset }
10-
private let cardCornerRadius: CGFloat = 30
11-
private let mediaCornerRadius: CGFloat = 18
6+
private var channelRailVerticalPadding: CGFloat { CardMetrics.railVerticalPadding }
7+
private var focusVerticalInset: CGFloat { CardMetrics.focusInset }
8+
private var focusHorizontalInset: CGFloat { CardMetrics.focusInset }
9+
private var cardCornerRadius: CGFloat { CardMetrics.cardCornerRadius }
10+
private var mediaCornerRadius: CGFloat { CardMetrics.mediaCornerRadius }
1211
private let autoRefreshStaleInterval: TimeInterval = 5 * 60
1312

1413
@State private var selectedSidebarTab: SidebarTab = .home

Twozz/Views/MediaContentCard.swift

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,19 @@ struct MediaContentCard: View {
1515

1616
var mediaWidth: CGFloat
1717
var mediaHeight: CGFloat
18-
var focusHorizontalInset: CGFloat = 18
19-
var focusVerticalInset: CGFloat = 18
20-
var cardCornerRadius: CGFloat = 22
21-
var mediaCornerRadius: CGFloat = 18
18+
var focusHorizontalInset: CGFloat = CardMetrics.focusInset
19+
var focusVerticalInset: CGFloat = CardMetrics.focusInset
20+
var cardCornerRadius: CGFloat = CardMetrics.cardCornerRadius
21+
var mediaCornerRadius: CGFloat = CardMetrics.mediaCornerRadius
2222

2323
@Environment(\.themePalette) private var palette
2424
@Environment(\.glassDisabled) private var glassDisabled
2525

2626
var body: some View {
27-
VStack(alignment: .leading, spacing: 8) {
27+
VStack(alignment: .leading, spacing: CardMetrics.captionSpacing) {
2828
media
2929

30-
VStack(alignment: .leading, spacing: 4) {
30+
VStack(alignment: .leading, spacing: CardMetrics.captionLineSpacing) {
3131
Text(title)
3232
.font(.subheadline.weight(.semibold))
3333
.foregroundStyle(usesLiftFocusedText ? palette.liftPrimaryText : Color.primary)
@@ -51,7 +51,11 @@ struct MediaContentCard: View {
5151
isFocused: isFocused,
5252
palette: palette
5353
)
54-
.shadow(color: Color.black.opacity(isFocused ? 0.36 : 0), radius: 20, y: 10)
54+
.shadow(
55+
color: Color.black.opacity(isFocused ? CardMetrics.focusShadowOpacity : 0),
56+
radius: CardMetrics.focusShadowRadius,
57+
y: CardMetrics.focusShadowY
58+
)
5559
.accessibilityElement(children: .ignore)
5660
.accessibilityLabel(accessibilityLabel)
5761
}

0 commit comments

Comments
 (0)