diff --git a/client/package-lock.json b/client/package-lock.json
index 2893612..9fb680a 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -14,6 +14,7 @@
"@tanstack/react-query-devtools": "^5.100.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
"lucide-react": "^1.14.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
@@ -4844,6 +4845,16 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
diff --git a/client/package.json b/client/package.json
index 3f89647..753d57e 100644
--- a/client/package.json
+++ b/client/package.json
@@ -18,6 +18,7 @@
"@tanstack/react-query-devtools": "^5.100.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
"lucide-react": "^1.14.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
diff --git a/client/src/components/Avatar.tsx b/client/src/components/Avatar.tsx
new file mode 100644
index 0000000..5566f7f
--- /dev/null
+++ b/client/src/components/Avatar.tsx
@@ -0,0 +1,34 @@
+import { cn } from "@/lib/utils";
+import { colorIndex, initials } from "@/lib/avatar";
+
+const VARIANTS = [
+ "bg-avatar-1-bg text-avatar-1-fg",
+ "bg-avatar-2-bg text-avatar-2-fg",
+ "bg-avatar-3-bg text-avatar-3-fg",
+ "bg-avatar-4-bg text-avatar-4-fg",
+ "bg-avatar-5-bg text-avatar-5-fg",
+] as const;
+
+export function Avatar({
+ name,
+ size = "md",
+ className,
+}: {
+ name: string;
+ size?: "md" | "lg";
+ className?: string;
+}) {
+ return (
+
+ {initials(name)}
+
+ );
+}
diff --git a/client/src/components/EmptyState.tsx b/client/src/components/EmptyState.tsx
new file mode 100644
index 0000000..a841e62
--- /dev/null
+++ b/client/src/components/EmptyState.tsx
@@ -0,0 +1,32 @@
+import { type LucideIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export function EmptyState({
+ icon: Icon,
+ headline,
+ subtext,
+ cta,
+ className,
+}: {
+ icon: LucideIcon;
+ headline: string;
+ subtext?: string;
+ cta?: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+
+
{headline}
+ {subtext && (
+
{subtext}
+ )}
+ {cta &&
{cta}
}
+
+ );
+}
diff --git a/client/src/components/EventBadge.tsx b/client/src/components/EventBadge.tsx
new file mode 100644
index 0000000..1323f50
--- /dev/null
+++ b/client/src/components/EventBadge.tsx
@@ -0,0 +1,20 @@
+import { cn } from "@/lib/utils";
+
+export function EventBadge({
+ label,
+ className,
+}: {
+ label: string;
+ className?: string;
+}) {
+ return (
+
+ {label}
+
+ );
+}
diff --git a/client/src/components/RingBadge.tsx b/client/src/components/RingBadge.tsx
new file mode 100644
index 0000000..fa16c1a
--- /dev/null
+++ b/client/src/components/RingBadge.tsx
@@ -0,0 +1,37 @@
+import { cn } from "@/lib/utils";
+
+type Ring = "inner_circle" | "network" | "community" | "acquaintances";
+
+const LABELS: Record = {
+ inner_circle: "Inner circle",
+ network: "Network",
+ community: "Community",
+ acquaintances: "Acquaintances",
+};
+
+const VARIANTS: Record = {
+ inner_circle: "bg-tier-inner-bg text-tier-inner-fg",
+ network: "bg-tier-network-bg text-tier-network-fg",
+ community: "bg-tier-community-bg text-tier-community-fg",
+ acquaintances: "bg-tieracquaintances-bg text-tier-acquaintances-fg",
+};
+
+export function RingBadge({
+ ring,
+ className,
+}: {
+ ring: Ring;
+ className?: string;
+}) {
+ return (
+
+ {LABELS[ring]}
+
+ );
+}
diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..c206c86
--- /dev/null
+++ b/client/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,199 @@
+"use client"
+
+import * as React from "react"
+import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps & {
+ size?: "default" | "sm"
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ Pick, "variant" | "size">) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ Pick, "variant" | "size">) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+}
diff --git a/client/src/components/ui/textarea.tsx b/client/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..04d27f7
--- /dev/null
+++ b/client/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/client/src/components/ui/tooltip.tsx b/client/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..7413f6e
--- /dev/null
+++ b/client/src/components/ui/tooltip.tsx
@@ -0,0 +1,55 @@
+import * as React from "react"
+import { Tooltip as TooltipPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
diff --git a/client/src/index.css b/client/src/index.css
index fb3c7e9..73ad0c9 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -48,6 +48,56 @@
--radius-4xl: calc(var(--radius) * 2.6);
}
+@theme {
+ /* SABER brand */
+ --color-sapphire: #0F52BA;
+ --color-sapphire-dark: #0A3D8F;
+ --color-sapphire-subtle: #EAF0FB;
+ --color-orange: #F47C20;
+ --color-orange-light: #FEF0E6;
+ --color-orange-dark: #C45F0A;
+ --color-page: #F4F5F6;
+
+ /* Destructive (lock #10) */
+ --color-danger: #DC2626;
+ --color-danger-hover: #B91C1C;
+
+ /* Event badge */
+ --color-event-bg: #F0F4FF;
+ --color-event-fg: #0F52BA;
+
+ /* Avatar palette (lock #12) */
+ --color-avatar-1-bg: #EAF0FB;
+ --color-avatar-1-fg: #0A3D8F;
+ --color-avatar-2-bg: #FEF0E6;
+ --color-avatar-2-fg: #C45F0A;
+ --color-avatar-3-bg: #E6F4F1;
+ --color-avatar-3-fg: #0D5C4A;
+ --color-avatar-4-bg: #F0EEFE;
+ --color-avatar-4-fg: #4A3BA8;
+ --color-avatar-5-bg: #FDE8F0;
+ --color-avatar-5-fg: #8C2F54;
+
+ /* Ring tiers */
+ --color-tier-inner-bg: #EAF0FB;
+ --color-tier-inner-fg: #0A3D8F;
+ --color-tier-network-bg: #E6F4F1;
+ --color-tier-network-fg: #0D5C4A;
+ --color-tier-community-bg: #FEF3E2;
+ --color-tier-community-fg: #7A4A10;
+ --color-tier-acquaintances-bg: #F4F5F6;
+ --color-tier-acquaintances-fg: #555555;
+
+ /* Typography scale (lock #8) */
+ --text-label: 11px;
+ --text-meta: 12px;
+ --text-field: 13px;
+ --text-name: 14px;
+ --text-name-lg: 15px;
+ --text-title: 16px;
+ --text-stat: 22px;
+}
+
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
diff --git a/client/src/lib/avatar.ts b/client/src/lib/avatar.ts
new file mode 100644
index 0000000..4ceb635
--- /dev/null
+++ b/client/src/lib/avatar.ts
@@ -0,0 +1,13 @@
+export function colorIndex(name: string): number {
+ return [...name].reduce(
+ (sum, char) => sum + char.charCodeAt(0),
+ 0,
+ ) % 5;
+}
+
+export function initials(name: string): string {
+ const parts = name.trim().split(/\s+/).filter(Boolean);
+ if (parts.length === 0) return "?";
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
+}
diff --git a/client/src/lib/greeting.ts b/client/src/lib/greeting.ts
new file mode 100644
index 0000000..00d0332
--- /dev/null
+++ b/client/src/lib/greeting.ts
@@ -0,0 +1,6 @@
+export function getGreeting(date: Date = new Date()): string {
+ const hour = date.getHours();
+ if (hour >= 5 && hour < 12) return "Good morning";
+ if (hour >= 12 && hour < 18) return "Good afternoon";
+ return "Good evening";
+}
diff --git a/client/src/lib/time.ts b/client/src/lib/time.ts
new file mode 100644
index 0000000..555d43d
--- /dev/null
+++ b/client/src/lib/time.ts
@@ -0,0 +1,14 @@
+import { differenceInDays } from "date-fns";
+
+export function relativeDays(from: Date | string, now: Date = new Date()): string {
+ const fromDate = typeof from === "string" ? new Date(from) : from;
+ const days = differenceInDays(now, fromDate);
+
+ if (days < 14) return `${days} ${days === 1 ? "day" : "days"} ago`;
+ if (days < 60) {
+ const weeks = Math.floor(days / 7);
+ return `${weeks} ${weeks === 1 ? "week" : "weeks"} ago`;
+ }
+ const months = Math.floor(days / 30);
+ return `${months} ${months === 1 ? "month" : "months"} ago`;
+}
diff --git a/client/src/lib/use-document-title.ts b/client/src/lib/use-document-title.ts
new file mode 100644
index 0000000..8b2600a
--- /dev/null
+++ b/client/src/lib/use-document-title.ts
@@ -0,0 +1,11 @@
+import { useEffect } from "react";
+
+export function useDocumentTitle(title: string): void {
+ useEffect(() => {
+ const previous = document.title;
+ document.title = title ? `${title} - SABER` : "SABER";
+ return () => {
+ document.title = previous;
+ };
+ }, [title]);
+}