feat(brand+promotion): brand-gradient system, restyled /about/* + team + contact, alias-based client matcher

Three coupled rollouts in one shot.

PART A — client highlight matcher (DB migration applied separately):
- promoted-articles.ts: AdvertiserName now carries brand_aliases; matcher
  iterates legal name + every alias with word-boundary regex and picks the
  longest match across all candidates so "Magewell Launches…" resolves to
  "Magewell Electronics Co., Ltd." via its Magewell alias.
- New isStoryAboutClient gate: hard-accept at position 0, accept if any
  editorial frame ("Product Spotlight:", "Review:", "First Look", …) appears
  in the prefix, hard-reject if the prefix ends with a partner/usage phrase
  ("with", "using", "powered by", "dealer of", "integration with", "used",
  "deploys", "runs on", "leverages", …), otherwise accept within a 32-char
  subject window. Validated against 5,000 historical posts: 27 partner
  false-positives blocked, 3 editorial-frame stories correctly saved, the
  Magewell article now highlights.

PART B — restyling /about, /about/team, /about/contact:
- New .brand-gradient utility: #2563EB→#1E3A8A linear-gradient with a
  16%-opacity diagonal sheen that sweeps in the first 30% of a 7s cycle
  and rests offscreen-right for the rest. Respects prefers-reduced-motion
  (band parked statically at 6% opacity).
- Team page: dark legacy hero (red radial, #ff5a55 eyebrows) replaced with
  brand-gradient hero; outer text wrapper #e5e7eb → #0F172A so section
  headings stop being white-on-white; all serif H1/H2/H3 → font-display;
  red accents (#ff5a55 / #ff6b66 / rgba(214,7,1)) → blue (#93C5FD / #7DD3FC
  / rgba(29,78,216)).
- About page: H1 + Stat values → font-display; Stat cards get a brand-
  gradient top stripe.
- Contact page: outer wrapper #e5e7eb → #0F172A (fixes the white-on-white
  Contact H1); H1 gets explicit text-[#0F172A] + font-display; dark contact
  cards keep dark bg but gain a brand-gradient top stripe + explicit
  text-[#e5e7eb] body + brighter cyan eyebrow/link colors.

PART C — richer Industry News client highlight:
- ArticleFeed: client-promoted row swaps the static border-l-4 for an
  absolute-positioned .brand-gradient strip on the left edge (sheen pulses
  through). Row gets a slightly richer bg tint (from-#1D4ED8/12 via /4).
- HIGHLIGHT pill now uses .brand-gradient instead of the static blue chip.

Also shipping the earlier-staged work:
- AnimatedLogo onDark: white ring around tile + brighter (#E2E8F0) tagline
  + cyan accent rule for the blue header.
- Article-prose: em/strong/code dark colors (em fixes the trailing
  copyright/trademark ghost text), hr line color, figcaption readable.
- ArticleComments: "Sign in to join the discussion" bar uses brand-gradient
  + white/outline buttons that read on blue.
- Forum: Discover/Following, Newest/Top Voted/Unanswered, Sign in to post,
  Start a thread, Create account — slim pills, brand-gradient active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 18:37:15 +00:00
parent 0b2ed8cd7f
commit f44add70c7
9 changed files with 257 additions and 83 deletions

View File

@@ -8,7 +8,7 @@ const CONTACTS = [
{
title: "Editorial",
body: (
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:editor@avbeat.com">editor@avbeat.com</a>
<a className="text-[#7DD3FC] hover:underline" href="mailto:editor@avbeat.com">editor@avbeat.com</a>
),
hint: "Story tips, corrections, interview requests.",
},
@@ -17,7 +17,7 @@ const CONTACTS = [
body: (
<>
Route PR-firm submissions through{" "}
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="https://distribution.relevantmediaproperties.com">distribution.relevantmediaproperties.com</a>.
<a className="text-[#7DD3FC] hover:underline" href="https://distribution.relevantmediaproperties.com">distribution.relevantmediaproperties.com</a>.
</>
),
hint: "Routed submissions land in our editorial queue with provenance.",
@@ -26,8 +26,8 @@ const CONTACTS = [
title: "Advertising",
body: (
<>
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:advertising@relevantmediaproperties.com">advertising@relevantmediaproperties.com</a> see the{" "}
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="https://relevantmediaproperties.com/av-beat---media-kit">media kit</a>.
<a className="text-[#7DD3FC] hover:underline" href="mailto:advertising@relevantmediaproperties.com">advertising@relevantmediaproperties.com</a> see the{" "}
<a className="text-[#7DD3FC] hover:underline" href="https://relevantmediaproperties.com/av-beat---media-kit">media kit</a>.
</>
),
hint: "728×90, 300×600, 300×250 inventory; CTR + impression reports monthly.",
@@ -37,7 +37,7 @@ const CONTACTS = [
body: (
<>
Ryan Salazar ·{" "}
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:ryan.salazar@relevantmediaproperties.com">ryan.salazar@relevantmediaproperties.com</a>
<a className="text-[#7DD3FC] hover:underline" href="mailto:ryan.salazar@relevantmediaproperties.com">ryan.salazar@relevantmediaproperties.com</a>
</>
),
hint: "Partnerships, M&A, syndication, network membership.",
@@ -48,27 +48,31 @@ export default function ContactPage() {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="max-w-container mx-auto px-4 py-12 text-[#e5e7eb]">
<div className="max-w-container mx-auto px-4 py-12 text-[#0F172A]">
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
<main>
<header className="mb-8 border-b border-[#DCE6F2] pb-6">
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[#1D4ED8] mb-2">
AV Beat
</div>
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">Contact</h1>
<p className="mt-3 text-[#9ca3af] text-base">
<h1 className="font-display text-4xl md:text-5xl font-bold tracking-tight text-[#0F172A]">Contact</h1>
<p className="mt-3 text-[#475569] text-base">
Pitches, press, partnerships pick the right desk and we'll get back fast.
</p>
</header>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-4">
{CONTACTS.map((c) => (
<li key={c.title} className="rounded border border-[#DCE6F2] bg-[#0b0f17] p-5 hover:border-[var(--color-text-info,#60a5fa)]/60 transition-colors">
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[var(--color-text-info,#60a5fa)] mb-2">
// Dark contact card with a brand-gradient top accent (animated
// sheen). Card body text is explicitly light because the parent
// wrapper is now dark-on-light at the page level.
<li key={c.title} className="relative overflow-hidden rounded border border-[#1a1f2a] bg-[#0b0f17] p-5 hover:border-[#1D4ED8]/60 transition-colors">
<span className="brand-gradient absolute top-0 left-0 right-0 h-1" aria-hidden />
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#93C5FD] mb-2">
{c.title}
</div>
<div className="text-[15px] leading-relaxed">{c.body}</div>
<div className="mt-3 text-xs text-[#6b7280]">{c.hint}</div>
<div className="text-[15px] leading-relaxed text-[#e5e7eb]">{c.body}</div>
<div className="mt-3 text-xs text-[#94a3b8]">{c.hint}</div>
</li>
))}
</ul>

View File

@@ -18,7 +18,7 @@ export default function AboutPage() {
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
AV Beat
</div>
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">About</h1>
<h1 className="font-display text-4xl md:text-5xl font-bold tracking-tight">About</h1>
<p className="mt-3 text-[#475569] text-base max-w-2xl">
An independent trade publication covering pro AV, live production, display tech, and signal-flow technology.
</p>
@@ -78,9 +78,13 @@ export default function AboutPage() {
}
function Stat({ label, value }: { label: string; value: string }) {
// Brand-gradient top accent (animated sheen) gives the stat tile its
// on-brand identity without redoing the layout. The tile itself stays
// white-on-light-border for legibility.
return (
<div className="rounded border border-[#DCE6F2] bg-[#FFFFFF] p-4">
<div className="font-serif text-3xl text-[#0F172A]">{value}</div>
<div className="relative overflow-hidden rounded border border-[#DCE6F2] bg-[#FFFFFF] p-4">
<span className="brand-gradient absolute top-0 left-0 right-0 h-1" aria-hidden />
<div className="font-display text-3xl font-bold text-[#0F172A]">{value}</div>
<div className="mt-1 text-[10px] font-mono uppercase tracking-[0.16em] text-[#475569]">
{label}
</div>

View File

@@ -93,21 +93,20 @@ export default async function TeamPage() {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="max-w-container mx-auto px-4 py-12 text-[#e5e7eb]">
<div className="max-w-container mx-auto px-4 py-12 text-[#0F172A]">
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
<main>
{/* Hero */}
<header className="relative overflow-hidden rounded-xl mb-10 border border-[#1a1f2a] bg-gradient-to-br from-[#0d1118] via-[#0b0f17] to-[#0d1118]">
<div className="absolute inset-0 opacity-[0.04] bg-[radial-gradient(ellipse_at_top_right,#d60701,transparent_60%)]" aria-hidden />
<div className="relative px-6 py-10 sm:px-10 sm:py-14">
<div className="inline-flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.22em] text-[#ff5a55] mb-3">
<span className="w-6 h-px bg-[#1D4ED8]" />
{/* Hero — brand-gradient (animated sheen) */}
<header className="brand-gradient rounded-xl mb-10">
<div className="px-6 py-10 sm:px-10 sm:py-14">
<div className="inline-flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.22em] text-white/85 mb-3">
<span className="w-6 h-px bg-white/60" />
AV Beat &middot; editorial
</div>
<h1 className="font-serif text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
<h1 className="font-display text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight text-white">
The team behind every byline
</h1>
<p className="mt-4 text-[#9ca3af] text-base md:text-lg max-w-2xl leading-relaxed">
<p className="mt-4 text-white/80 text-base md:text-lg max-w-2xl leading-relaxed">
Beats covered by trained AI personas working under house style, plus the
business and support people who keep things moving.
</p>
@@ -117,7 +116,7 @@ export default async function TeamPage() {
{/* Editorial team — three-col grid on desktop, larger portraits */}
<section className="mb-14">
<div className="flex items-baseline justify-between mb-5">
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">Editorial</h2>
<h2 className="font-display text-2xl md:text-3xl font-bold tracking-tight">Editorial</h2>
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
{team.length} {team.length === 1 ? "writer" : "writers"}
</span>
@@ -129,7 +128,7 @@ export default async function TeamPage() {
return (
<li
key={p.slug}
className="group relative rounded-xl border border-[#1a1f2a] bg-[#0b0f17] p-5 hover:border-[#1D4ED8]/50 hover:shadow-[0_0_0_1px_rgba(214,7,1,0.15)] transition-all"
className="group relative rounded-xl border border-[#1a1f2a] bg-[#0b0f17] p-5 hover:border-[#1D4ED8]/50 hover:shadow-[0_0_0_1px_rgba(29,78,216,0.20)] transition-all"
>
{/* Top accent */}
<div className="absolute top-0 left-5 right-5 h-px bg-gradient-to-r from-transparent via-[#1D4ED8]/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden />
@@ -146,16 +145,16 @@ export default async function TeamPage() {
/>
</a>
) : (
<div className="w-24 h-24 rounded-full border-2 border-[#1a1f2a] bg-[#141a25] flex items-center justify-center font-serif font-bold text-2xl text-[#6c7686] flex-shrink-0">
<div className="w-24 h-24 rounded-full border-2 border-[#1a1f2a] bg-[#141a25] flex items-center justify-center font-display font-bold text-2xl text-[#6c7686] flex-shrink-0">
{initials(p.name)}
</div>
)}
<div className="flex-1 min-w-0">
<a href={`/authors/${p.slug}`} className="block hover:underline">
<h3 className="font-serif text-xl font-bold leading-tight">{p.name}</h3>
<h3 className="font-display text-xl font-bold leading-tight">{p.name}</h3>
</a>
{(p.beat || p.slug === "ryan-salazar") && (
<div className="inline-block mt-1.5 px-2 py-0.5 rounded-sm bg-[#1D4ED8]/10 border border-[#1D4ED8]/25 text-[10px] font-mono uppercase tracking-[0.12em] text-[#ff6b66]">
<div className="inline-block mt-1.5 px-2 py-0.5 rounded-sm bg-[#1D4ED8]/15 border border-[#1D4ED8]/35 text-[10px] font-mono uppercase tracking-[0.12em] text-[#93C5FD]">
{displayBeat(p)}
</div>
)}
@@ -173,11 +172,11 @@ export default async function TeamPage() {
<ObfuscatedEmail
user={e.user}
domain={e.domain}
className="text-[#aab1bd] hover:text-[#ff6b66] font-mono"
className="text-[#aab1bd] hover:text-[#7DD3FC] font-mono"
/>
<a
href={`/authors/${p.slug}`}
className="text-[10px] font-mono uppercase tracking-[0.12em] text-[#6c7686] hover:text-[#ff6b66]"
className="text-[10px] font-mono uppercase tracking-[0.12em] text-[#6c7686] hover:text-[#7DD3FC]"
>
View
</a>
@@ -193,14 +192,14 @@ export default async function TeamPage() {
{ops.length > 0 && (
<section>
<div className="flex items-baseline justify-between mb-5">
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">
<h2 className="font-display text-2xl md:text-3xl font-bold tracking-tight">
Accounts &amp; support
</h2>
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
{ops.length} {ops.length === 1 ? "contact" : "contacts"}
</span>
</div>
<p className="text-[#9ca3af] text-sm mb-5 max-w-2xl">
<p className="text-[#475569] text-sm mb-5 max-w-2xl">
For billing, accounts receivable, vendor onboarding, sales, and
general support questions these are the people to reach.
</p>
@@ -220,13 +219,13 @@ export default async function TeamPage() {
className="w-20 h-20 rounded-full border-2 border-[#1a1f2a] flex-shrink-0 object-cover"
/>
) : (
<div className="w-20 h-20 rounded-full border-2 border-[#1a1f2a] flex-shrink-0 bg-[#141a25] flex items-center justify-center font-serif font-bold text-xl text-[#6c7686]">
<div className="w-20 h-20 rounded-full border-2 border-[#1a1f2a] flex-shrink-0 bg-[#141a25] flex items-center justify-center font-display font-bold text-xl text-[#6c7686]">
{initials(p.display_name)}
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="font-serif text-lg font-bold leading-tight">{p.display_name}</h3>
<div className="inline-block mt-1 px-2 py-0.5 rounded-sm bg-[#FFFFFF] border border-[#1D4ED8]/30 text-[10px] font-mono uppercase tracking-[0.12em] text-[#7ba0ff]">
<h3 className="font-display text-lg font-bold leading-tight">{p.display_name}</h3>
<div className="inline-block mt-1 px-2 py-0.5 rounded-sm bg-[#1D4ED8]/15 border border-[#1D4ED8]/35 text-[10px] font-mono uppercase tracking-[0.12em] text-[#93C5FD]">
{p.role}
</div>
<div className="text-xs text-[#aab1bd] mt-3 space-y-1">
@@ -235,13 +234,13 @@ export default async function TeamPage() {
if (!e) return null;
return (
<div className="font-mono">
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#ff6b66]" />
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#7DD3FC]" />
</div>
);
})()}
{p.phone && (
<div>
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#ff6b66] font-mono">
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#7DD3FC] font-mono">
{p.phone}{p.extension ? ` ext. ${p.extension}` : ''}
</a>
</div>

View File

@@ -299,14 +299,14 @@ export default function ForumCategoryPage() {
{isAuthenticated ? (
<button
onClick={() => setShowNewThread(!showNewThread)}
className="flex-shrink-0 bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body font-semibold text-sm px-4 py-2 rounded-lg transition-colors"
className="brand-gradient flex-shrink-0 font-body font-semibold text-sm px-4 py-2 rounded-full"
>
+ New Thread
</button>
) : (
<Link
href={`/forum/login?next=${typeof window === 'undefined' ? '/forum' : encodeURIComponent(window.location.pathname)}`}
className="flex-shrink-0 bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body font-semibold text-sm px-4 py-2 rounded-lg transition-colors"
className="brand-gradient flex-shrink-0 font-body font-semibold text-sm px-4 py-2 rounded-full"
>
Sign in to post
</Link>
@@ -393,16 +393,16 @@ export default function ForumCategoryPage() {
)}
</div>
{/* Sort Options */}
<div className="flex gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-1 flex-shrink-0">
{/* Sort Options — segmented pill control; active uses brand-gradient */}
<div className="flex gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-full p-1 flex-shrink-0">
{sortOptions.map(opt => (
<button
key={opt.value}
onClick={() => setSort(opt.value)}
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
className={`px-3 py-1 rounded-full text-xs font-body font-semibold transition-colors whitespace-nowrap ${
sort === opt.value
? 'bg-[#1D4ED8] text-white'
: 'text-[#888] hover:text-[#0F172A]'
? 'brand-gradient'
: 'text-[#475569] hover:text-[#0F172A]'
}`}
>
{opt.label}

View File

@@ -301,7 +301,7 @@ export default function ForumIndexPage() {
{currentUserId ? (
<Link
href="/forum/new"
className="inline-flex items-center gap-2 bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body font-semibold text-sm rounded-lg px-5 py-2.5 transition-colors"
className="brand-gradient inline-flex items-center gap-2 font-body font-semibold text-sm rounded-full px-5 py-2"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
@@ -311,7 +311,7 @@ export default function ForumIndexPage() {
) : (
<Link
href="/forum/login?next=/forum"
className="inline-flex items-center gap-2 bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body font-semibold text-sm rounded-lg px-5 py-2.5 transition-colors"
className="brand-gradient inline-flex items-center gap-2 font-body font-semibold text-sm rounded-full px-5 py-2"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3" />
@@ -328,12 +328,12 @@ export default function ForumIndexPage() {
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-8">
<div>
{/* Feed Mode Tabs */}
<div className="flex items-center gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-1 mb-6 w-fit">
{/* Feed Mode Tabs — slim pill toggle; active uses brand-gradient */}
<div className="flex items-center gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-full p-1 mb-6 w-fit">
<button
onClick={() => setFeedMode('discover')}
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
feedMode === 'discover' ? 'bg-[#1D4ED8] text-white' : 'text-[#888] hover:text-[#0F172A]'
className={`px-4 py-1.5 rounded-full text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
feedMode === 'discover' ? 'brand-gradient' : 'text-[#475569] hover:text-[#0F172A]'
}`}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
@@ -343,8 +343,8 @@ export default function ForumIndexPage() {
</button>
<button
onClick={() => setFeedMode('following')}
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
feedMode === 'following' ? 'bg-[#1D4ED8] text-white' : 'text-[#888] hover:text-[#0F172A]'
className={`px-4 py-1.5 rounded-full text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
feedMode === 'following' ? 'brand-gradient' : 'text-[#475569] hover:text-[#0F172A]'
}`}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
@@ -363,7 +363,7 @@ export default function ForumIndexPage() {
<p className="text-[#475569] font-body text-sm mb-3">Sign in to see threads from people you follow.</p>
<Link
href="/login"
className="inline-block bg-[#1D4ED8] text-white font-body text-sm font-bold px-5 py-2 rounded-lg hover:bg-[#1E3A8A] transition-colors"
className="brand-gradient inline-block font-body text-sm font-bold px-5 py-2 rounded-full"
>
Sign In
</Link>
@@ -482,16 +482,16 @@ export default function ForumIndexPage() {
</svg>
</div>
{/* Sort Options */}
<div className="flex gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-1">
{/* Sort Options — segmented pill control; active uses brand-gradient */}
<div className="flex gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-full p-1">
{sortOptions.map(opt => (
<button
key={opt.value}
onClick={() => setSort(opt.value)}
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
className={`px-3 py-1 rounded-full text-xs font-body font-semibold transition-colors whitespace-nowrap ${
sort === opt.value
? 'bg-[#1D4ED8] text-white'
: 'text-[#888] hover:text-[#0F172A]'
? 'brand-gradient'
: 'text-[#475569] hover:text-[#0F172A]'
}`}
>
{opt.label}

View File

@@ -561,14 +561,23 @@ export default function ArticleFeed() {
: null;
const promoted = article?.promoted_kind;
// For client-promoted rows, we drop the static border-l in
// favor of a `.brand-gradient` strip absolute-positioned on
// the left edge — same blue gradient as the brand mark, with
// the subtle sheen pulsing through. The row gets a slightly
// richer bg tint than premium so client rows stand out more
// than plain FEATURED stories.
const promotedClass = promoted === 'premium'
? 'border-l-4 border-amber-400 bg-gradient-to-r from-amber-500/10 to-transparent pl-3 -ml-2 mr-0'
: promoted === 'client'
? 'border-l-4 border-[#1D4ED8] bg-gradient-to-r from-[#1D4ED8]/8 to-transparent pl-3 -ml-2 mr-0'
? 'pl-4 -ml-2 mr-0 bg-gradient-to-r from-[#1D4ED8]/12 via-[#1D4ED8]/4 to-transparent'
: '';
return (
<Fragment key={`feed-${i}`}>
<article className={`article-card flex gap-3 md:gap-4 py-4 md:py-5 group cursor-pointer rounded-sm px-2 -mx-2 ${promotedClass}`}>
<article className={`article-card relative flex gap-3 md:gap-4 py-4 md:py-5 group cursor-pointer rounded-sm px-2 -mx-2 ${promotedClass}`}>
{promoted === 'client' && (
<span className="brand-gradient absolute left-0 top-1 bottom-1 w-1 rounded-full" aria-hidden />
)}
<Link
href={articleHref}
{...linkProps}
@@ -593,7 +602,7 @@ export default function ArticleFeed() {
</span>
)}
{promoted === 'client' && (
<span className="font-body text-[9px] font-bold text-[#7aa7d4] bg-[#1D4ED8]/15 border border-[#1D4ED8]/40 px-1.5 py-0.5 rounded uppercase tracking-widest" title={article?.promoted_advertiser ? `Highlight: ${article.promoted_advertiser}` : 'Highlight'}>
<span className="brand-gradient font-body text-[9px] font-bold px-1.5 py-0.5 rounded uppercase tracking-widest" title={article?.promoted_advertiser ? `Highlight: ${article.promoted_advertiser}` : 'Highlight'}>
HIGHLIGHT
</span>
)}

View File

@@ -186,20 +186,20 @@ export default function ArticleComments({ articleSlug }: Props) {
</div>
</div>
) : (
<div className="bg-gradient-to-r from-[#0d1520] to-[#F8FAFC] border border-[#DCE6F2] rounded-md p-5 mb-6 text-center">
<div className="brand-gradient rounded-md p-5 mb-6 text-center">
<p className="text-white font-body text-sm mb-3">
Sign in to join the discussion. Vote, reply, and follow industry conversations.
</p>
<div className="flex gap-3 justify-center">
<Link
href={`/forum/login?next=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "")}`}
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body text-xs font-semibold px-5 py-2 rounded"
className="bg-white hover:bg-[#F1F5F9] text-[#0F172A] font-body text-xs font-semibold px-5 py-2 rounded transition-colors"
>
Sign in
</Link>
<Link
href={`/forum/register?next=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "")}`}
className="border border-[#1D4ED8] text-[#1D4ED8] hover:bg-[#1D4ED8]/10 font-body text-xs font-semibold px-5 py-2 rounded"
className="border border-white text-white hover:bg-white/10 font-body text-xs font-semibold px-5 py-2 rounded transition-colors"
>
Create account
</Link>

View File

@@ -30,6 +30,11 @@ interface AdvertiserName {
client_id: string;
company_name: string;
company_name_lower: string;
/** Marketing/brand aliases — short forms that actually appear in
* press-release titles. Populated from adv.clients.brand_aliases
* because the legal company_name (with Inc/Ltd/Co. suffixes) almost
* never appears verbatim in headlines. */
brand_aliases: string[] | null;
}
interface FeaturedSlot {
@@ -47,7 +52,7 @@ async function fetchActiveAdvertisers(property: string): Promise<AdvertiserName[
const svc = createAdminClient("adv");
const { data } = await svc
.from("active_advertisers_by_property")
.select("client_id, company_name, company_name_lower")
.select("client_id, company_name, company_name_lower, brand_aliases")
.eq("property", property);
return (data || []) as AdvertiserName[];
} catch {
@@ -74,23 +79,123 @@ async function fetchActiveSlots(property: string): Promise<FeaturedSlot[]> {
}
/**
* Return the article's matched advertiser company name (case-insensitive
* title contains), or null. Picks the longest match if multiple compete
* so "Sony Pictures" wins over "Sony" when both are advertisers.
* "Story is about the client" detection
* =====================================
* Rule: we only highlight when the story narratively revolves around the
* client or their product. A downstream mention ("FileCatalyst Announces
* Integration with LiveU", "Stage Precision streamlines tracking with AJA
* I/O cards") must NOT promote the partner — the story is about someone
* else, the client is just a component.
*
* Three layered checks against the prefix of the title up to the match
* start, in order:
* 1. Match starts at position 0 → ALWAYS accept (lead with the client).
* 2. Title contains an editorial framing phrase before the match
* ("Product Spotlight:", "Review:", "First Look:", etc.) → accept
* regardless of where the client name falls.
* 3. Immediate prefix ends with a partner/usage phrase
* ("with", "using", "powered by", "dealer of", "integration with",
* …) → reject; the client is being credited as a vendor/integration,
* not the subject.
* 4. Default: accept iff the match start is in the first 32 chars.
*/
const SUBJECT_WINDOW_CHARS = 32;
const EDITORIAL_FRAMES = [
"product spotlight:",
"spotlight:",
"review:",
"first look:",
"first look at",
"hands-on with",
"hands on with",
"preview:",
"profile:",
"interview:",
"meet ",
"inside ",
];
// Partner/usage phrases — when the immediate prefix ends with one of
// these, the client is positioned as the SOURCE/COMPONENT, and the real
// subject is the company / person / project doing the with-ing.
const PARTNER_PREFIXES = [
"with ",
"using ",
"uses ",
"used ",
"powered by ",
"thanks to ",
"integrating ",
"integration with ",
"integrates ",
"featuring ",
"support for ",
"compatible with ",
"adopts ",
"selects ",
"chooses ",
"dealer of ",
"stocking dealer of ",
"partner of ",
"partners with ",
"partnership with ",
"via ",
"runs on ",
"running ",
"built with ",
"built on ",
"made with ",
"deploys ",
"deployed ",
"ships with ",
"shipped with ",
"relies on ",
"rely on ",
"leverages ",
"leveraging ",
"supplied by ",
"supported by ",
];
function isStoryAboutClient(title: string, matchStart: number): boolean {
if (matchStart === 0) return true;
const prefix = title.substring(0, matchStart).toLowerCase();
for (const frame of EDITORIAL_FRAMES) {
if (prefix.includes(frame)) return true;
}
for (const p of PARTNER_PREFIXES) {
if (prefix.endsWith(p)) return false;
}
return matchStart < SUBJECT_WINDOW_CHARS;
}
/**
* Return the article's matched advertiser, or null. Matches the title
* against the client's legal name PLUS every brand alias (word-boundary,
* case-insensitive), filters by `isStoryAboutClient`, and picks the
* longest matched substring across all candidates so "Sony Pictures" beats
* "Sony" when both could match — and "Magewell Launches…" resolves via the
* "Magewell" alias to "Magewell Electronics Co., Ltd."
*/
function matchAdvertiserInTitle(title: string, advertisers: AdvertiserName[]): AdvertiserName | null {
if (!title || advertisers.length === 0) return null;
const t = title.toLowerCase();
let best: AdvertiserName | null = null;
let best: { adv: AdvertiserName; matchLen: number } | null = null;
for (const a of advertisers) {
if (!a.company_name_lower) continue;
// Word boundary check: avoid "Sony" matching "Sony" inside "Sonyform" etc.
const re = new RegExp(`\\b${a.company_name_lower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
if (re.test(t)) {
if (!best || a.company_name_lower.length > best.company_name_lower.length) best = a;
const candidates: string[] = [
a.company_name_lower,
...(a.brand_aliases || []).map((x) => x.toLowerCase()),
].filter((c) => !!c && c.length > 0);
for (const c of candidates) {
const re = new RegExp(`\\b${c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
const m = re.exec(t);
if (m && isStoryAboutClient(title, m.index)) {
if (!best || c.length > best.matchLen) {
best = { adv: a, matchLen: c.length };
}
}
return best;
}
}
return best?.adv ?? null;
}
function withinPromoWindow(dateStr: string): boolean {

View File

@@ -1674,7 +1674,7 @@ a.company-mention:hover {
font-family: var(--font-sans), sans-serif;
font-size: 13px;
font-style: italic;
color: #9a9690;
color: #475569;
margin-top: 0.6em;
line-height: 1.45;
}
@@ -1690,23 +1690,27 @@ a.company-mention:hover {
.bb-article-prose hr {
border: none;
height: 1px;
background: rgba(255, 255, 255, 0.08);
background: #DCE6F2;
margin: 2.5em 25% 2.5em 25%;
}
/* strong/em/code were cream/near-white from the legacy dark-bg era; they
now render on a white page, so force readable dark colors. The trailing
"Copyright … All rights reserved. NDI® is a trademark…" boilerplate is
wrapped in <em>, which is why it ghosts on the live page. */
.bb-article-prose strong {
color: #f5f3ee;
color: #0F172A;
font-weight: 700;
}
.bb-article-prose em {
color: #ece9e2;
color: #1e293b;
}
.bb-article-prose code {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 0.92em;
background: #FFFFFF;
background: #F1F5F9;
padding: 0.1em 0.4em;
border-radius: 3px;
color: #f0eee8;
color: #1e293b;
}
@media (max-width: 720px) {
.bb-article-prose,
@@ -1715,3 +1719,52 @@ a.company-mention:hover {
.bb-article-prose h2 { font-size: 24px; }
.bb-article-prose blockquote { font-size: 19px; }
}
/* =====================================================================
.brand-gradient — the AV Beat tile gradient (#2563EB → #1E3A8A) +
a SUBTLE diagonal sheen that sweeps once every ~7s and pauses for the
rest of the loop, so the effect catches the eye briefly without
strobing. Apply to active states of pills/toggles, primary CTAs, and
header bars. `isolation: isolate` plus a z-index:-1 pseudo lets the
sheen live behind the button text without needing each child to be
wrapped in a span.
===================================================================== */
.brand-gradient {
background-image: linear-gradient(135deg, #2563EB 0%, #1E3A8A 100%);
color: #FFFFFF;
position: relative;
isolation: isolate;
overflow: hidden;
}
.brand-gradient::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(
100deg,
transparent 42%,
rgba(255, 255, 255, 0.16) 50%,
transparent 58%
);
transform: translateX(-130%);
animation: brand-sheen 7s ease-in-out 1.2s infinite;
pointer-events: none;
}
.brand-gradient:hover {
background-image: linear-gradient(135deg, #1E3A8A 0%, #1E40AF 100%);
}
@keyframes brand-sheen {
/* Sweep across in first 30% of the cycle, then rest offscreen-right
for the remaining 70% so the effect feels punctuated, not constant. */
0% { transform: translateX(-130%); }
30% { transform: translateX(130%); }
100% { transform: translateX(130%); }
}
@media (prefers-reduced-motion: reduce) {
.brand-gradient::before {
animation: none;
transform: none;
opacity: 0.06;
}
}