diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 0c53835..e5de838 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,10 +1,14 @@ import { NavBar } from "@/components/layout/NavBar"; +import { Breadcrumbs } from "@/components/layout/Breadcrumbs"; export default function AppLayout({ children }: { children: React.ReactNode }) { return (
-
{children}
+
+ + {children} +
); } diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index be41b30..8a7bb13 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -24,8 +24,8 @@ export default async function DashboardPage() {
- -

Welcome back

+ +

Welcome back

Scan for nearby devices, take control, and record sessions to replay later - all running directly from your browser over Web Bluetooth. diff --git a/app/(app)/stats/page.tsx b/app/(app)/stats/page.tsx index 87d7c42..a1654a0 100644 --- a/app/(app)/stats/page.tsx +++ b/app/(app)/stats/page.tsx @@ -27,7 +27,7 @@ export default async function StatsPage() {

- + Sessions Devices Recordings diff --git a/app/globals.css b/app/globals.css index b6ce796..c276cfa 100644 --- a/app/globals.css +++ b/app/globals.css @@ -127,13 +127,46 @@ @apply border-border outline-ring/50; } body { - @apply bg-background text-foreground; + @apply text-foreground; } html { @apply font-sans; } } +/* Animated signature-sweep background - sits behind everything; cards use + .bp-glass (translucent + blurred) so it shines through instead of being + fully hidden behind an opaque card surface. */ +body { + background-color: var(--background); + background-image: + radial-gradient(ellipse 80% 60% at 15% 10%, color-mix(in srgb, #ff6fb0 32%, transparent), transparent 60%), + radial-gradient(ellipse 70% 60% at 85% 25%, color-mix(in srgb, #4f7fe0 28%, transparent), transparent 60%), + radial-gradient(ellipse 75% 65% at 50% 100%, color-mix(in srgb, #b34bde 30%, transparent), transparent 60%); + background-repeat: no-repeat; + background-size: 140% 140%; + background-attachment: fixed; + animation: bp-bg-drift 24s ease-in-out infinite; +} + +@keyframes bp-bg-drift { + 0% { + background-position: 0% 0%, 100% 0%, 50% 100%; + } + 50% { + background-position: 20% 20%, 80% 30%, 60% 80%; + } + 100% { + background-position: 0% 0%, 100% 0%, 50% 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + body { + animation: none; + } +} + /* Signature pink -> purple -> blue sweep (matches app/icon.svg), used for the wordmark and one hero moment - not smeared across every surface. */ .bp-gradient-text { @@ -147,17 +180,20 @@ background: linear-gradient(135deg, #ff6fb0, #b34bde 50%, #4f7fe0); } -/* Console-module surface: hairline border + a soft inset top highlight, - standing in for backdrop-blur glassmorphism. */ +/* Console-module surface: translucent + blurred so the animated background + sweep shines through, with a hairline border and soft inset top highlight. */ .bp-glass { - background: var(--card); + background: color-mix(in srgb, var(--card) 55%, transparent); + backdrop-filter: blur(20px) saturate(150%); border: 1px solid var(--border); box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 8%, transparent); transition: border-color 0.2s ease, - box-shadow 0.2s ease; + box-shadow 0.2s ease, + background-color 0.2s ease; } .bp-glass:hover { + background: color-mix(in srgb, var(--card) 62%, transparent); border-color: color-mix(in srgb, var(--primary) 45%, var(--border)); box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 10%, transparent), @@ -201,6 +237,47 @@ letter-spacing: 0.01em; } +/* BrandMark's animated variant (dashboard hero): the heart glyph beats, the + EKG-style curve glows in the primary accent instead of plain white. */ +@keyframes bp-mark-heartbeat { + 0%, + 100% { + opacity: 0.22; + transform: scale(1); + } + 50% { + opacity: 0.75; + transform: scale(1.1); + } +} +.bp-mark-heart { + transform-box: fill-box; + transform-origin: center; + animation: bp-mark-heartbeat 1.8s ease-in-out infinite; +} +@keyframes bp-mark-curve-glow { + 0%, + 100% { + filter: drop-shadow(0 0 1px color-mix(in srgb, var(--primary) 50%, transparent)); + } + 50% { + filter: + drop-shadow(0 0 5px color-mix(in srgb, var(--primary) 100%, transparent)) + drop-shadow(0 0 10px color-mix(in srgb, var(--primary) 70%, transparent)); + } +} +.bp-mark-curve { + stroke: #fff; + animation: bp-mark-curve-glow 1.8s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .bp-mark-heart, + .bp-mark-curve { + animation: none; + } +} + @keyframes bp-pulse-glow { 0%, 100% { diff --git a/app/icon.svg b/app/icon.svg index 484bbde..6b2ec96 100644 --- a/app/icon.svg +++ b/app/icon.svg @@ -7,16 +7,18 @@ - - + + + + diff --git a/app/login/page.tsx b/app/login/page.tsx index 303768f..a587e1e 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -13,7 +13,7 @@ export default function LoginPage() {
- Sexy + Sexy
Enter the shared access secret to continue.
diff --git a/components/control/RecordControls.tsx b/components/control/RecordControls.tsx index 48b2f68..ab2ff15 100644 --- a/components/control/RecordControls.tsx +++ b/components/control/RecordControls.tsx @@ -28,7 +28,7 @@ export function RecordControls({ active, elapsedLabel, disabled, busy, onStart, } return ( - ); diff --git a/components/layout/BrandMark.tsx b/components/layout/BrandMark.tsx index 41b4c31..4963623 100644 --- a/components/layout/BrandMark.tsx +++ b/components/layout/BrandMark.tsx @@ -3,10 +3,12 @@ import { cn } from "@/lib/utils"; interface BrandMarkProps { size?: number; className?: string; + /** Beats the heart glyph and makes the curve glow - used for the dashboard hero mark. */ + animated?: boolean; } /** Inline twin of app/icon.svg (the favicon) - kept as markup, not an , so it can be sized/animated with CSS. */ -export function BrandMark({ size = 28, className }: BrandMarkProps) { +export function BrandMark({ size = 28, className, animated = false }: BrandMarkProps) { return ( - - + + + + ); } diff --git a/components/layout/Breadcrumbs.tsx b/components/layout/Breadcrumbs.tsx new file mode 100644 index 0000000..779592a --- /dev/null +++ b/components/layout/Breadcrumbs.tsx @@ -0,0 +1,55 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { ChevronRight, House } from "lucide-react"; + +const SECTION_LABELS: Record = { + control: "Control", + recordings: "Recordings", + sessions: "Sessions", + stats: "Stats", + devices: "Devices", +}; + +const LEAF_LABELS: Record = { + replay: "Replay", +}; + +export function Breadcrumbs() { + const pathname = usePathname(); + const segments = pathname.split("/").filter(Boolean); + + if (segments.length === 0) { + return null; + } + + const crumbs = segments.reduce<{ label: string; href: string }[]>((acc, segment) => { + const href = `${acc.at(-1)?.href ?? ""}/${segment}`; + const label = /^\d+$/.test(segment) ? `#${segment}` : (LEAF_LABELS[segment] ?? SECTION_LABELS[segment] ?? segment); + return [...acc, { label, href }]; + }, []); + + return ( + + ); +} diff --git a/components/layout/NavBar.tsx b/components/layout/NavBar.tsx index 0fa6f7d..9e09b65 100644 --- a/components/layout/NavBar.tsx +++ b/components/layout/NavBar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useTheme } from "next-themes"; @@ -45,6 +46,7 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) { export function NavBar() { const { theme, setTheme } = useTheme(); const router = useRouter(); + const [menuOpen, setMenuOpen] = useState(false); async function handleLogout() { await fetch("/api/auth/logout", { method: "POST" }); @@ -57,7 +59,7 @@ export function NavBar() {
- Sexy + Sexy
); } diff --git a/components/stats/DeviceUsageTable.tsx b/components/stats/DeviceUsageTable.tsx index 1f4e08b..8ab1a9e 100644 --- a/components/stats/DeviceUsageTable.tsx +++ b/components/stats/DeviceUsageTable.tsx @@ -1,3 +1,4 @@ +import { Card, CardContent } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; export interface DeviceUsageRow { @@ -12,33 +13,41 @@ export interface DeviceUsageRow { export function DeviceUsageTable({ devices }: { devices: DeviceUsageRow[] }) { if (devices.length === 0) { - return

No device activity yet.

; + return ( + + No device activity yet. + + ); } return ( - - - - Device - Sessions - Active time - Commands - Last used - - - - {devices.map((d) => ( - - {d.displayName ?? d.bleName} - {d.sessionCount} - {(d.totalActiveMs / 60_000).toFixed(1)}m - {d.commandCount} - - {d.lastUsedAt ? new Date(d.lastUsedAt).toLocaleString() : "Never"} - - - ))} - -
+ + + + + + Device + Sessions + Active time + Commands + Last used + + + + {devices.map((d) => ( + + {d.displayName ?? d.bleName} + {d.sessionCount} + {(d.totalActiveMs / 60_000).toFixed(1)}m + {d.commandCount} + + {d.lastUsedAt ? new Date(d.lastUsedAt).toLocaleString() : "Never"} + + + ))} + +
+
+
); } diff --git a/components/stats/RecordingLibraryStats.tsx b/components/stats/RecordingLibraryStats.tsx index 08d4687..6976712 100644 --- a/components/stats/RecordingLibraryStats.tsx +++ b/components/stats/RecordingLibraryStats.tsx @@ -35,26 +35,30 @@ export function RecordingLibraryStats({ stats }: { stats: RecordingLibrarySummar {stats.list.length > 0 && ( - - - - Recording - Plays - Last played - - - - {stats.list.map((r) => ( - - {r.name} - {r.playCount} - - {r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"} - - - ))} - -
+ + + + + + Recording + Plays + Last played + + + + {stats.list.map((r) => ( + + {r.name} + {r.playCount} + + {r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"} + + + ))} + +
+
+
)} ); diff --git a/components/stats/SessionsSummaryCards.tsx b/components/stats/SessionsSummaryCards.tsx index 4573254..29f05c1 100644 --- a/components/stats/SessionsSummaryCards.tsx +++ b/components/stats/SessionsSummaryCards.tsx @@ -40,9 +40,11 @@ export function SessionsSummaryCards({ summary }: { summary: SessionsSummary }) -

- {liveCount} live · {replayCount} replay -

+ + + {liveCount} live · {replayCount} replay + + {summary.durationPerDevice.length > 0 && (