Front-page polish: hero lead card + title-cased labels
Render the #1 Top Story as an Apple-News-style hero card (flat surface, 4pt stripe, 24pt headline, summary, sources, prominent score). Prettify category labels (slug -> Title Case, with AI/US/Formula 1 special-cases) across rows, hero, and detail. Per Emil's framework, no row entrance/press motion on a constantly-scrolled surface — invest in hierarchy and defaults instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -292,7 +292,10 @@ struct SignalFeedView: View {
|
|||||||
@ViewBuilder private var digestContent: some View {
|
@ViewBuilder private var digestContent: some View {
|
||||||
let d = digest
|
let d = digest
|
||||||
sectionHeader("TOP STORIES", pill: nil)
|
sectionHeader("TOP STORIES", pill: nil)
|
||||||
ForEach(d.top) { mainRow($0) }
|
if let lead = d.top.first {
|
||||||
|
mainRow(lead, hero: true)
|
||||||
|
ForEach(d.top.dropFirst()) { mainRow($0) }
|
||||||
|
}
|
||||||
ForEach(d.sections, id: \.0.id) { section, stories in
|
ForEach(d.sections, id: \.0.id) { section, stories in
|
||||||
sectionHeader(section.label.uppercased(), pill: section.pill)
|
sectionHeader(section.label.uppercased(), pill: section.pill)
|
||||||
ForEach(stories) { mainRow($0) }
|
ForEach(stories) { mainRow($0) }
|
||||||
@@ -323,11 +326,17 @@ struct SignalFeedView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func mainRow(_ story: StorySummary) -> some View {
|
private func mainRow(_ story: StorySummary, hero: Bool = false) -> some View {
|
||||||
NavigationLink(value: story) {
|
NavigationLink(value: story) {
|
||||||
|
if hero {
|
||||||
|
heroCard(story)
|
||||||
|
} else {
|
||||||
StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id))
|
StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id))
|
||||||
}
|
}
|
||||||
.plainBlackRow()
|
}
|
||||||
|
.listRowInsets(hero ? EdgeInsets(top: 2, leading: 16, bottom: 12, trailing: 16) : EdgeInsets())
|
||||||
|
.listRowBackground(Color.black)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
markSeen(story)
|
markSeen(story)
|
||||||
if story.id == mainStories.last?.id { Task { await store.loadMore() } }
|
if story.id == mainStories.last?.id { Task { await store.loadMore() } }
|
||||||
@@ -353,6 +362,47 @@ struct SignalFeedView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apple-News-style lead card for the #1 Top Story — flat surface, big type.
|
||||||
|
private func heroCard(_ story: StorySummary) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
SignalStripe(score: story.signalScore, width: 4)
|
||||||
|
VStack(alignment: .leading, spacing: 11) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(Topic.label(story.topic).uppercased())
|
||||||
|
.font(.system(size: 10, weight: .bold, design: .monospaced)).kerning(0.8)
|
||||||
|
.foregroundStyle(Color(hex: "8A8A8A"))
|
||||||
|
Spacer()
|
||||||
|
Text("\(story.signalScore)")
|
||||||
|
.font(.system(size: 21, weight: .heavy, design: .monospaced))
|
||||||
|
.foregroundStyle(Signal.scoreColor(story.signalScore))
|
||||||
|
}
|
||||||
|
Text(story.headline)
|
||||||
|
.font(.system(size: 24, weight: .heavy))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(4)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if !story.summary.isEmpty {
|
||||||
|
Text(story.summary)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(Color(hex: "9A9A9A"))
|
||||||
|
.lineLimit(3).lineSpacing(2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
if !story.sources.isEmpty {
|
||||||
|
SourceChips(names: story.sources.map(\.name), total: story.sourceCount)
|
||||||
|
}
|
||||||
|
Text("\(story.sourceCount) sources · \(story.updatedAt.timeAgoShort()) ago")
|
||||||
|
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
||||||
|
.foregroundStyle(Color(hex: "666666"))
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.background(Palette.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Palette.surface2, lineWidth: 0.5))
|
||||||
|
}
|
||||||
|
|
||||||
private var caughtUpRow: some View {
|
private var caughtUpRow: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "checkmark.circle")
|
Image(systemName: "checkmark.circle")
|
||||||
|
|||||||
@@ -106,11 +106,15 @@ enum Signal {
|
|||||||
enum Topic {
|
enum Topic {
|
||||||
static func label(_ slug: String) -> String {
|
static func label(_ slug: String) -> String {
|
||||||
switch slug.lowercased() {
|
switch slug.lowercased() {
|
||||||
case "finance": return "Finance"
|
case "ai", "artificial-intelligence": return "AI"
|
||||||
case "tech": return "Tech"
|
case "us", "united-states": return "US"
|
||||||
case "politics": return "Politics"
|
case "formula-1": return "Formula 1"
|
||||||
case "africa": return "Africa"
|
case "ui-ux", "web-design-and-ui-ux": return "Web Design"
|
||||||
default: return slug.prefix(1).uppercased() + slug.dropFirst()
|
default:
|
||||||
|
// Slug → Title Case, e.g. "cloud-computing" → "Cloud Computing".
|
||||||
|
return slug.split(separator: "-")
|
||||||
|
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
|
||||||
|
.joined(separator: " ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user