feat(sidebar): What's New button and a Dark Mode section

The sidebar's bottom block gets two new controls:

- A "What's New" button that opens the release-note popup, with a badge
  until the operator has read the latest release.
- A Dark Mode section that replaces the old one-click toggle with an
  explicit Light/Dark switch, so the current mode is always visible
  instead of being inferred from the button's label.

The theme colour picker stays in Settings -> Appearance; the sidebar
carries the mode switch only, so the nav rail is not a strip of 16
coloured squares.
This commit is contained in:
Dave 2026-11-02 07:13:51 +02:00
parent 61fb2669e0
commit 139ea259f4
5 changed files with 393 additions and 18 deletions

View file

@ -122,4 +122,33 @@ describe("Sidebar", () => {
expect(link).not.toBeNull();
expect(link).toHaveAttribute("href", "/search");
});
it("puts What's New and a Dark Mode section in the bottom block", () => {
render(<Sidebar />);
const desktop = screen.getAllByRole("complementary")[0];
// The release-notes popup button.
expect(
within(desktop).getByRole("button", { name: "What's New" }),
).toBeInTheDocument();
// The Dark Mode section: a collapsible heading and the light/dark
// switch. The theme colour swatches were pulled out of the sidebar
// (a strip of coloured squares reads as noise in a nav rail) — the
// full picker lives in Settings → Appearance.
expect(
within(desktop).getByRole("button", { name: /dark mode/i }),
).toBeInTheDocument();
expect(within(desktop).getByRole("button", { name: "Light" })).toBeInTheDocument();
expect(within(desktop).getByRole("button", { name: "Dark" })).toBeInTheDocument();
// Regression guard: no base theme may leak back into the sidebar as
// a swatch button.
for (const label of ["WFDASH", "Neon", "Cosmic", "MiniMax", "Crisp"]) {
expect(
within(desktop).queryByRole("button", { name: label }),
`sidebar should not render a theme swatch for "${label}"`,
).not.toBeInTheDocument();
}
});
});

View file

