Three new product-agnostic packages unlock visible elegance lifts
across every product:
═══════════════════════════════════════════════════════════════════════
@bytelyst/motion@0.1.0 — Wave 4 elegance primitives (2.21 KB / 8 KB)
═══════════════════════════════════════════════════════════════════════
<Reveal> — IntersectionObserver-based fade/slide entry,
6 directions, configurable spring + delay
<StaggerList> — sequenced reveal of children with per-item delay
<NumberFlow> — RAF-tweened number counter, cubic-out easing,
Intl-formatted, prefers-reduced-motion aware
<TiltCard> — 3D perspective tilt + cursor-tracking glare
overlay (single-element ref, no React rerenders)
<ScrollProgress> — fixed scroll-position bar (window or any element)
Plus exported `SPRINGS` (4 cubic-bezier presets) + `prefersReducedMotion`
helper. Every primitive accepts `disableMotion` for snapshot tests.
═══════════════════════════════════════════════════════════════════════
@bytelyst/data-viz@0.1.0 — Wave 5b viz primitives (2.63 KB / 10 KB)
═══════════════════════════════════════════════════════════════════════
<Sparkline> — line trend with gradient fill + last-point marker
<BarSparkline> — discrete-bar mini-chart with max-bar highlight
<KpiCard> — label + headline + delta arrow + sparkline; supports
'goodWhen=lower' for latency/cost metrics
<ProgressRing> — circular progress with center content slot,
animated stroke-dashoffset
<Heatmap> — GitHub-style calendar grid with color-mix() intensity
All pure SVG / CSS — zero runtime dependencies.
═══════════════════════════════════════════════════════════════════════
@bytelyst/notifications-ui@0.1.0 — Wave 7 essentials (3.31 KB / 10 KB)
═══════════════════════════════════════════════════════════════════════
<NotificationCenter> — bell trigger + badge + dropdown panel with
All / Unread / Mentions tabs, outside-click
+ Escape close, mark-all-read action
<InboxItem> — single row with unread dot, kind glyph,
relative timestamp, optional action buttons
<BannerStack> — top-of-page strip with maxVisible + +N more,
accent-bordered tone variants, dismissible
<Announcement> — inline 'What's new' pill (3 tone variants)
5 notification kinds (info/success/warning/danger/mention) + 5 banner
kinds (... + announcement gradient).
═══════════════════════════════════════════════════════════════════════
Quality gates
═══════════════════════════════════════════════════════════════════════
All three packages: tsc --noEmit clean, build clean.
Tests: motion 16/16 · data-viz 14/14 · notifications-ui 17/17
Bundles: motion 2.21 KB · data-viz 2.63 KB · noti-ui 3.31 KB
Budgets: added to .size-limit.cjs (8/10/10 KB respectively)
Refs:
learning_ai_uxui_web/docs/ROADMAP_2026.md
§Wave 4 (Motion), §Wave 5b (Charts), §Wave 7 (Productisation)
Decisions doc §13 (mobile-native = tokens-only) leaves room for these
web-first packages to be the canonical surface
201 lines
5.7 KiB
TypeScript
201 lines
5.7 KiB
TypeScript
import type { CSSProperties } from 'react';
|
|
import type { NotificationItem, NotificationKind } from './types.js';
|
|
|
|
export interface InboxItemProps {
|
|
item: NotificationItem;
|
|
/** Called when the item body is clicked. */
|
|
onSelect?: (item: NotificationItem) => void;
|
|
/** Mark this single item as read. */
|
|
onMarkRead?: (id: string) => void;
|
|
/** Dense layout (smaller padding, smaller font). */
|
|
dense?: boolean;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<InboxItem>` — single notification row. Used inside `<NotificationCenter>`
|
|
* but also exported for products that build custom inbox surfaces.
|
|
*/
|
|
export function InboxItem({
|
|
item,
|
|
onSelect,
|
|
onMarkRead,
|
|
dense,
|
|
className,
|
|
style,
|
|
}: InboxItemProps) {
|
|
const tone = TONE[item.kind ?? 'info'];
|
|
const padding = dense ? '6px 10px' : 'var(--bl-space-2, 8px) var(--bl-space-3, 12px)';
|
|
|
|
return (
|
|
<article
|
|
data-testid={`bl-inbox-item-${item.id}`}
|
|
data-kind={item.kind ?? 'info'}
|
|
data-read={item.read ? 'true' : 'false'}
|
|
className={className}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
gap: 'var(--bl-space-2, 8px)',
|
|
padding,
|
|
borderBottom: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
|
|
background: item.read ? 'transparent' : 'var(--bl-accent-muted, rgba(99,102,241,0.06))',
|
|
...style,
|
|
}}
|
|
>
|
|
{/* Unread dot */}
|
|
<span
|
|
aria-hidden
|
|
style={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: '50%',
|
|
marginTop: 8,
|
|
flexShrink: 0,
|
|
background: item.read ? 'transparent' : tone.dot,
|
|
transition: 'background 200ms ease',
|
|
}}
|
|
/>
|
|
|
|
{/* Avatar / kind icon */}
|
|
<span
|
|
aria-hidden
|
|
style={{
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: 'var(--bl-radius-pill, 999px)',
|
|
background: tone.iconBg,
|
|
color: tone.iconFg,
|
|
display: 'inline-grid',
|
|
placeItems: 'center',
|
|
fontSize: 14,
|
|
fontWeight: 700,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{item.icon ?? tone.glyph}
|
|
</span>
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<button
|
|
type="button"
|
|
data-testid={`bl-inbox-item-trigger-${item.id}`}
|
|
onClick={() => {
|
|
if (!item.read) onMarkRead?.(item.id);
|
|
onSelect?.(item);
|
|
}}
|
|
style={{
|
|
display: 'block',
|
|
width: '100%',
|
|
textAlign: 'left',
|
|
background: 'transparent',
|
|
border: 'none',
|
|
padding: 0,
|
|
color: 'inherit',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontSize: dense ? '0.8rem' : '0.85rem',
|
|
fontWeight: 600,
|
|
color: 'var(--bl-text-primary, inherit)',
|
|
}}
|
|
>
|
|
{item.title}
|
|
</div>
|
|
{item.body && (
|
|
<div
|
|
style={{
|
|
fontSize: dense ? '0.7rem' : '0.75rem',
|
|
color: 'var(--bl-text-secondary, #555)',
|
|
marginTop: 2,
|
|
whiteSpace: 'pre-wrap',
|
|
}}
|
|
>
|
|
{item.body}
|
|
</div>
|
|
)}
|
|
{item.createdAt && (
|
|
<div
|
|
style={{
|
|
fontSize: '0.7rem',
|
|
color: 'var(--bl-text-tertiary, #888)',
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{relativeTime(item.createdAt)}
|
|
</div>
|
|
)}
|
|
</button>
|
|
{item.actions && item.actions.length > 0 && (
|
|
<div style={{ marginTop: 6, display: 'flex', gap: 6 }}>
|
|
{item.actions.map(a => (
|
|
<button
|
|
key={a.id}
|
|
type="button"
|
|
onClick={a.onSelect}
|
|
style={{
|
|
fontSize: '0.7rem',
|
|
padding: '2px 8px',
|
|
borderRadius: 'var(--bl-radius-pill, 999px)',
|
|
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
|
|
background: 'transparent',
|
|
color: 'var(--bl-text-secondary, #555)',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{a.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
const TONE: Record<NotificationKind, { dot: string; iconBg: string; iconFg: string; glyph: string }> = {
|
|
info: {
|
|
dot: 'var(--bl-info, #38bdf8)',
|
|
iconBg: 'var(--bl-info-muted, rgba(56,189,248,0.18))',
|
|
iconFg: 'var(--bl-info, #38bdf8)',
|
|
glyph: 'i',
|
|
},
|
|
success: {
|
|
dot: 'var(--bl-success, #22c55e)',
|
|
iconBg: 'var(--bl-success-muted, rgba(34,197,94,0.18))',
|
|
iconFg: 'var(--bl-success, #22c55e)',
|
|
glyph: '✓',
|
|
},
|
|
warning: {
|
|
dot: 'var(--bl-warning, #f59e0b)',
|
|
iconBg: 'var(--bl-warning-muted, rgba(245,158,11,0.18))',
|
|
iconFg: 'var(--bl-warning, #f59e0b)',
|
|
glyph: '!',
|
|
},
|
|
danger: {
|
|
dot: 'var(--bl-danger, #ef4444)',
|
|
iconBg: 'var(--bl-danger-muted, rgba(239,68,68,0.18))',
|
|
iconFg: 'var(--bl-danger, #ef4444)',
|
|
glyph: '✕',
|
|
},
|
|
mention: {
|
|
dot: 'var(--bl-accent, #6366f1)',
|
|
iconBg: 'var(--bl-accent-muted, rgba(99,102,241,0.18))',
|
|
iconFg: 'var(--bl-accent, #6366f1)',
|
|
glyph: '@',
|
|
},
|
|
};
|
|
|
|
function relativeTime(input: string | Date): string {
|
|
const d = typeof input === 'string' ? new Date(input) : input;
|
|
const diff = (Date.now() - d.getTime()) / 1000;
|
|
if (diff < 60) return 'just now';
|
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`;
|
|
return d.toLocaleDateString();
|
|
}
|