import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import {
motion,
AnimatePresence,
useReducedMotion,
LayoutGroup,
} from "framer-motion";
// Notification center: a bell button that opens a glassmorphism popover with a
// segmented switch toggling between a notifications list and a "What's new" feed.
//
// Drop <NotificationCenter /> anywhere with room below-right (e.g. an app top bar).
// The popover anchors to the bell's top-right corner and scales from that origin.
// Motion: a spring entrance, a shared-layout sliding pill (layoutId), and a
// direction-aware crossfade between panels — all disabled under reduced motion.
//
// Swap NOTIFICATIONS / ANNOUNCEMENTS for your data. Images here live under
// /playground/notification-center/ — point them at your own assets.
export default function NotificationCenter() {
const reduce = useReducedMotion();
const uid = useId();
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<TabId>("notifications");
const [dir, setDir] = useState(1);
const [peek, setPeek] = useState(false); // inactive tab hovered → active pill leans toward it
const wrapRef = useRef<HTMLDivElement>(null);
const bellRef = useRef<HTMLButtonElement>(null);
const bodyRef = useRef<HTMLDivElement>(null); // shared scroll container — reset to top on tab switch
const tabRefs = useRef<Record<TabId, HTMLButtonElement | null>>({
notifications: null,
whatsnew: null,
});
const tabIndex = (id: TabId) => TABS.findIndex((t) => t.id === id);
function selectTab(id: TabId) {
setDir(tabIndex(id) > tabIndex(tab) ? 1 : -1);
setTab(id);
setPeek(false); // clear the lean before the pill slides
}
// While open: close on outside click (mousedown beats the bell's onClick) and
// on Escape. The Escape listener is capture-phase + stopPropagation so it
// closes the popover before any ancestor/global Escape handler can react,
// regardless of where focus currently sits.
useEffect(() => {
if (!open) return;
function onDown(e: MouseEvent) {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.stopPropagation();
e.preventDefault();
setOpen(false);
bellRef.current?.focus();
}
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey, true);
};
}, [open]);
// Move focus into the popover when it opens.
useEffect(() => {
if (open) tabRefs.current[tab]?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
function onTabKeyDown(e: React.KeyboardEvent) {
const i = tabIndex(tab);
let next: TabId | null = null;
if (e.key === "ArrowRight") next = TABS[(i + 1) % TABS.length].id;
else if (e.key === "ArrowLeft") next = TABS[(i - 1 + TABS.length) % TABS.length].id;
else if (e.key === "Home") next = TABS[0].id;
else if (e.key === "End") next = TABS[TABS.length - 1].id;
if (next) {
e.preventDefault();
selectTab(next);
tabRefs.current[next]?.focus();
}
}
const popSpring = reduce
? { duration: 0 }
: { type: "spring" as const, duration: 0.34, bounce: 0 };
const pillSpring = reduce
? { duration: 0 }
: { type: "spring" as const, duration: 0.4, bounce: 0 };
const panelTween = { duration: reduce ? 0 : 0.22, ease: [0.22, 0.61, 0.36, 1] as const };
const slide = reduce ? 0 : 8;
// Anticipatory lean: the active pill stretches ~2px toward a hovered inactive
// tab (177px pill × 0.0113 ≈ 2px), anchored on its far edge.
const peekScale = reduce ? 1 : 1.0113;
const peekSpring = reduce
? { duration: 0 }
: { type: "spring" as const, duration: 0.6, bounce: 0 };
return (
<div className="nc-bell-wrap" ref={wrapRef}>
<style>{styles}</style>
<button
ref={bellRef}
type="button"
className="nc-bell"
aria-label="Notifications"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<BellIcon />
<span className="nc-bell-dot" aria-hidden="true" />
</button>
<AnimatePresence>
{open && (
<motion.div
className="nc-popover"
role="dialog"
aria-label="Notification center"
aria-modal="false"
initial={{ opacity: 0, scale: 0.96, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: -2 }}
transition={popSpring}
style={{ transformOrigin: "top right" }}
>
<div className="nc-header">
<LayoutGroup id={uid}>
<div className="nc-switch" role="tablist" aria-label="Notification view">
{TABS.map((t) => {
const active = tab === t.id;
return (
<button
key={t.id}
ref={(el) => {
tabRefs.current[t.id] = el;
}}
type="button"
role="tab"
id={`${uid}-tab-${t.id}`}
aria-selected={active}
aria-controls={`${uid}-panel-${t.id}`}
tabIndex={active ? 0 : -1}
className={`nc-tab${active ? " is-active" : ""}`}
onClick={() => selectTab(t.id)}
onMouseEnter={() => setPeek(!active)}
onMouseLeave={() => setPeek(false)}
onKeyDown={onTabKeyDown}
>
{active && (
<motion.span
layoutId="nc-pill"
className="nc-pill"
style={{ originX: tabIndex(tab) === 0 ? 0 : 1 }}
animate={{ scaleX: peek ? peekScale : 1 }}
transition={{ layout: pillSpring, scaleX: peekSpring }}
/>
)}
<span className="nc-tab-label">{t.label}</span>
</button>
);
})}
</div>
</LayoutGroup>
</div>
<div className="nc-body" ref={bodyRef}>
<AnimatePresence mode="wait" initial={false} custom={dir}>
{tab === "notifications" ? (
<motion.div
key="notifications"
role="tabpanel"
id={`${uid}-panel-notifications`}
aria-labelledby={`${uid}-tab-notifications`}
tabIndex={0}
initial={{ opacity: 0, x: dir * slide }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: dir * -slide }}
transition={panelTween}
>
<ScrollReset containerRef={bodyRef} />
<ul className="nc-list">
{NOTIFICATIONS.map((n) => (
<li key={n.id} className="nc-row">
<div className="nc-avatar">
{n.logo ? (
<img
src={n.logo.src}
alt={n.logo.ticker}
width={32}
height={32}
className="nc-avatar-logo"
translate="no"
/>
) : (
<span className="nc-avatar-glyph" aria-hidden="true">
<Glyph name={n.glyph!} />
</span>
)}
</div>
<div className="nc-row-body">
<p className="nc-row-msg">{n.message}</p>
<p className="nc-row-time">{n.time}</p>
</div>
</li>
))}
</ul>
</motion.div>
) : (
<motion.div
key="whatsnew"
role="tabpanel"
id={`${uid}-panel-whatsnew`}
aria-labelledby={`${uid}-tab-whatsnew`}
tabIndex={0}
initial={{ opacity: 0, x: dir * slide }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: dir * -slide }}
transition={panelTween}
>
<ScrollReset containerRef={bodyRef} />
<div className="nc-feed">
{ANNOUNCEMENTS.map((a, i) => (
<div key={a.id}>
{i > 0 && <div className="nc-divider" />}
<article className={`nc-item nc-item--${a.layout}`}>
{a.layout === "hero" ? (
<>
<div className="nc-item-text">
<div className="nc-item-top">
{a.unread && <span className="nc-blip" aria-hidden="true" />}
<h3 className="nc-item-title">{a.title}</h3>
<ArrowIcon />
</div>
<p className="nc-item-body">{a.body}</p>
</div>
<img
className="nc-hero-img"
src={a.image}
alt={a.alt}
width={360}
height={182}
loading="lazy"
/>
<p className="nc-item-date">{a.date}</p>
</>
) : (
<>
<div className="nc-item-row">
<div className="nc-item-content">
<div className="nc-item-text">
<div className="nc-item-top">
{a.unread && <span className="nc-blip" aria-hidden="true" />}
<h3 className="nc-item-title">{a.title}</h3>
<ArrowIcon />
</div>
<p className="nc-item-body nc-clamp">{a.body}</p>
</div>
<p className="nc-item-date">{a.date}</p>
</div>
<img
className="nc-thumb"
src={a.image}
alt={a.alt}
width={136}
height={102}
loading="lazy"
/>
</div>
</>
)}
</article>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// ----- Icons -----
function Glyph({ name }: { name: "premium" | "gift" | "coin" }) {
if (name === "premium") {
return (
<svg viewBox="0 0 15.869 13.0751" width="16" height="16" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" clipRule="evenodd" d="M10.7016 0C11.5166 0 12.2947 0.343337 12.8441 0.945312L15.4379 3.78809C16.0127 4.41809 16.0127 5.38269 15.4379 6.0127L9.70742 12.293C8.75571 13.3359 7.11332 13.3358 6.16152 12.293L0.43105 6.0127C-0.143604 5.38267 -0.143763 4.41802 0.43105 3.78809L3.0248 0.945312C3.57413 0.343494 4.35157 0.000152031 5.1664 0H10.7016ZM2.85683 6.00098L7.49062 11.0791C7.72856 11.3399 8.1394 11.3399 8.37734 11.0791L13.0102 6.00098H2.85683ZM5.1664 1.80078C4.8574 1.80093 4.5622 1.93091 4.3539 2.15918L2.49062 4.2002H13.3773L11.5141 2.15918C11.3057 1.93094 11.0106 1.80078 10.7016 1.80078H5.1664Z" />
</svg>
);
}
if (name === "gift") {
return (
<svg viewBox="0 0 14.8008 14.9005" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M9.05859 0.0674641C9.51838 -0.121025 10.0447 0.0990024 10.2334 0.558676C10.4219 1.01849 10.2019 1.54483 9.74219 1.73348C9.32212 1.90571 9.00312 2.11838 8.74707 2.40048H12.4004C13.7259 2.40048 14.8008 3.47539 14.8008 4.80087V6.00107C14.8007 6.55197 14.6131 7.05804 14.3008 7.46299V12.0001C14.3008 13.6016 13.0021 14.9005 11.4004 14.9005H3.40039C1.79876 14.9005 0.5 13.6017 0.5 12.0001V7.46299C0.187717 7.05804 0.000107375 6.55197 0 6.00107V4.80087C0 3.47539 1.07491 2.40048 2.40039 2.40048H6.05371C5.79766 2.11838 5.47866 1.90571 5.05859 1.73348C4.59887 1.54483 4.37886 1.01849 4.56738 0.558676C4.75605 0.0990024 5.2824 -0.121025 5.74219 0.0674641C6.41828 0.344696 6.96437 0.714891 7.40039 1.1993C7.83641 0.714891 8.3825 0.344696 9.05859 0.0674641ZM2.30078 12.0001C2.30078 12.6076 2.79288 13.1007 3.40039 13.1007H6.55078V8.40049H2.40039C2.36698 8.40049 2.33386 8.39598 2.30078 8.39463V12.0001ZM12.4004 8.40049H8.25V13.1007H11.4004C12.0077 13.1006 12.5 12.6077 12.5 12.0001V8.39463C12.4669 8.39598 12.4338 8.40049 12.4004 8.40049ZM2.40039 4.20126C2.06902 4.20126 1.80078 4.4695 1.80078 4.80087V6.00107C1.80104 6.33222 2.06918 6.60068 2.40039 6.60068H6.55078V4.20126H2.40039ZM8.25 6.60068H12.4004C12.7316 6.60068 12.9997 6.33222 13 6.00107V4.80087C13 4.4695 12.7318 4.20126 12.4004 4.20126H8.25V6.60068Z" />
</svg>
);
}
return (
<svg viewBox="0 0 15 15" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M7.5 2.7627C7.94183 2.7627 8.2998 3.12067 8.2998 3.5625V3.80469C8.6895 3.90197 9.07847 4.06936 9.44629 4.31641C9.81287 4.56281 9.91035 5.06006 9.66406 5.42676C9.41765 5.79331 8.9204 5.89082 8.55371 5.64453C8.00666 5.27716 7.41317 5.23751 6.9834 5.36816C6.52109 5.50886 6.4248 5.75745 6.4248 5.86523C6.42487 6.06215 6.48862 6.17401 6.6748 6.30664C6.91572 6.47818 7.26627 6.60817 7.76465 6.7832C8.20344 6.93731 8.79083 7.13683 9.25293 7.46582C9.76958 7.83377 10.1748 8.39422 10.1748 9.21191C10.1748 10.2969 9.31354 11.0002 8.38477 11.2168C8.35675 11.2233 8.32818 11.2276 8.2998 11.2334V11.4375C8.2998 11.8793 7.94183 12.2373 7.5 12.2373C7.05817 12.2373 6.7002 11.8793 6.7002 11.4375V11.1768C6.19385 11.0479 5.67467 10.8161 5.1709 10.4688C4.80728 10.2179 4.71603 9.72014 4.9668 9.35645C5.21763 8.99282 5.71541 8.90157 6.0791 9.15234C6.85971 9.69052 7.56966 9.7645 8.02148 9.65918C8.49894 9.54786 8.5752 9.30529 8.5752 9.21191C8.57518 9.01458 8.51153 8.90227 8.3252 8.76953C8.08428 8.59796 7.73375 8.46801 7.23535 8.29297C6.7965 8.13885 6.20919 7.93936 5.74707 7.61035C5.23049 7.24255 4.82529 6.68277 4.8252 5.86523C4.8252 4.79436 5.6666 4.09677 6.5166 3.83789C6.57686 3.81955 6.63808 3.80287 6.7002 3.78809V3.5625C6.7002 3.12067 7.05817 2.7627 7.5 2.7627Z" />
<path fillRule="evenodd" clipRule="evenodd" d="M7.5 0C11.642 0 14.9998 3.35804 15 7.5C14.9998 11.642 11.642 15 7.5 15C3.35817 14.9998 0.000197928 11.6418 0 7.5C0.000211036 3.35817 3.35817 0.000211043 7.5 0ZM7.5 1.80078C4.35229 1.80099 1.80099 4.35229 1.80078 7.5C1.80098 10.6477 4.35228 13.2 7.5 13.2002C10.6479 13.2002 13.2 10.6479 13.2002 7.5C13.2 4.35216 10.6479 1.80078 7.5 1.80078Z" />
</svg>
);
}
function BellIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" aria-hidden="true">
<path d="M16 17C16 19.2091 14.2091 21 12 21C9.79086 21 8 19.2091 8 17" />
<path d="M20 16.191C20 16.6378 19.6378 17 19.191 17H4.80902C4.36221 17 4 16.6378 4 16.191C4 16.0654 4.02924 15.9415 4.08541 15.8292L5.21846 13.5631C5.40413 13.1917 5.51071 12.7859 5.53144 12.3712L5.70037 8.99251C5.86822 5.63561 8.6389 3 12 3C15.3611 3 18.1318 5.63561 18.2996 8.99251L18.4686 12.3712C18.4893 12.7859 18.5959 13.1917 18.7815 13.5631L19.9146 15.8292C19.9708 15.9415 20 16.0654 20 16.191Z" strokeLinecap="square" />
</svg>
);
}
// Resets the shared scroll container to the top the instant a panel mounts —
// pre-paint and while the panel is still at opacity 0, so flipping tabs always
// starts at the top without the outgoing list visibly jumping.
function ScrollReset({ containerRef }: { containerRef: React.RefObject<HTMLDivElement | null> }) {
useLayoutEffect(() => {
if (containerRef.current) containerRef.current.scrollTop = 0;
}, []);
return null;
}
function ArrowIcon() {
return (
<svg className="nc-item-arrow" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8.36328 3.36334C8.71475 3.01186 9.28525 3.01186 9.63672 3.36334L13.6367 7.36334C13.9882 7.71481 13.9882 8.28531 13.6367 8.63677L9.63672 12.6368C9.28526 12.9882 8.71474 12.9882 8.36328 12.6368C8.01182 12.2853 8.01184 11.7148 8.36328 11.3633L10.8262 8.90044H3C2.50297 8.90044 2.09965 8.49708 2.09961 8.00005C2.09961 7.503 2.50294 7.09966 3 7.09966H10.8262L8.36328 4.63677C8.01182 4.28531 8.01184 3.71481 8.36328 3.36334Z" fill="currentColor" />
</svg>
);
}
// ----- Data -----
const TABS = [
{ id: "notifications", label: "Notifications" },
{ id: "whatsnew", label: "What’s new" },
] as const;
type TabId = (typeof TABS)[number]["id"];
const ASSETS = "/playground/notification-center";
type Notification = {
id: number;
glyph?: "premium" | "gift" | "coin";
logo?: { src: string; ticker: string };
message: string;
time: string;
};
const NOTIFICATIONS: Notification[] = [
{ id: 2, logo: { src: `${ASSETS}/logo-aapl.png`, ticker: "Apple" }, message: "Your limit order to buy 15 shares of AAPL was completed.", time: "7 hours ago" },
{ id: 3, logo: { src: `${ASSETS}/logo-aapl.png`, ticker: "Apple" }, message: "Your price target of $310.00 USD for AAPL has been reached.", time: "10 hours ago" },
{ id: 4, logo: { src: `${ASSETS}/logo-nvda.png`, ticker: "Nvidia" }, message: "Your limit order to buy 50 shares of NVDA was completed.", time: "10 hours ago" },
{ id: 5, logo: { src: `${ASSETS}/logo-amzn.png`, ticker: "Amazon" }, message: "Your target price of $255.00 USD for AMZN has been reached.", time: "a day ago" },
{ id: 6, glyph: "gift", message: "The time has come! Check if you won $1,000,000 by June 2 at 11:59 PM. T&Cs apply.", time: "a day ago" },
{ id: 7, glyph: "coin", message: "Your INTERAC e-Transfer was deposited into Jeff Bridges account.", time: "a day ago" },
{ id: 8, glyph: "coin", message: "Your INTERAC e-Transfer was deposited into Jeff Bridges account.", time: "a day ago" },
{ id: 9, glyph: "coin", message: "See your interest payout for this month.", time: "2 days ago" },
{ id: 1, glyph: "premium", message: "Now that you have $500,000 with Wealthsimple, say hello to new benefits.", time: "23 days ago" },
];
type Announcement = {
id: number;
layout: "hero" | "row";
unread: boolean;
title: string;
body: string;
image: string;
alt: string;
date: string;
};
const IPO_BODY =
"Get shares at IPO prices — before the market does. Browse our list of initial public offerings (IPO) for access to companies about to go public.";
const ANNOUNCEMENTS: Announcement[] = [
{ id: 1, layout: "hero", unread: true, title: "IPO access unlocked", body: IPO_BODY, image: `${ASSETS}/img-ipo.png`, alt: "A glowing keyhole emitting light in a dark room.", date: "May 28, 2026" },
{ id: 2, layout: "row", unread: false, title: "We’re giving away a million dollars, every month", body: "Buy in bulk. Become a millionaire. T&Cs apply.", image: `${ASSETS}/img-million.png`, alt: "Coins falling into a glass jar of cash.", date: "May 28, 2026" },
{ id: 3, layout: "row", unread: false, title: "Hate your business account? We made a better one.", body: "Running a business comes with a whole lot of headaches, and your bank is probably one of th…", image: `${ASSETS}/img-business.png`, alt: "A copper coil and a studded metal disc on a pale concrete floor.", date: "Apr 23, 2026" },
{ id: 4, layout: "row", unread: false, title: "Introducing The Trade Show", body: "A new live event all about trading and new Wealthsimple trading products.", image: `${ASSETS}/img-tradeshow.png`, alt: "A promotional graphic for The Trade Show, a trading broadcast.", date: "Apr 11, 2026" },
{ id: 5, layout: "row", unread: false, title: "We’re giving away a 1-kilogram bar of gold", body: "Want to know what a 1-kilogram bar of gold feels like in your hand? Then this is the contest…", image: `${ASSETS}/img-gold.png`, alt: "An upright gold bar engraved with a W.", date: "Nov 5, 2025" },
];
// ----- Styles -----
const styles = `
.nc-bell-wrap {
/* ---- Theme tokens — light-dark(<light>, <dark>); the dark arm is the
original design, tune the light arm here. Glass doesn't invert 1:1: light
glass is a near-white translucent fill + hairline border + soft shadow.
Multi-shadow lists use helper vars (light-dark() takes two args). Inherits
the page color-scheme, so these resolve to the visitor's theme. ---- */
--nc-title: light-dark(#1a1a1a, #fafafa);
--nc-muted: light-dark(#5f5f5c, #a3a3a3);
--nc-soft: light-dark(rgba(0, 0, 0, 0.45), rgba(255, 255, 255, 0.5));
--nc-ink-strong: light-dark(#111, #fff);
--nc-accent: #ed4f3c;
--nc-outline: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.12));
--nc-hairline: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.08));
--nc-divider: light-dark(rgba(0, 0, 0, 0.07), rgba(255, 255, 255, 0.06));
--nc-hover: light-dark(rgba(0, 0, 0, 0.045), rgba(255, 255, 255, 0.04));
--nc-focus: light-dark(rgba(0, 0, 0, 0.4), rgba(255, 255, 255, 0.55));
--nc-scroll-thumb: light-dark(rgba(0, 0, 0, 0.22), rgba(255, 255, 255, 0.24));
--nc-scroll-thumb-hover: light-dark(rgba(0, 0, 0, 0.34), rgba(255, 255, 255, 0.40));
--nc-glass-bg: light-dark(rgba(252, 252, 250, 0.78), rgba(38, 38, 38, 0.9));
--nc-glass-border: light-dark(rgba(0, 0, 0, 0.1), #3e3f40);
--nc-glass-shadow-light:
0 0 0 1px rgba(0, 0, 0, 0.04),
0 8px 24px rgba(0, 0, 0, 0.1),
0 24px 60px -12px rgba(0, 0, 0, 0.16);
--nc-glass-shadow-dark:
0 0 0 1px rgba(0, 0, 0, 0.3),
0 8px 24px rgba(0, 0, 0, 0.36),
0 24px 60px -12px rgba(0, 0, 0, 0.5);
--nc-glass-shadow: light-dark(var(--nc-glass-shadow-light), var(--nc-glass-shadow-dark));
--nc-header-bg: light-dark(rgba(255, 255, 255, 0.5), rgba(37, 37, 37, 0.6));
--nc-track-bg: light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.5));
--nc-track-border: light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.08));
--nc-pill-bg: light-dark(rgba(255, 255, 255, 0.92), rgba(38, 38, 38, 0.5));
--nc-pill-border: light-dark(rgba(0, 0, 0, 0.06), #3e3f40);
--nc-pill-shadow: light-dark(0 1px 2px rgba(0, 0, 0, 0.12), 0 7px 26px rgba(0, 0, 0, 0.12));
--nc-bell-ink: light-dark(rgba(0, 0, 0, 0.6), rgba(255, 255, 255, 0.7));
--nc-bell-bg-hover: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.06));
--nc-bell-bg-active: light-dark(rgba(0, 0, 0, 0.07), rgba(255, 255, 255, 0.08));
--nc-stage: light-dark(#F7F7F4, #0D0D0D); /* page/top-bar bg behind the bell (matches the site canvas) */
/* Ring tracks the stage so it can never drift. Keep in sync with the live
file's --nc-bell-dot-ring (which points at the site's --background). */
--nc-bell-dot-ring: var(--nc-stage);
position: relative;
display: inline-flex;
font-family: var(--font-sans, system-ui, sans-serif);
}
.nc-bell-wrap *, .nc-bell-wrap *::before, .nc-bell-wrap *::after { box-sizing: border-box; }
/* ---- Bell ---- */
.nc-bell {
position: relative;
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--nc-bell-ink);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
transition: background-color 160ms ease, color 160ms ease, transform 125ms ease;
}
.nc-bell:hover { background: var(--nc-bell-bg-hover); color: var(--nc-ink-strong); }
.nc-bell:active { transform: scale(0.94); }
.nc-bell[aria-expanded="true"] { background: var(--nc-bell-bg-active); color: var(--nc-ink-strong); }
.nc-bell:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--nc-stage), 0 0 0 4px var(--nc-focus);
}
.nc-bell-dot {
position: absolute;
top: 8px;
right: 9px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--nc-accent);
border: 1.5px solid var(--nc-bell-dot-ring); /* matches the stage canvas */
}
/* ---- Popover shell ---- */
.nc-popover {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: min(396px, calc(100vw - 32px));
display: flex;
flex-direction: column;
background: var(--nc-glass-bg);
backdrop-filter: blur(100px) saturate(1.4);
-webkit-backdrop-filter: blur(100px) saturate(1.4);
border: 1px solid var(--nc-glass-border);
border-radius: 18px;
box-shadow: var(--nc-glass-shadow);
overflow: clip;
z-index: 20;
}
/* ---- Header + switch ---- */
.nc-header {
flex-shrink: 0;
height: 68px;
display: flex;
align-items: center;
padding: 0 16px;
background: var(--nc-header-bg);
border-bottom: 1px solid var(--nc-outline);
}
.nc-switch {
display: flex;
width: 100%;
padding: 2px;
gap: 2px;
border-radius: 10px;
background: var(--nc-track-bg);
border: 1px solid var(--nc-track-border);
}
.nc-tab {
position: relative;
flex: 1 0 0;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 12px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
color: var(--nc-soft);
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
transition: color 200ms ease;
}
.nc-tab.is-active { color: var(--nc-ink-strong); }
/* Brighten the inactive tab's label on hover to signal clickability (animated
by the existing color transition). Scoped so the active tab stays strong. */
.nc-tab:not(.is-active):hover { color: light-dark(rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.8)); }
.nc-tab:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--nc-focus);
}
.nc-tab-label {
position: relative;
z-index: 1;
font-size: 12px;
line-height: 16px;
font-weight: 500;
letter-spacing: 0.18px;
}
.nc-pill {
position: absolute;
inset: 0;
z-index: 0;
border-radius: 8px;
background: var(--nc-pill-bg);
border: 1px solid var(--nc-pill-border);
box-shadow: var(--nc-pill-shadow);
}
/* ---- Scroll body ---- */
.nc-body {
flex: 1;
min-height: 0;
overflow-y: auto;
/* Clip the transient horizontal overflow from the tab-switch slide (panels
translate x: ±8px). Without this, overflow-y:auto forces overflow-x to
compute to auto, flashing a horizontal scrollbar mid-transition. */
overflow-x: clip;
overscroll-behavior: contain;
max-height: min(60vh, 468px);
/* Firefox: thin recolored bar, transparent track. */
scrollbar-width: thin;
scrollbar-color: var(--nc-scroll-thumb) transparent;
}
.nc-body > *:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--nc-focus); border-radius: 12px; }
/* Chromium/Safari: hide the track, keep a slim recolored thumb. Styling
::-webkit-scrollbar opts out of the macOS overlay scrollbar and reserves a
~6px gutter (no pure-CSS overlay exists) — an accepted tradeoff for a
persistent, recolored pill. Matches the site-wide scrollbar (see global.css):
6px pill, faint-always-visible, darkening on container hover. */
.nc-body::-webkit-scrollbar { width: 6px; }
.nc-body::-webkit-scrollbar-track { background: transparent; }
.nc-body::-webkit-scrollbar-thumb { background: var(--nc-scroll-thumb); border-radius: 100px; }
.nc-body:hover::-webkit-scrollbar-thumb { background: var(--nc-scroll-thumb-hover); }
/* ---- Notifications list ---- */
/* Uniform 6px padding so each row's hover highlight sits an even 6px from the
list edges on every side (first row's content lands 18px below the header). */
.nc-list { list-style: none; margin: 0; display: flex; flex-direction: column; gap: 2px; padding: 6px; }
.nc-row {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px;
border-radius: 12px;
transition: background-color 160ms ease;
}
.nc-row:hover { background: var(--nc-hover); }
.nc-avatar { flex-shrink: 0; width: 32px; height: 32px; }
.nc-avatar-logo { width: 32px; height: 32px; border-radius: 50%; display: block; }
.nc-avatar-glyph {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 999px;
background: var(--nc-hover);
color: var(--nc-title);
}
.nc-row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding-top: 1px; }
.nc-row-msg { margin: 0; font-size: 14px; line-height: 20px; letter-spacing: 0.14px; color: var(--nc-muted); }
.nc-row-time { margin: 0; font-size: 12px; line-height: 16px; letter-spacing: 0.18px; color: var(--nc-soft); }
/* ---- What's new feed (spacing per the Paper artboard) ---- */
/* Feed has no gap/padding and dividers no margin — the breathing room around
each divider (and around the hover cards) comes from the item padding (hero
18px, rows 16px); the first/last item's hover card lands an even 6px in. */
.nc-feed { display: flex; flex-direction: column; gap: 0; padding: 0; }
.nc-divider { height: 1px; width: 100%; background: var(--nc-divider); }
.nc-item { display: flex; flex-direction: column; position: relative; isolation: isolate; }
/* Hover card — mirrors the notifications highlight. The ::before sits inside the
item's padding (~12px around the content) and floats ~6px off the dividers;
only the background fades in, so the resting layout is untouched. */
.nc-item::before {
content: "";
position: absolute;
inset: 6px;
border-radius: 12px;
background-color: transparent;
transition: background-color 160ms ease;
z-index: -1;
}
.nc-item:hover::before { background-color: var(--nc-hover); }
.nc-item--hero { gap: 12px; padding: 18px; }
.nc-item--row { padding: 16px 18px; }
.nc-item-row { display: flex; gap: 16px; align-items: flex-start; }
.nc-item-content { display: flex; flex-direction: column; gap: 12px; flex: 1; min-width: 0; }
.nc-item-text { display: flex; flex-direction: column; gap: 4px; }
.nc-item-top { display: flex; align-items: center; gap: 8px; min-width: 0; }
.nc-blip { flex-shrink: 0; width: 8px; height: 8px; border-radius: 50%; background: var(--nc-accent); }
/* Title truncates to a single line; the trailing arrow keeps its in-flow slot
(flex-shrink: 0 + the 8px gap) so the ellipsis lands before it and the
hover-reveal causes no reflow. */
.nc-item-title { margin: 0; font-size: 14px; line-height: 20px; font-weight: 700; color: var(--nc-title); flex: 0 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Hover affordance: a right-arrow that appears after the title, signalling
the item links somewhere. Decorative (aria-hidden). */
.nc-item-arrow { flex-shrink: 0; color: var(--nc-title); opacity: 0; }
.nc-item:hover .nc-item-arrow { opacity: 1; }
.nc-item-body { margin: 0; font-size: 14px; line-height: 20px; letter-spacing: 0.14px; color: var(--nc-muted); }
.nc-clamp { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.nc-item-date { margin: 0; font-size: 12px; line-height: 16px; letter-spacing: 0.18px; color: var(--nc-soft); }
.nc-hero-img {
width: 100%;
aspect-ratio: 360 / 182;
height: auto;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--nc-hairline);
}
.nc-thumb {
flex-shrink: 0;
width: 128px;
aspect-ratio: 3 / 2;
object-fit: cover;
border-radius: 12px;
border: 1px solid var(--nc-hairline);
}
@media (max-width: 420px) { .nc-thumb { width: 104px; } }
@media (prefers-reduced-motion: reduce) {
.nc-bell-wrap *, .nc-bell-wrap *::before, .nc-bell-wrap *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
`;