From 123cae65c22912023b8e6635f42c254e1098c6eb Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Thu, 19 Mar 2026 03:00:23 +0000 Subject: [PATCH 1/4] =?UTF-8?q?Improve=20admin=20UI=20visual=20design=20?= =?UTF-8?q?=E2=80=94=20polish=20look=20and=20feel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sticky nav bar with subtle shadow, branded GroomBook wordmark, green gradient Book button - Consistent brand green (#4f8a6f) for primary buttons across all admin pages - Tables wrapped in white cards with rounded corners and soft shadows - Uppercase table headers with better spacing and hierarchy - Input/button border-radius increased to 6px for softer feel - Global CSS: button transitions, input focus states with brand green ring, subtle card shadows - Background changed from plain white to light gray (#f0f2f5) for depth - Reports: polished stat cards with shadows, refined section headers, card-wrapped tables - Custom scrollbar styling for a cleaner look Closes groombook/groombook#58 Co-Authored-By: Paperclip --- apps/web/src/App.tsx | 41 +++++++++------ apps/web/src/index.css | 50 ++++++++++++++++++- apps/web/src/pages/Appointments.tsx | 21 ++++---- apps/web/src/pages/Clients.tsx | 12 ++--- apps/web/src/pages/GroupBooking.tsx | 19 +++---- apps/web/src/pages/Invoices.tsx | 18 ++++--- apps/web/src/pages/Reports.tsx | 77 +++++++++++++++-------------- apps/web/src/pages/Services.tsx | 18 ++++--- apps/web/src/pages/Staff.tsx | 14 +++--- 9 files changed, 170 insertions(+), 100 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b3ef7fc..7df2c75 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -23,29 +23,42 @@ const NAV_LINKS = [ function AdminLayout() { const location = useLocation(); return ( -
+
-
+
} /> } /> diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 67a2b22..6d09d02 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -10,11 +10,13 @@ body { font-size: 16px; line-height: 1.5; color: #1a202c; - background: #f7fafc; + background: #f0f2f5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } a { - color: #4f8a6f; + color: #3d7a5f; text-decoration: none; } @@ -25,4 +27,48 @@ a:hover { h1 { font-size: 1.5rem; margin-top: 0; + letter-spacing: -0.01em; +} + +h2, h3, h4 { + letter-spacing: -0.01em; +} + +/* ─── Admin button polish ─── */ +button { + transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease; +} + +button:active:not(:disabled) { + transform: translateY(0.5px); +} + +/* ─── Admin input / select focus states ─── */ +input:focus, select:focus, textarea:focus { + outline: none; + border-color: #4f8a6f; + box-shadow: 0 0 0 3px rgba(79, 138, 111, 0.12); +} + +/* ─── Admin card-like containers (borders get subtle shadow) ─── */ +[style*="border: 1px solid"] { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +/* ─── Scrollbar polish ─── */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; } diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 20c5fae..ea2c539 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -291,7 +291,7 @@ export function AppointmentsPage() { @@ -370,11 +370,11 @@ export function AppointmentsPage() { {days.map((day, i) => { const isToday = formatDate(day) === formatDate(new Date()); return ( -
+
{saving ? "Saving…" @@ -841,19 +841,20 @@ function Field({ label, children }: { label: string; children: React.ReactNode } } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", - borderRadius: 4, - background: "#f9fafb", + borderRadius: 6, + background: "#fff", cursor: "pointer", fontSize: 13, + fontWeight: 500, }; const inputStyle: React.CSSProperties = { width: "100%", - padding: "0.4rem 0.5rem", + padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", - borderRadius: 4, + borderRadius: 6, fontSize: 14, boxSizing: "border-box", }; diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index e28d424..50df051 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -315,7 +315,7 @@ export function ClientsPage() {

Clients

@@ -387,7 +387,7 @@ export function ClientsPage() { ) : (
{pets.map((p) => ( -
+
{p.name}
@@ -498,7 +498,7 @@ export function ClientsPage() { {clientFormError &&

{clientFormError}

}
- @@ -637,7 +637,7 @@ export function ClientsPage() { {logFormError &&

{logFormError}

}
- @@ -674,9 +674,9 @@ function Field({ label, children }: { label: string; children: React.ReactNode } } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13, + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500, }; const inputStyle: React.CSSProperties = { - width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 4, fontSize: 14, boxSizing: "border-box", + width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, boxSizing: "border-box", }; diff --git a/apps/web/src/pages/GroupBooking.tsx b/apps/web/src/pages/GroupBooking.tsx index 8662a07..445530a 100644 --- a/apps/web/src/pages/GroupBooking.tsx +++ b/apps/web/src/pages/GroupBooking.tsx @@ -287,7 +287,7 @@ function NewGroupBookingForm({ @@ -471,7 +471,7 @@ export function GroupBookingPage() { @@ -558,25 +558,26 @@ function Field({ } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", - borderRadius: 4, - background: "#f9fafb", + borderRadius: 6, + background: "#fff", cursor: "pointer", fontSize: 13, + fontWeight: 500, }; const inputStyle: React.CSSProperties = { width: "100%", - padding: "0.4rem 0.5rem", + padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", - borderRadius: 4, + borderRadius: 6, fontSize: 13, boxSizing: "border-box", }; const tdStyle: React.CSSProperties = { - padding: "0.45rem 1rem", - borderBottom: "1px solid #f1f5f9", + padding: "0.5rem 1rem", + borderBottom: "1px solid #f3f4f6", color: "#374151", }; diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index dd86cf7..d1c3457 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -129,7 +129,7 @@ function CreateFromAppointmentForm({ @@ -540,7 +540,7 @@ export function InvoicesPage() { @@ -551,11 +551,12 @@ export function InvoicesPage() { No invoices yet. Create one from a completed appointment.

) : ( +
{["Date", "Client", "Subtotal", "Tax", "Tip", "Total", "Status", ""].map((h) => ( - ))} @@ -582,6 +583,7 @@ export function InvoicesPage() { ))}
+ {h}
+
)} {showCreate && ( @@ -647,15 +649,15 @@ function Field({ label, children }: { label: string; children: React.ReactNode } } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", - borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13, + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", + borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500, }; const inputStyle: React.CSSProperties = { - width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", - borderRadius: 4, fontSize: 14, boxSizing: "border-box", + width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", + borderRadius: 6, fontSize: 14, boxSizing: "border-box", }; const tdStyle: React.CSSProperties = { - padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0", + padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6", }; diff --git a/apps/web/src/pages/Reports.tsx b/apps/web/src/pages/Reports.tsx index fabb159..f7b3ceb 100644 --- a/apps/web/src/pages/Reports.tsx +++ b/apps/web/src/pages/Reports.tsx @@ -84,14 +84,15 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s
-
+
{label}
{value}
@@ -102,7 +103,7 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s function SectionHeader({ children }: { children: React.ReactNode }) { return ( -

+

{children}

); @@ -110,35 +111,37 @@ function SectionHeader({ children }: { children: React.ReactNode }) { function Table({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) { return ( - - - - {headers.map((h) => ( - - ))} - - - - {rows.map((row, i) => ( - - {row.map((cell, j) => ( - +
+
- {h} -
- {cell} -
+ + + {headers.map((h) => ( + ))} - ))} - {rows.length === 0 && ( - - - - )} - -
+ {h} +
- No data for this period. -
+ + + {rows.map((row, i) => ( + + {row.map((cell, j) => ( + + {cell} + + ))} + + ))} + {rows.length === 0 && ( + + + No data for this period. + + + )} + + +
); } @@ -267,7 +270,7 @@ export function ReportsPage() { -
@@ -390,19 +393,19 @@ export function ReportsPage() { // ─── Shared styles ──────────────────────────────────────────────────────────── const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", - borderRadius: 4, - background: "#f9fafb", + borderRadius: 6, + background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500, }; const inputStyle: React.CSSProperties = { - padding: "0.3rem 0.4rem", + padding: "0.35rem 0.5rem", border: "1px solid #d1d5db", - borderRadius: 4, + borderRadius: 6, fontSize: 13, marginLeft: "0.25rem", }; diff --git a/apps/web/src/pages/Services.tsx b/apps/web/src/pages/Services.tsx index 229b0d7..eb952b1 100644 --- a/apps/web/src/pages/Services.tsx +++ b/apps/web/src/pages/Services.tsx @@ -119,7 +119,7 @@ export function ServicesPage() {

Services

@@ -128,11 +128,12 @@ export function ServicesPage() { {services.length === 0 ? (

No services configured yet.

) : ( +
{["Name", "Description", "Price", "Duration", "Status", ""].map((h) => ( - ))} @@ -171,6 +172,7 @@ export function ServicesPage() { ))}
+ {h}
+
)} {showForm && ( @@ -230,7 +232,7 @@ export function ServicesPage() { @@ -277,15 +279,15 @@ function Field({ label, children }: { label: string; children: React.ReactNode } } const btnStyle: React.CSSProperties = { - padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", - borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13, + padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", + borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500, }; const inputStyle: React.CSSProperties = { - width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", - borderRadius: 4, fontSize: 14, boxSizing: "border-box", + width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", + borderRadius: 6, fontSize: 14, boxSizing: "border-box", }; const tdStyle: React.CSSProperties = { - padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0", + padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6", }; diff --git a/apps/web/src/pages/Staff.tsx b/apps/web/src/pages/Staff.tsx index 0f34ffb..5e9b594 100644 --- a/apps/web/src/pages/Staff.tsx +++ b/apps/web/src/pages/Staff.tsx @@ -78,7 +78,7 @@ export function StaffPage() {

Staff

-
@@ -86,11 +86,12 @@ export function StaffPage() { {staff.length === 0 ? (

No staff members yet.

) : ( +
{["Name", "Email", "Role", "Status", ""].map((h) => ( - + ))} @@ -113,6 +114,7 @@ export function StaffPage() { ))}
{h}{h}
+
)} {showForm && ( @@ -143,7 +145,7 @@ export function StaffPage() {
{formError &&

{formError}

}
- @@ -156,7 +158,7 @@ export function StaffPage() { ); } -const btnStyle: React.CSSProperties = { padding: "0.35rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 4, background: "#f9fafb", cursor: "pointer", fontSize: 13 }; -const inputStyle: React.CSSProperties = { width: "100%", padding: "0.4rem 0.5rem", border: "1px solid #d1d5db", borderRadius: 4, fontSize: 14, boxSizing: "border-box" }; +const btnStyle: React.CSSProperties = { padding: "0.4rem 0.85rem", border: "1px solid #d1d5db", borderRadius: 6, background: "#fff", cursor: "pointer", fontSize: 13, fontWeight: 500 }; +const inputStyle: React.CSSProperties = { width: "100%", padding: "0.45rem 0.6rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, boxSizing: "border-box" }; const labelStyle: React.CSSProperties = { display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13, color: "#374151" }; -const tdStyle: React.CSSProperties = { padding: "0.5rem 0.75rem", borderBottom: "1px solid #e2e8f0" }; +const tdStyle: React.CSSProperties = { padding: "0.55rem 0.75rem", borderBottom: "1px solid #f3f4f6" }; -- 2.52.0 From f21ac590400120415f6753c2bc5033ff7d5ba37e Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Thu, 19 Mar 2026 03:16:46 +0000 Subject: [PATCH 2/4] Fix test selectors for branded nav text - Use regex /Groom\s*Book/ to match split-element brand text - Use getByRole("link") for Book CTA to avoid matching brand Co-Authored-By: Paperclip --- apps/web/src/__tests__/App.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx index cfd422d..47b9656 100644 --- a/apps/web/src/__tests__/App.test.tsx +++ b/apps/web/src/__tests__/App.test.tsx @@ -23,12 +23,12 @@ function renderApp(route = "/admin") { describe("App navigation", () => { it("renders the Groom Book brand", () => { const nav = renderApp(); - expect(within(nav).getByText("Groom Book")).toBeInTheDocument(); + expect(within(nav).getByText(/Groom\s*Book/)).toBeInTheDocument(); }); it("renders the Book CTA button", () => { const nav = renderApp(); - expect(within(nav).getByText("Book")).toBeInTheDocument(); + expect(within(nav).getByRole("link", { name: "Book" })).toBeInTheDocument(); }); it("renders all primary nav links", () => { @@ -61,6 +61,6 @@ describe("App navigation", () => { ); // Customer portal should render at root - no admin nav present - expect(screen.queryByText("Groom Book")).not.toBeInTheDocument(); + expect(screen.queryByText(/Groom\s*Book/)).not.toBeInTheDocument(); }); }); -- 2.52.0 From ad4cc56546fb314c1aad3657d2761bf0e08be5e8 Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Thu, 19 Mar 2026 03:20:09 +0000 Subject: [PATCH 3/4] Fix brand text test to handle split-element rendering The nav brand was changed to GroomBook for color styling, but getByText with a regex can't match text split across child elements. Use a custom text matcher that checks the STRONG element's textContent. Co-Authored-By: Paperclip --- apps/web/src/__tests__/App.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx index 47b9656..0052984 100644 --- a/apps/web/src/__tests__/App.test.tsx +++ b/apps/web/src/__tests__/App.test.tsx @@ -23,7 +23,9 @@ function renderApp(route = "/admin") { describe("App navigation", () => { it("renders the Groom Book brand", () => { const nav = renderApp(); - expect(within(nav).getByText(/Groom\s*Book/)).toBeInTheDocument(); + expect( + within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) + ).toBeInTheDocument(); }); it("renders the Book CTA button", () => { @@ -61,6 +63,8 @@ describe("App navigation", () => { ); // Customer portal should render at root - no admin nav present - expect(screen.queryByText(/Groom\s*Book/)).not.toBeInTheDocument(); + expect( + screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) + ).not.toBeInTheDocument(); }); }); -- 2.52.0 From dc37a5f59ed4795402362920315d8e89ab447894 Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Thu, 19 Mar 2026 03:27:54 +0000 Subject: [PATCH 4/4] Fix E2E tests for split-element brand name The brand is now GroomBook (no space), so Playwright's getByText needs "GroomBook" instead of "Groom Book". Co-Authored-By: Paperclip --- apps/e2e/tests/navigation.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/e2e/tests/navigation.spec.ts b/apps/e2e/tests/navigation.spec.ts index 0b8e78f..39d9530 100644 --- a/apps/e2e/tests/navigation.spec.ts +++ b/apps/e2e/tests/navigation.spec.ts @@ -48,38 +48,38 @@ test("customer portal loads at root", async ({ page }) => { test("admin appointments page loads", async ({ page }) => { await page.goto("/admin"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); // Calendar/appointments view renders await expect(page.locator("nav")).toBeVisible(); }); test("admin clients page loads", async ({ page }) => { await page.goto("/admin/clients"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Clients" })).toBeVisible(); }); test("admin services page loads", async ({ page }) => { await page.goto("/admin/services"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Services" })).toBeVisible(); }); test("admin staff page loads", async ({ page }) => { await page.goto("/admin/staff"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Staff" })).toBeVisible(); }); test("admin invoices page loads", async ({ page }) => { await page.goto("/admin/invoices"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible(); }); test("admin reports page loads", async ({ page }) => { await page.goto("/admin/reports"); - await expect(page.getByText("Groom Book")).toBeVisible(); + await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByRole("link", { name: "Reports" })).toBeVisible(); }); -- 2.52.0