@ -39,6 +39,7 @@ import { BrandStripe } from "@/components/brand-stripe";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
import { WhatsNewButton } from "@/components/whats-new-dialog";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
@ -273,13 +274,92 @@ function NavLink({
);
}
/* ============================================================================
* DarkModeSection — the light/dark switch
*
* Lives in the sidebar's bottom block. Only the mode switch lives here:
* the row of theme colour swatches that used to sit under it was pulled
* out — a strip of 16 coloured squares reads as noise in a nav rail, and
* the sidebar is for navigation, not for picking a palette.
*
* Settings → Appearance still carries the full theme picker.
* ========================================================================== */
function DarkModeSection() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [open, setOpen] = useState(true);
useEffect(() => setMounted(true), []); // eslint-disable-line react-hooks/set-state-in-effect
// `theme` is undefined on the server and undefined for the client's
// first (hydration) render — next-themes reads localStorage after
// mount. Rendering a highlight off it before mount is what produces
// the "wrong button is lit for a frame" flicker, and on a dark-mode
// browser an outright hydration mismatch. So we highlight nothing
// until we know, then paint the right one.
const isDark = mounted && theme === "dark";
return (
<div className="space-y-1.5">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex w-full items-center gap-1.5 px-2.5 py-1 text-[10.5px] font-semibold uppercase tracking-widest text-muted-foreground transition-colors hover:text-foreground"
>
Dark mode
<ChevronDown
className={cn(
"ml-auto h-3 w-3 transition-transform",
open ? "" : "-rotate-90"
)}
/>
</button>
{open && (
<div className="space-y-2 px-2.5">
<div className="grid grid-cols-2 gap-1 rounded-lg bg-sidebar-accent/50 p-1">
<button
type="button"
onClick={() => setTheme("light")}
aria-pressed={mounted && !isDark}
className={cn(
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors",
mounted && !isDark
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-muted-foreground hover:text-sidebar-foreground"
)}
>
<Sun className="h-3.5 w-3.5" />
Light
</button>
<button
type="button"
onClick={() => setTheme("dark")}
aria-pressed={isDark}
className={cn(
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors",
isDark
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-muted-foreground hover:text-sidebar-foreground"
)}
>
<Moon className="h-3.5 w-3.5" />
Dark
</button>
</div>
</div>
)}
</div>
);
}
/* ============================================================================
* NavContent — the actual sidebar body
* ========================================================================== */
function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
const pathname = usePathname();
const { user, logout } = useAuth();
const { theme, setTheme } = useTheme();
return (
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
@ -391,6 +471,8 @@ function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
{/* Bottom block */}
<div className="space-y-1 p-3">
<WhatsNewButton />
<Link
href="/settings"
onClick={onLinkClick}
@ -402,23 +484,10 @@ function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
Settings
</Link>
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="flex h-9 w-full items-center gap-3 rounded-lg px-2.5 text-sm text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
{theme === "dark" ? (
<>
<Sun className="h-[17px] w-[17px]" />
Light mode
</>
) : (
<>
<Moon className="h-[17px] w-[17px]" />
Dark mode
</>
)}
</button>
{/* Dark mode — the light/dark switch. This replaces the old
one-click toggle that sat here: two controls for one setting
in the same strip reads as a bug, not a feature. */}
<DarkModeSection />
<Separator className="my-1" />

View file

@ -0,0 +1,45 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { WhatsNewButton } from "./whats-new-dialog";
import { LATEST_RELEASE } from "@/lib/release-notes";
const SEEN_KEY = "wfdash-whats-new-seen";
describe("WhatsNewButton", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("flags the latest release as new until it has been read", () => {
render(<WhatsNewButton />);
expect(screen.getByTestId("whats-new-badge")).toBeInTheDocument();
});
it("marks the release as seen when the popup is opened", () => {
render(<WhatsNewButton />);
// The dialog is opened by us, not by base-ui, so the "seen" stamp
// has to be written here. A version that only fired from
// onOpenChange would leave the badge lit forever.
fireEvent.click(screen.getByRole("button", { name: "What's New" }));
expect(window.localStorage.getItem(SEEN_KEY)).toBe(LATEST_RELEASE.version);
});
it("does not flag a release the operator has already read", () => {
window.localStorage.setItem(SEEN_KEY, LATEST_RELEASE.version);
render(<WhatsNewButton />);
expect(screen.queryByTestId("whats-new-badge")).not.toBeInTheDocument();
});
it("shows the release notes in the popup", async () => {
render(<WhatsNewButton />);
fireEvent.click(screen.getByRole("button", { name: "What's New" }));
expect(await screen.findByText(LATEST_RELEASE.title)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,150 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Megaphone } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { LATEST_RELEASE, RELEASE_NOTES } from "@/lib/release-notes";
import { cn } from "@/lib/utils";
/**
* What's New — a button in the sidebar that opens a popup of the
* releases that have landed since the operator last looked.
*
* "Seen" is tracked in localStorage against the latest version string.
* Bump the version in src/lib/release-notes.ts and the button lights up
* again for everyone — no server round-trip, no account state.
*/
const SEEN_KEY = "wfdash-whats-new-seen";
function formatDate(iso: string): string {
const d = new Date(`${iso}T00:00:00`);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-ZA", {
day: "numeric",
month: "short",
year: "numeric",
});
}
export function WhatsNewButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
// `mounted` guards the "New" badge: the server has no idea what this
// operator has already read, so the badge stays off until we know.
// Reading localStorage during render would mismatch the server HTML.
const [state, setState] = useState<{ mounted: boolean; seen: string | null }>({
mounted: false,
seen: null,
});
useEffect(() => {
let stored: string | null = null;
try {
stored = window.localStorage.getItem(SEEN_KEY);
} catch {
/* localStorage blocked — treat as unseen */
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setState({ mounted: true, seen: stored });
}, []);
const markSeen = useCallback(() => {
try {
window.localStorage.setItem(SEEN_KEY, LATEST_RELEASE.version);
} catch {
/* ignore */
}
setState((s) => ({ ...s, seen: LATEST_RELEASE.version }));
}, []);
const isNew = state.mounted && state.seen !== LATEST_RELEASE.version;
return (
<>
<button
type="button"
onClick={() => {
setOpen(true);
// Opening is what counts as reading it. This has to happen
// here rather than in onOpenChange: the dialog is opened by
// us, so base-ui never routes the open through its own
// change handler — only the close does.
markSeen();
}}
aria-label="What's New"
className={cn(
"flex h-9 w-full items-center gap-3 rounded-lg px-2.5 text-sm text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground",
className
)}
>
<Megaphone className="h-[17px] w-[17px]" />
What&apos;s New
{isNew && (
<span
data-testid="whats-new-badge"
className="ml-auto rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary"
>
New
</span>
)}
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg max-h-[85vh] grid-rows-[auto_1fr] gap-0 p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2">
<Megaphone className="h-4 w-4 text-primary" />
What&apos;s new
</DialogTitle>
<DialogDescription>
What has landed in WFDASH lately. The most recent release is at the top.
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto px-6 py-4">
<ol className="space-y-5">
{RELEASE_NOTES.map((release, i) => (
<li key={release.version} className="space-y-2">
<div className="flex items-baseline gap-2">
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide",
i === 0
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}
>
{release.version}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(release.date)}
</span>
</div>
<p className="text-sm font-medium">{release.title}</p>
<ul className="space-y-1.5">
{release.items.map((item) => (
<li key={item} className="flex gap-2 text-sm text-muted-foreground">
<span
aria-hidden
className="mt-[7px] h-1 w-1 shrink-0 rounded-full bg-primary/70"
/>
<span className="leading-relaxed">{item}</span>
</li>
))}
</ul>
</li>
))}
</ol>
</div>
</DialogContent>
</Dialog>
</>
);
}

