diff --git a/src/components/sidebar.test.tsx b/src/components/sidebar.test.tsx
index 686ee75..c8d17ef 100644
--- a/src/components/sidebar.test.tsx
+++ b/src/components/sidebar.test.tsx
@@ -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();
+ 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();
+ }
+ });
});
diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx
index 27c7ecf..0bba727 100644
--- a/src/components/sidebar.tsx
+++ b/src/components/sidebar.tsx
@@ -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 (
+
+
+
void }) {
Settings
-
+ {/* 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. */}
+
diff --git a/src/components/whats-new-dialog.test.tsx b/src/components/whats-new-dialog.test.tsx
new file mode 100644
index 0000000..d71e6ea
--- /dev/null
+++ b/src/components/whats-new-dialog.test.tsx
@@ -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();
+
+ expect(screen.getByTestId("whats-new-badge")).toBeInTheDocument();
+ });
+
+ it("marks the release as seen when the popup is opened", () => {
+ render();
+
+ // 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();
+
+ expect(screen.queryByTestId("whats-new-badge")).not.toBeInTheDocument();
+ });
+
+ it("shows the release notes in the popup", async () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "What's New" }));
+
+ expect(await screen.findByText(LATEST_RELEASE.title)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/whats-new-dialog.tsx b/src/components/whats-new-dialog.tsx
new file mode 100644
index 0000000..2c224fc
--- /dev/null
+++ b/src/components/whats-new-dialog.tsx
@@ -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 (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/lib/release-notes.ts b/src/lib/release-notes.ts
new file mode 100644
index 0000000..c0939be
--- /dev/null
+++ b/src/lib/release-notes.ts
@@ -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];