-
Stats
+
Stats
Usage across sessions, devices, and your recording library.
diff --git a/app/globals.css b/app/globals.css
index fa1de1c..666358b 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -7,6 +7,7 @@
@theme inline {
--font-heading: var(--font-display);
--font-sans: var(--font-sans);
+ --font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -48,79 +49,77 @@
}
/*
- * Palette: dark-mode-first (fits the product's low-lit, intimate register).
- * Every hue anchors to the pink -> purple -> blue brand sweep; --primary/
- * --accent/--chart-* are the only places it should read as loud. Card
- * surfaces stay a near-neutral indigo-black so the sweep doesn't flood
- * every panel.
+ * Modern pink -> purple -> blue palette (the app's signature sweep, also
+ * used in app/icon.svg / BrandMark). Dark cool near-black base with a vivid
+ * pink primary; violet/blue fill out charts and the wordmark/icon gradient.
*/
:root {
- --background: oklch(0.99 0.005 300);
- --foreground: oklch(0.22 0.03 290);
- --card: oklch(0.98 0.008 300);
- --card-foreground: oklch(0.22 0.03 290);
- --popover: oklch(0.99 0.005 300);
- --popover-foreground: oklch(0.22 0.03 290);
- --primary: oklch(0.58 0.23 322);
- --primary-foreground: oklch(0.99 0.01 322);
- --secondary: oklch(0.94 0.02 300);
- --secondary-foreground: oklch(0.3 0.05 300);
- --muted: oklch(0.95 0.015 300);
- --muted-foreground: oklch(0.5 0.03 290);
- --accent: oklch(0.9 0.06 330);
- --accent-foreground: oklch(0.3 0.1 330);
- --destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.9 0.02 300);
- --input: oklch(0.9 0.02 300);
- --ring: oklch(0.58 0.23 322);
- --chart-1: oklch(0.72 0.2 350);
- --chart-2: oklch(0.62 0.24 320);
- --chart-3: oklch(0.58 0.22 280);
- --chart-4: oklch(0.6 0.2 250);
- --chart-5: oklch(0.68 0.15 220);
- --radius: 0.9rem;
- --sidebar: oklch(0.97 0.01 300);
- --sidebar-foreground: oklch(0.22 0.03 290);
- --sidebar-primary: oklch(0.58 0.23 322);
- --sidebar-primary-foreground: oklch(0.99 0.01 322);
- --sidebar-accent: oklch(0.9 0.06 330);
- --sidebar-accent-foreground: oklch(0.3 0.1 330);
- --sidebar-border: oklch(0.9 0.02 300);
- --sidebar-ring: oklch(0.58 0.23 322);
+ --background: #f5f3fb;
+ --foreground: #1f1b2e;
+ --card: #ffffff;
+ --card-foreground: #1f1b2e;
+ --popover: #ffffff;
+ --popover-foreground: #1f1b2e;
+ --primary: #db2777;
+ --primary-foreground: #ffffff;
+ --secondary: #ede9fe;
+ --secondary-foreground: #3a2d54;
+ --muted: #efeafd;
+ --muted-foreground: #6b6280;
+ --accent: #ede4fb;
+ --accent-foreground: #4c1d95;
+ --destructive: #dc2626;
+ --border: #e4defa;
+ --input: #e4defa;
+ --ring: #db2777;
+ --chart-1: #db2777;
+ --chart-2: #9333ea;
+ --chart-3: #7c3aed;
+ --chart-4: #2563eb;
+ --chart-5: #0284c7;
+ --radius: 0.75rem;
+ --sidebar: #ede9fe;
+ --sidebar-foreground: #1f1b2e;
+ --sidebar-primary: #db2777;
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: #ede4fb;
+ --sidebar-accent-foreground: #4c1d95;
+ --sidebar-border: #e4defa;
+ --sidebar-ring: #db2777;
}
.dark {
- --background: oklch(0.16 0.025 285);
- --foreground: oklch(0.95 0.015 290);
- --card: oklch(0.21 0.03 285);
- --card-foreground: oklch(0.95 0.015 290);
- --popover: oklch(0.19 0.03 285);
- --popover-foreground: oklch(0.95 0.015 290);
- --primary: oklch(0.7 0.2 322);
- --primary-foreground: oklch(0.15 0.03 322);
- --secondary: oklch(0.28 0.04 285);
- --secondary-foreground: oklch(0.92 0.02 290);
- --muted: oklch(0.26 0.03 285);
- --muted-foreground: oklch(0.68 0.03 290);
- --accent: oklch(0.35 0.09 330);
- --accent-foreground: oklch(0.95 0.03 330);
- --destructive: oklch(0.704 0.191 22.216);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 14%);
- --ring: oklch(0.7 0.2 322);
- --chart-1: oklch(0.75 0.19 350);
- --chart-2: oklch(0.7 0.22 320);
- --chart-3: oklch(0.68 0.2 280);
- --chart-4: oklch(0.68 0.19 250);
- --chart-5: oklch(0.72 0.15 220);
- --sidebar: oklch(0.19 0.03 285);
- --sidebar-foreground: oklch(0.95 0.015 290);
- --sidebar-primary: oklch(0.7 0.2 322);
- --sidebar-primary-foreground: oklch(0.15 0.03 322);
- --sidebar-accent: oklch(0.35 0.09 330);
- --sidebar-accent-foreground: oklch(0.95 0.03 330);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.7 0.2 322);
+ --background: #15131f;
+ --foreground: #f2f0f7;
+ --card: #1f1b2c;
+ --card-foreground: #f2f0f7;
+ --popover: #1c1829;
+ --popover-foreground: #f2f0f7;
+ --primary: #ec4899;
+ --primary-foreground: #ffffff;
+ --secondary: #2a2440;
+ --secondary-foreground: #e5e1f0;
+ --muted: #272138;
+ --muted-foreground: #a79fc0;
+ --accent: #3a2d54;
+ --accent-foreground: #efeaff;
+ --destructive: #ef4444;
+ --border: color-mix(in srgb, #a78bfa 25%, transparent);
+ --input: color-mix(in srgb, #a78bfa 18%, transparent);
+ --ring: #ec4899;
+ --chart-1: #f472b6;
+ --chart-2: #c084fc;
+ --chart-3: #a78bfa;
+ --chart-4: #60a5fa;
+ --chart-5: #38bdf8;
+ --sidebar: #1c1829;
+ --sidebar-foreground: #f2f0f7;
+ --sidebar-primary: #ec4899;
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: #3a2d54;
+ --sidebar-accent-foreground: #efeaff;
+ --sidebar-border: color-mix(in srgb, #a78bfa 25%, transparent);
+ --sidebar-ring: #ec4899;
}
@layer base {
@@ -135,52 +134,69 @@
}
}
-/* Signature pink -> purple -> blue sweep, used sparingly (primary CTAs,
- active rings, headline accents) rather than flooding every surface. */
+/* 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 {
- background: linear-gradient(
- 100deg,
- oklch(0.75 0.19 350),
- oklch(0.65 0.23 322),
- oklch(0.62 0.2 260)
- );
+ background: linear-gradient(100deg, #ff6fb0, #b34bde 50%, #4f7fe0);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.bp-gradient-ring {
- background: linear-gradient(
- 135deg,
- oklch(0.75 0.19 350),
- oklch(0.65 0.23 322),
- oklch(0.62 0.2 260)
- );
+ background: linear-gradient(135deg, #ff6fb0, #b34bde 50%, #4f7fe0);
}
-/* Glassy translucent card surface, subtle glow on hover. */
+/* Console-module surface: hairline border + a soft inset top highlight,
+ standing in for backdrop-blur glassmorphism. */
.bp-glass {
- background: color-mix(in oklch, var(--card) 72%, transparent);
- backdrop-filter: blur(16px);
- -webkit-backdrop-filter: blur(16px);
+ background: var(--card);
border: 1px solid var(--border);
+ box-shadow: inset 0 1px 0 color-mix(in srgb, var(--foreground) 8%, transparent);
transition:
- box-shadow 0.2s ease,
border-color 0.2s ease,
- transform 0.2s ease;
+ box-shadow 0.2s ease;
}
.bp-glass:hover {
- border-color: color-mix(in oklch, var(--primary) 45%, var(--border));
- box-shadow: 0 0 0 1px color-mix(in oklch, var(--primary) 25%, transparent), 0 10px 30px -12px rgba(0, 0, 0, 0.45);
+ 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),
+ 0 10px 30px -14px rgba(0, 0, 0, 0.45);
+}
+
+/* Illuminated indicator dot - reused on connection status and per-actuator
+ transmit indicators. */
+.bp-led {
+ display: inline-block;
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 999px;
+ background: var(--muted-foreground);
+}
+.bp-led[data-on="true"] {
+ background: var(--primary);
+}
+
+/* Thin panel seam - a hardware "trim line," not a plain UI divider. */
+.bp-hairline {
+ height: 1px;
+ background: color-mix(in srgb, #a78bfa 35%, transparent);
+}
+
+/* Instrument-readout numerals: tabular mono for actuator %, timers, stats. */
+.bp-readout {
+ font-family: var(--font-mono);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.01em;
}
@keyframes bp-pulse-glow {
0%,
100% {
- box-shadow: 0 0 0 0 color-mix(in oklch, var(--primary) 55%, transparent);
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 55%, transparent);
}
50% {
- box-shadow: 0 0 0 6px color-mix(in oklch, var(--primary) 0%, transparent);
+ box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary) 0%, transparent);
}
}
.bp-pulse {
diff --git a/app/icon.svg b/app/icon.svg
new file mode 100644
index 0000000..484bbde
--- /dev/null
+++ b/app/icon.svg
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/layout.tsx b/app/layout.tsx
index 2ec9531..94cbe8e 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,15 +1,16 @@
import type { Metadata } from "next";
import "./globals.css";
-import { Geist, Space_Grotesk } from "next/font/google";
+import { Inter, Space_Grotesk, JetBrains_Mono } from "next/font/google";
import { cn } from "@/lib/utils";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { Toaster } from "@/components/ui/sonner";
-const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
+const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
const spaceGrotesk = Space_Grotesk({ subsets: ["latin"], variable: "--font-display" });
+const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });
export const metadata: Metadata = {
- title: "sexy",
+ title: "SEXY",
description: "Bluetooth toy control console",
};
@@ -18,7 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
diff --git a/app/login/page.tsx b/app/login/page.tsx
index e57b82a..bc3b0cb 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -1,13 +1,17 @@
import { Suspense } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { LoginForm } from "@/components/auth/LoginForm";
+import { BrandMark } from "@/components/layout/BrandMark";
export default function LoginPage() {
return (
- sexy
+
+
+ SEXY
+
Enter the shared access secret to continue.
diff --git a/components/control/ActuatorSlider.tsx b/components/control/ActuatorSlider.tsx
index a65c2f2..de8c495 100644
--- a/components/control/ActuatorSlider.tsx
+++ b/components/control/ActuatorSlider.tsx
@@ -12,20 +12,17 @@ interface ActuatorSliderProps {
export function ActuatorSlider({ actuator, value, transmitting, onChange }: ActuatorSliderProps) {
return (
-
+
- {actuator.descriptor}
-
+
+
+ {actuator.descriptor}
+
+
{Math.round(value * 100)}%
-
onChange(v / 100)}
- />
+ onChange(v / 100)} />
);
}
diff --git a/components/control/DeviceCard.tsx b/components/control/DeviceCard.tsx
index f627e82..1577650 100644
--- a/components/control/DeviceCard.tsx
+++ b/components/control/DeviceCard.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ActuatorSlider } from "./ActuatorSlider";
import type { ConnectedDeviceInfo } from "@/lib/buttplug/types";
@@ -22,13 +22,14 @@ export function DeviceCard({
}: DeviceCardProps) {
return (
-
- {device.displayName ?? device.name}
+
+ {device.displayName ?? device.name}
Stop
-
+
+
{device.actuators.length === 0 ? (
No controllable actuators reported.
) : (
diff --git a/components/devices/DevicesTable.tsx b/components/devices/DevicesTable.tsx
index 0764bff..1a82ba0 100644
--- a/components/devices/DevicesTable.tsx
+++ b/components/devices/DevicesTable.tsx
@@ -66,8 +66,8 @@ export function DevicesTable({ devices }: { devices: DeviceRow[] }) {
- {d.bleName}
-
+ {d.bleName}
+
{d.lastConnectedAt ? new Date(d.lastConnectedAt).toLocaleString() : "Never"}
diff --git a/components/layout/BrandMark.tsx b/components/layout/BrandMark.tsx
new file mode 100644
index 0000000..41b4c31
--- /dev/null
+++ b/components/layout/BrandMark.tsx
@@ -0,0 +1,42 @@
+import { cn } from "@/lib/utils";
+
+interface BrandMarkProps {
+ size?: number;
+ className?: string;
+}
+
+/** 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) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/layout/ConnectionStatus.tsx b/components/layout/ConnectionStatus.tsx
index b1f9702..545cf5d 100644
--- a/components/layout/ConnectionStatus.tsx
+++ b/components/layout/ConnectionStatus.tsx
@@ -8,15 +8,10 @@ export function ConnectionStatus() {
const deviceCount = useButtplugStore((s) => Object.keys(s.devices).length);
return (
-
-
-
- {connected ? `${deviceCount} device${deviceCount === 1 ? "" : "s"} connected` : "Not connected"}
+
+
+
+ {connected ? `${deviceCount} device${deviceCount === 1 ? "" : "s"} connected` : "not connected"}
);
diff --git a/components/layout/NavBar.tsx b/components/layout/NavBar.tsx
index 49e3b3d..431d9d2 100644
--- a/components/layout/NavBar.tsx
+++ b/components/layout/NavBar.tsx
@@ -7,6 +7,7 @@ import { Menu, Moon, Sun, LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { ConnectionStatus } from "./ConnectionStatus";
+import { BrandMark } from "./BrandMark";
import { cn } from "@/lib/utils";
const LINKS = [
@@ -28,10 +29,10 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
href={link.href}
onClick={onNavigate}
className={cn(
- "rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
+ "border-b-2 px-2.5 py-1.5 text-xs font-medium tracking-wide uppercase transition-colors",
pathname === link.href
- ? "bg-primary/15 text-primary"
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
+ ? "border-primary text-foreground"
+ : "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{link.label}
@@ -54,8 +55,9 @@ export function NavBar() {
return (
-
- sexy
+
+
+
SEXY
diff --git a/components/recordings/RecordingsTable.tsx b/components/recordings/RecordingsTable.tsx
index 589a323..500c889 100644
--- a/components/recordings/RecordingsTable.tsx
+++ b/components/recordings/RecordingsTable.tsx
@@ -64,11 +64,13 @@ export function RecordingsTable({ recordings }: { recordings: RecordingRow[] })
{r.name}
- {formatDuration(r.durationMs)}
+ {formatDuration(r.durationMs)}
- {r.playCount}
+
+ {r.playCount}
+
-
+
{r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"}
diff --git a/components/sessions/SessionTimelineChart.tsx b/components/sessions/SessionTimelineChart.tsx
index 0e829dc..b95688a 100644
--- a/components/sessions/SessionTimelineChart.tsx
+++ b/components/sessions/SessionTimelineChart.tsx
@@ -1,6 +1,6 @@
"use client";
-import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
+import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart";
export interface TimelineRow {
@@ -46,27 +46,36 @@ export function SessionTimelineChart({ timeline }: { timeline: TimelineRow[] })
return (
-
-
+
+
+ {series.map(([sessionDeviceId]) => (
+
+
+
+
+ ))}
+
+
`${Math.round(v / 1000)}s`}
tickLine={false}
axisLine={false}
+ className="bp-readout text-xs"
/>
-
+
} />
{series.map(([sessionDeviceId]) => (
-
))}
-
+
);
}
diff --git a/components/sessions/SessionsTable.tsx b/components/sessions/SessionsTable.tsx
index 8d5715e..93be365 100644
--- a/components/sessions/SessionsTable.tsx
+++ b/components/sessions/SessionsTable.tsx
@@ -68,8 +68,10 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
{s.kind}
{s.status}
- {new Date(s.startedAt).toLocaleString()}
- {formatDuration(s.durationMs)}
+
+ {new Date(s.startedAt).toLocaleString()}
+
+ {formatDuration(s.durationMs)}
void handleDelete(s.id)} aria-label="Delete">
diff --git a/components/stats/DeviceUsageTable.tsx b/components/stats/DeviceUsageTable.tsx
index d1b73ef..1f4e08b 100644
--- a/components/stats/DeviceUsageTable.tsx
+++ b/components/stats/DeviceUsageTable.tsx
@@ -30,10 +30,10 @@ export function DeviceUsageTable({ devices }: { devices: DeviceUsageRow[] }) {
{devices.map((d) => (
{d.displayName ?? d.bleName}
- {d.sessionCount}
- {(d.totalActiveMs / 60_000).toFixed(1)}m
- {d.commandCount}
-
+ {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 bf9a695..08d4687 100644
--- a/components/stats/RecordingLibraryStats.tsx
+++ b/components/stats/RecordingLibraryStats.tsx
@@ -16,13 +16,13 @@ export function RecordingLibraryStats({ stats }: { stats: RecordingLibrarySummar
Recordings
- {stats.count}
+ {stats.count}
Avg length
-
+
{Math.round(stats.avgDurationMs / 1000)}s
@@ -30,7 +30,7 @@ export function RecordingLibraryStats({ stats }: { stats: RecordingLibrarySummar
Total plays
- {stats.totalPlayCount}
+ {stats.totalPlayCount}
@@ -47,8 +47,8 @@ export function RecordingLibraryStats({ stats }: { stats: RecordingLibrarySummar
{stats.list.map((r) => (
{r.name}
- {r.playCount}
-
+ {r.playCount}
+
{r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"}
diff --git a/components/stats/SessionsSummaryCards.tsx b/components/stats/SessionsSummaryCards.tsx
index 38da728..4573254 100644
--- a/components/stats/SessionsSummaryCards.tsx
+++ b/components/stats/SessionsSummaryCards.tsx
@@ -23,19 +23,19 @@ export function SessionsSummaryCards({ summary }: { summary: SessionsSummary })
Completed sessions
- {summary.count}
+ {summary.count}
Total time
- {formatHours(summary.totalDurationMs)}
+ {formatHours(summary.totalDurationMs)}
Avg session length
-
+
{Math.round(summary.avgDurationMs / 60_000)}m
diff --git a/proxy.ts b/proxy.ts
index 2540e04..e33c208 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -22,5 +22,5 @@ export default async function proxy(req: NextRequest) {
}
export const config = {
- matcher: ["/((?!_next/static|_next/image|favicon.ico|login|api/auth/login|api/health).*)"],
+ matcher: ["/((?!_next/static|_next/image|favicon.ico|icon.svg|login|api/auth/login|api/health).*)"],
};