82
src/lib/release-notes.ts Normal file
View file

@ -0,0 +1,82 @@
/**
* What's New — the release-note registry behind the Settings page popup.
*
* A plain data file, newest first. There's no changelog table and no
* fetch: the notes are written by hand when a release goes out, and the
* popup renders whatever is in here. That keeps the whole feature
* client-side (it opens instantly, works offline) and means the copy
* stays deliberate rather than being generated from commit messages.
*
* To publish a release: add an entry at the top of the array. The
* version string is the "seen" marker — bumping it is what makes the
* What's New button light up for every operator again.
*/
export interface ReleaseNote {
/** Version marker. Bump this to re-notify operators. */
version: string;
/** ISO date (YYYY-MM-DD) of the release. */
date: string;
/** One-line headline for the release. */
title: string;
/** The changes, one sentence each. */
items: string[];
}
export const RELEASE_NOTES: ReleaseNote[] = [
{
version: "2026.11.01",
date: "2026-11-01",
title: "Delivery Pool, letterhead lock and a dark mode section",
items: [
"Delivery Pool: invoiced lines waiting to go out, one card per customer invoice. Assign a driver, upload the signed copy, and the delivery closes.",
"Quote letterheads are locked to the customer's company — one letterhead per company, so quotes always go out on the right paper.",
"The counter never sells at R0. Items without a price stay off the till, and customer lines go to the price list instead.",
"The sidebar now has a Dark Mode section with a light/dark switch. Theme colours live in Settings → Appearance.",
"This What's New popup — press it in the sidebar to see what changed.",
],
},
{
version: "2026.10.30",
date: "2026-10-30",
title: "Collection Confirm gets a second pair of eyes",
items: [
"Collection Confirm: a Notes button on each PO card, so the driver photos can be checked against the PO before you confirm.",
"The AI never changes a price. A different cost on the invoice is flagged as a red Price tick for a person to settle.",
"Collection Pool: stock lines can go back to pending and be marked ready again.",
"Quotes now record who they were sent to.",
],
},
{
version: "2026.10.28",
date: "2026-10-28",
title: "Auto-push and a shortcut to AI settings",
items: [
"Collection Confirm can auto-push purple cards straight on to invoicing, with a two-day log of everything it moved.",
"The Settings page links straight to AI settings.",
],
},
{
version: "2026.10.26",
date: "2026-10-26",
title: "A phone board for the managers",
items: [
"/manager is a mobile board for the managers: every order and where it is, on a phone.",
"The preview popup is the same one the desk uses on /search.",
],
},
{
version: "2026.10.24",
date: "2026-10-24",
title: "One POS matcher, AI-only quote matching",
items: [
"One size-aware matcher now drives the quote assistant, the order fill and the blue price check — so they can't disagree about an item.",
"The quote assistant prices only an exact match and shows the closest one instead when it isn't sure.",
"Quote matching is AI-only. The old rule-based scorer is retired; the AI links an order when it's sure and suggests otherwise.",
"The blue price check badge appears on both Process pages.",
],
},
];
/** The newest entry — the one the "New" dot on the button points at. */
export const LATEST_RELEASE: ReleaseNote = RELEASE_NOTES[0];