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", title: "Editorial",
body: ( 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.", hint: "Story tips, corrections, interview requests.",
}, },
@@ -17,7 +17,7 @@ const CONTACTS = [
body: ( body: (
<> <>
Route PR-firm submissions through{" "} 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.", hint: "Routed submissions land in our editorial queue with provenance.",
@@ -26,8 +26,8 @@ const CONTACTS = [
title: "Advertising", title: "Advertising",
body: ( 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-[#7DD3FC] 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="https://relevantmediaproperties.com/av-beat---media-kit">media kit</a>.
</> </>
), ),
hint: "728×90, 300×600, 300×250 inventory; CTR + impression reports monthly.", hint: "728×90, 300×600, 300×250 inventory; CTR + impression reports monthly.",
@@ -37,7 +37,7 @@ const CONTACTS = [
body: ( body: (
<> <>
Ryan Salazar ·{" "} 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.", hint: "Partnerships, M&A, syndication, network membership.",
@@ -48,27 +48,31 @@ export default function ContactPage() {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<Header /> <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"> <div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
<main> <main>
<header className="mb-8 border-b border-[#DCE6F2] pb-6"> <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 AV Beat
</div> </div>
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">Contact</h1> <h1 className="font-display text-4xl md:text-5xl font-bold tracking-tight text-[#0F172A]">Contact</h1>
<p className="mt-3 text-[#9ca3af] text-base"> <p className="mt-3 text-[#475569] text-base">
Pitches, press, partnerships pick the right desk and we'll get back fast. Pitches, press, partnerships pick the right desk and we'll get back fast.
</p> </p>
</header> </header>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-4"> <ul className="grid grid-cols-1 md:grid-cols-2 gap-4">
{CONTACTS.map((c) => ( {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"> // Dark contact card with a brand-gradient top accent (animated
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[var(--color-text-info,#60a5fa)] mb-2"> // 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} {c.title}
</div> </div>
<div className="text-[15px] leading-relaxed">{c.body}</div> <div className="text-[15px] leading-relaxed text-[#e5e7eb]">{c.body}</div>
<div className="mt-3 text-xs text-[#6b7280]">{c.hint}</div> <div className="mt-3 text-xs text-[#94a3b8]">{c.hint}</div>
</li> </li>
))} ))}
</ul> </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"> <div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
AV Beat AV Beat
</div> </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"> <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. An independent trade publication covering pro AV, live production, display tech, and signal-flow technology.
</p> </p>
@@ -78,9 +78,13 @@ export default function AboutPage() {
} }
function Stat({ label, value }: { label: string; value: string }) { 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 ( return (
<div className="rounded border border-[#DCE6F2] bg-[#FFFFFF] p-4"> <div className="relative overflow-hidden rounded border border-[#DCE6F2] bg-[#FFFFFF] p-4">
<div className="font-serif text-3xl text-[#0F172A]">{value}</div> <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]"> <div className="mt-1 text-[10px] font-mono uppercase tracking-[0.16em] text-[#475569]">
{label} {label}
</div> </div>

View File

@@ -93,21 +93,20 @@ export default async function TeamPage() {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<Header /> <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"> <div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
<main> <main>
{/* Hero */} {/* Hero — brand-gradient (animated sheen) */}
<header className="relative overflow-hidden rounded-xl mb-10 border border-[#1a1f2a] bg-gradient-to-br from-[#0d1118] via-[#0b0f17] to-[#0d1118]"> <header className="brand-gradient rounded-xl mb-10">
<div className="absolute inset-0 opacity-[0.04] bg-[radial-gradient(ellipse_at_top_right,#d60701,transparent_60%)]" aria-hidden /> <div className="px-6 py-10 sm:px-10 sm:py-14">
<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-white/85 mb-3">
<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-white/60" />
<span className="w-6 h-px bg-[#1D4ED8]" />
AV Beat &middot; editorial AV Beat &middot; editorial
</div> </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 The team behind every byline
</h1> </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 Beats covered by trained AI personas working under house style, plus the
business and support people who keep things moving. business and support people who keep things moving.
</p> </p>
@@ -117,7 +116,7 @@ export default async function TeamPage() {
{/* Editorial team — three-col grid on desktop, larger portraits */} {/* Editorial team — three-col grid on desktop, larger portraits */}
<section className="mb-14"> <section className="mb-14">
<div className="flex items-baseline justify-between mb-5"> <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]"> <span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
{team.length} {team.length === 1 ? "writer" : "writers"} {team.length} {team.length === 1 ? "writer" : "writers"}
</span> </span>
@@ -129,7 +128,7 @@ export default async function TeamPage() {
return ( return (
<li <li
key={p.slug} 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 */} {/* 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 /> <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> </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)} {initials(p.name)}
</div> </div>
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<a href={`/authors/${p.slug}`} className="block hover:underline"> <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> </a>
{(p.beat || p.slug === "ryan-salazar") && ( {(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)} {displayBeat(p)}
</div> </div>
)} )}
@@ -173,11 +172,11 @@ export default async function TeamPage() {
<ObfuscatedEmail <ObfuscatedEmail
user={e.user} user={e.user}
domain={e.domain} domain={e.domain}
className="text-[#aab1bd] hover:text-[#ff6b66] font-mono" className="text-[#aab1bd] hover:text-[#7DD3FC] font-mono"
/> />
<a <a
href={`/authors/${p.slug}`} 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 View
</a> </a>
@@ -193,14 +192,14 @@ export default async function TeamPage() {
{ops.length > 0 && ( {ops.length > 0 && (
<section> <section>
<div className="flex items-baseline justify-between mb-5"> <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 Accounts &amp; support
</h2> </h2>
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]"> <span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
{ops.length} {ops.length === 1 ? "contact" : "contacts"} {ops.length} {ops.length === 1 ? "contact" : "contacts"}
</span> </span>
</div> </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 For billing, accounts receivable, vendor onboarding, sales, and
general support questions these are the people to reach. general support questions these are the people to reach.
</p> </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" 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)} {initials(p.display_name)}
</div> </div>
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-serif text-lg font-bold leading-tight">{p.display_name}</h3> <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-[#FFFFFF] border border-[#1D4ED8]/30 text-[10px] font-mono uppercase tracking-[0.12em] text-[#7ba0ff]"> <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} {p.role}
</div> </div>
<div className="text-xs text-[#aab1bd] mt-3 space-y-1"> <div className="text-xs text-[#aab1bd] mt-3 space-y-1">
@@ -235,13 +234,13 @@ export default async function TeamPage() {
if (!e) return null; if (!e) return null;
return ( return (
<div className="font-mono"> <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> </div>
); );
})()} })()}
{p.phone && ( {p.phone && (
<div> <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}` : ''} {p.phone}{p.extension ? ` ext. ${p.extension}` : ''}
</a> </a>
</div> </div>

View File

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

View File

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

View File

@@ -561,14 +561,23 @@ export default function ArticleFeed() {
: null; : null;
const promoted = article?.promoted_kind; 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' 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' ? 'border-l-4 border-amber-400 bg-gradient-to-r from-amber-500/10 to-transparent pl-3 -ml-2 mr-0'
: promoted === 'client' : 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 ( return (
<Fragment key={`feed-${i}`}> <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 <Link
href={articleHref} href={articleHref}
{...linkProps} {...linkProps}
@@ -593,7 +602,7 @@ export default function ArticleFeed() {
</span> </span>
)} )}
{promoted === 'client' && ( {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 HIGHLIGHT
</span> </span>
)} )}

View File

@@ -186,20 +186,20 @@ export default function ArticleComments({ articleSlug }: Props) {
</div> </div>
</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"> <p className="text-white font-body text-sm mb-3">
Sign in to join the discussion. Vote, reply, and follow industry conversations. Sign in to join the discussion. Vote, reply, and follow industry conversations.
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Link <Link
href={`/forum/login?next=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "")}`} 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 Sign in
</Link> </Link>
<Link <Link
href={`/forum/register?next=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "")}`} 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 Create account
</Link> </Link>

View File

@@ -30,6 +30,11 @@ interface AdvertiserName {
client_id: string; client_id: string;
company_name: string; company_name: string;
company_name_lower: 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 { interface FeaturedSlot {
@@ -47,7 +52,7 @@ async function fetchActiveAdvertisers(property: string): Promise<AdvertiserName[
const svc = createAdminClient("adv"); const svc = createAdminClient("adv");
const { data } = await svc const { data } = await svc
.from("active_advertisers_by_property") .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); .eq("property", property);
return (data || []) as AdvertiserName[]; return (data || []) as AdvertiserName[];
} catch { } catch {
@@ -74,23 +79,123 @@ async function fetchActiveSlots(property: string): Promise<FeaturedSlot[]> {
} }
/** /**
* Return the article's matched advertiser company name (case-insensitive * "Story is about the client" detection
* title contains), or null. Picks the longest match if multiple compete * =====================================
* so "Sony Pictures" wins over "Sony" when both are advertisers. * 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 { function matchAdvertiserInTitle(title: string, advertisers: AdvertiserName[]): AdvertiserName | null {
if (!title || advertisers.length === 0) return null; if (!title || advertisers.length === 0) return null;
const t = title.toLowerCase(); const t = title.toLowerCase();
let best: AdvertiserName | null = null; let best: { adv: AdvertiserName; matchLen: number } | null = null;
for (const a of advertisers) { for (const a of advertisers) {
if (!a.company_name_lower) continue; const candidates: string[] = [
// Word boundary check: avoid "Sony" matching "Sony" inside "Sonyform" etc. a.company_name_lower,
const re = new RegExp(`\\b${a.company_name_lower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i"); ...(a.brand_aliases || []).map((x) => x.toLowerCase()),
if (re.test(t)) { ].filter((c) => !!c && c.length > 0);
if (!best || a.company_name_lower.length > best.company_name_lower.length) best = a; 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 { function withinPromoWindow(dateStr: string): boolean {

View File

@@ -1674,7 +1674,7 @@ a.company-mention:hover {
font-family: var(--font-sans), sans-serif; font-family: var(--font-sans), sans-serif;
font-size: 13px; font-size: 13px;
font-style: italic; font-style: italic;
color: #9a9690; color: #475569;
margin-top: 0.6em; margin-top: 0.6em;
line-height: 1.45; line-height: 1.45;
} }
@@ -1690,23 +1690,27 @@ a.company-mention:hover {
.bb-article-prose hr { .bb-article-prose hr {
border: none; border: none;
height: 1px; height: 1px;
background: rgba(255, 255, 255, 0.08); background: #DCE6F2;
margin: 2.5em 25% 2.5em 25%; 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 { .bb-article-prose strong {
color: #f5f3ee; color: #0F172A;
font-weight: 700; font-weight: 700;
} }
.bb-article-prose em { .bb-article-prose em {
color: #ece9e2; color: #1e293b;
} }
.bb-article-prose code { .bb-article-prose code {
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 0.92em; font-size: 0.92em;
background: #FFFFFF; background: #F1F5F9;
padding: 0.1em 0.4em; padding: 0.1em 0.4em;
border-radius: 3px; border-radius: 3px;
color: #f0eee8; color: #1e293b;
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.bb-article-prose, .bb-article-prose,
@@ -1715,3 +1719,52 @@ a.company-mention:hover {
.bb-article-prose h2 { font-size: 24px; } .bb-article-prose h2 { font-size: 24px; }
.bb-article-prose blockquote { font-size: 19px; } .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;
}
}