diff --git a/src/__tests__/AccessibilityAudit.test.tsx b/src/__tests__/AccessibilityAudit.test.tsx
new file mode 100644
index 00000000..76ec9633
--- /dev/null
+++ b/src/__tests__/AccessibilityAudit.test.tsx
@@ -0,0 +1,80 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import AccessibilityAuditPanel from "@/components/AccessibilityAuditPanel";
+import { auditColorPairs, auditDocument } from "@/lib/accessibilityAudit";
+
+function html(markup: string): HTMLElement {
+ const root = document.createElement("div");
+ root.innerHTML = markup;
+ document.body.appendChild(root);
+ return root;
+}
+
+afterEach(() => {
+ document.body.innerHTML = "";
+});
+
+describe("auditDocument", () => {
+ it("reports nothing for accessible markup", () => {
+ const root = html(`
+
Title
Section
+
+
+
+
+
+
+
+ `);
+ expect(auditDocument(root)).toEqual([]);
+ });
+
+ it("flags missing alt, unnamed controls, unlabeled fields and heading jumps", () => {
+ const root = html(`
+ Title
Skipped
+
+
+
+
+
+ `);
+ const rules = auditDocument(root).map((i) => i.rule);
+ expect(rules.filter((r) => r === "img-alt")).toHaveLength(1);
+ expect(rules.filter((r) => r === "control-name")).toHaveLength(2);
+ expect(rules.filter((r) => r === "form-label")).toHaveLength(2);
+ expect(rules.filter((r) => r === "heading-order")).toHaveLength(1);
+ });
+
+ it("accepts aria-labelledby references", () => {
+ const root = html(`Amount`);
+ expect(auditDocument(root)).toEqual([]);
+ });
+});
+
+describe("auditColorPairs", () => {
+ it("flags low contrast, honours large text and rejects invalid colors", () => {
+ const issues = auditColorPairs([
+ { label: "ok", foreground: "#000000", background: "#ffffff" },
+ { label: "low", foreground: "#aaaaaa", background: "#ffffff" },
+ { label: "large-ok", foreground: "#767676", background: "#ffffff", largeText: true },
+ { label: "bad", foreground: "nope", background: "#ffffff" },
+ ]);
+ expect(issues.map((i) => i.element)).toEqual(["low", "bad"]);
+ expect(issues[0].message).toContain("below the required 4.5:1");
+ });
+});
+
+describe("AccessibilityAuditPanel", () => {
+ it("runs the audit on demand", () => {
+ render(
+
+

+
+
,
+ );
+ expect(screen.queryByText(/issue/)).not.toBeInTheDocument();
+ fireEvent.click(screen.getByText("Run audit"));
+ expect(screen.getByText("1 issue found")).toBeInTheDocument();
+ expect(screen.getByText("Image is missing an alt attribute.")).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/CommunityEventsCalendar.test.tsx b/src/__tests__/CommunityEventsCalendar.test.tsx
new file mode 100644
index 00000000..e298e1f6
--- /dev/null
+++ b/src/__tests__/CommunityEventsCalendar.test.tsx
@@ -0,0 +1,72 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import CommunityEventsCalendar from "@/components/creator/CommunityEventsCalendar";
+import {
+ filterEvents,
+ groupEventsByDay,
+ monthGrid,
+ toggleRsvp,
+ upcomingEvents,
+ type CommunityEvent,
+} from "@/lib/creatorEvents";
+
+const events: CommunityEvent[] = [
+ { id: "b", title: "Invoice meetup", type: "meetup", startsAt: "2026-03-20T15:00:00", location: "Lagos" },
+ { id: "a", title: "Splits workshop", type: "workshop", startsAt: "2026-03-05T10:00:00" },
+ { id: "c", title: "April webinar", type: "webinar", startsAt: "2026-04-02T10:00:00" },
+ { id: "bad", title: "Broken", type: "meetup", startsAt: "not-a-date" },
+];
+
+describe("creatorEvents", () => {
+ it("sorts, filters by type and drops invalid dates", () => {
+ expect(filterEvents(events).map((e) => e.id)).toEqual(["a", "b", "c"]);
+ expect(filterEvents(events, "meetup").map((e) => e.id)).toEqual(["b"]);
+ });
+
+ it("returns only upcoming events", () => {
+ expect(upcomingEvents(events, new Date("2026-03-10T00:00:00")).map((e) => e.id)).toEqual(["b", "c"]);
+ });
+
+ it("groups events by local day", () => {
+ const groups = groupEventsByDay(events);
+ expect(groups.get("2026-03-05")?.map((e) => e.id)).toEqual(["a"]);
+ });
+
+ it("builds whole-week month grids", () => {
+ const grid = monthGrid(2026, 2); // March 2026 starts on a Sunday
+ expect(grid.length % 7).toBe(0);
+ expect(grid[0]?.getDate()).toBe(1);
+ expect(grid.filter(Boolean)).toHaveLength(31);
+ });
+
+ it("toggles RSVPs without mutating the input", () => {
+ const empty = new Set();
+ const one = toggleRsvp(empty, "a");
+ expect(one.has("a")).toBe(true);
+ expect(empty.size).toBe(0);
+ expect(toggleRsvp(one, "a").has("a")).toBe(false);
+ });
+});
+
+describe("CommunityEventsCalendar", () => {
+ it("shows the month's events, filters by type and navigates months", () => {
+ render();
+ expect(screen.getByRole("heading", { name: "March 2026" })).toBeInTheDocument();
+ expect(screen.getAllByText("Splits workshop").length).toBeGreaterThan(0);
+
+ fireEvent.change(screen.getByLabelText("Event type"), { target: { value: "meetup" } });
+ expect(screen.queryByText(/Splits workshop/)).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByLabelText("Next month"));
+ expect(screen.getByRole("heading", { name: "April 2026" })).toBeInTheDocument();
+ expect(screen.getByText("No events this month.")).toBeInTheDocument();
+ });
+
+ it("toggles RSVP and reports it", () => {
+ const onRsvpChange = vi.fn();
+ render();
+ fireEvent.click(screen.getAllByText("RSVP")[0]);
+ expect(onRsvpChange).toHaveBeenLastCalledWith(["a"]);
+ expect(screen.getByText("Going")).toHaveAttribute("aria-pressed", "true");
+ });
+});
diff --git a/src/__tests__/InvoiceSecurityDashboard.test.tsx b/src/__tests__/InvoiceSecurityDashboard.test.tsx
new file mode 100644
index 00000000..e63d8c10
--- /dev/null
+++ b/src/__tests__/InvoiceSecurityDashboard.test.tsx
@@ -0,0 +1,64 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import InvoiceSecurityDashboard from "@/components/security/InvoiceSecurityDashboard";
+import { levelForScore, summarizeInvoiceSecurity, type InvoiceSecurityEvent } from "@/lib/invoiceSecurity";
+
+const events: InvoiceSecurityEvent[] = [
+ { id: "1", invoiceId: "INV-1", kind: "failed_auth", timestamp: 1 },
+ { id: "2", invoiceId: "INV-1", kind: "large_payment", timestamp: 2 },
+ { id: "3", invoiceId: "INV-2", kind: "export", timestamp: 3 },
+];
+
+describe("invoiceSecurity", () => {
+ it("scores 100 with no events", () => {
+ const summary = summarizeInvoiceSecurity([]);
+ expect(summary.score).toBe(100);
+ expect(summary.level).toBe("good");
+ expect(summary.riskiestInvoices).toEqual([]);
+ });
+
+ it("weights events, counts by kind and ranks invoices by risk", () => {
+ const summary = summarizeInvoiceSecurity(events);
+ expect(summary.score).toBe(100 - 5 - 10 - 1);
+ expect(summary.countsByKind.failed_auth).toBe(1);
+ expect(summary.riskiestInvoices).toEqual([
+ { invoiceId: "INV-1", risk: 15, events: 2 },
+ { invoiceId: "INV-2", risk: 1, events: 1 },
+ ]);
+ });
+
+ it("never drops below zero and maps score to a level", () => {
+ const many = Array.from({ length: 50 }, (_, i) => ({
+ id: String(i),
+ invoiceId: "INV-X",
+ kind: "large_payment" as const,
+ timestamp: i,
+ }));
+ const summary = summarizeInvoiceSecurity(many);
+ expect(summary.score).toBe(0);
+ expect(summary.level).toBe("at_risk");
+ expect(levelForScore(79)).toBe("attention");
+ expect(levelForScore(80)).toBe("good");
+ });
+
+ it("includes suspicious-activity alerts in the score", () => {
+ const activity = Array.from({ length: 6 }, (_, i) => ({ id: `e${i}`, actor: "GA", amount: 10 + i, timestamp: i * 1000 }));
+ const summary = summarizeInvoiceSecurity([], activity);
+ expect(summary.alerts.length).toBeGreaterThan(0);
+ expect(summary.score).toBeLessThan(100);
+ });
+});
+
+describe("InvoiceSecurityDashboard", () => {
+ it("renders the score, counts and riskiest invoices", () => {
+ render();
+ expect(screen.getByRole("status")).toHaveTextContent("84");
+ expect(screen.getByText("Failed sign-ins")).toBeInTheDocument();
+ expect(screen.getByText("INV-1")).toBeInTheDocument();
+ });
+
+ it("shows an empty state", () => {
+ render();
+ expect(screen.getByText("No security events recorded.")).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/ResourceLibrary.test.tsx b/src/__tests__/ResourceLibrary.test.tsx
new file mode 100644
index 00000000..76895e6d
--- /dev/null
+++ b/src/__tests__/ResourceLibrary.test.tsx
@@ -0,0 +1,54 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import ResourceLibrary from "@/components/creator/ResourceLibrary";
+import {
+ DEFAULT_RESOURCES,
+ filterResources,
+ loadBookmarks,
+ saveBookmarks,
+ toggleBookmark,
+} from "@/lib/creatorResources";
+
+beforeEach(() => window.localStorage.clear());
+
+describe("creatorResources", () => {
+ it("filters by query (title, description, tags) and category", () => {
+ expect(filterResources(DEFAULT_RESOURCES, { query: "TAX" }).map((r) => r.id)).toEqual(["tax-export"]);
+ expect(filterResources(DEFAULT_RESOURCES, { category: "template" }).map((r) => r.id)).toEqual(["invoice-templates"]);
+ expect(filterResources(DEFAULT_RESOURCES, { category: "all" })).toHaveLength(DEFAULT_RESOURCES.length);
+ });
+
+ it("filters to bookmarks and toggles immutably", () => {
+ const none = new Set();
+ const one = toggleBookmark(none, "rate-card");
+ expect(none.size).toBe(0);
+ expect(filterResources(DEFAULT_RESOURCES, { bookmarkedOnly: true }, one).map((r) => r.id)).toEqual(["rate-card"]);
+ expect(toggleBookmark(one, "rate-card").size).toBe(0);
+ });
+
+ it("round-trips bookmarks and survives corrupt storage", () => {
+ saveBookmarks(new Set(["a", "b"]));
+ expect([...loadBookmarks()].sort()).toEqual(["a", "b"]);
+ window.localStorage.setItem("creator-resource-bookmarks", "{not json");
+ expect(loadBookmarks().size).toBe(0);
+ });
+});
+
+describe("ResourceLibrary", () => {
+ it("searches, and bookmarks persist", () => {
+ render();
+ fireEvent.change(screen.getByLabelText("Search resources"), { target: { value: "rate" } });
+ expect(screen.getByText("Rate card builder")).toBeInTheDocument();
+ expect(screen.queryByText("Tax export")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByLabelText("Bookmark Rate card builder"));
+ expect(screen.getByText("Bookmarked")).toBeInTheDocument();
+ expect([...loadBookmarks()]).toEqual(["rate-card"]);
+ });
+
+ it("shows an empty state", () => {
+ render();
+ fireEvent.change(screen.getByLabelText("Search resources"), { target: { value: "zzz-nothing" } });
+ expect(screen.getByText("No resources match.")).toBeInTheDocument();
+ });
+});
diff --git a/src/app/creator/events/page.tsx b/src/app/creator/events/page.tsx
new file mode 100644
index 00000000..9dafb102
--- /dev/null
+++ b/src/app/creator/events/page.tsx
@@ -0,0 +1,10 @@
+import CommunityEventsCalendar from "@/components/creator/CommunityEventsCalendar";
+
+export default function CreatorEventsPage() {
+ return (
+
+ Community events
+
+
+ );
+}
diff --git a/src/app/creator/resources/page.tsx b/src/app/creator/resources/page.tsx
new file mode 100644
index 00000000..239b4475
--- /dev/null
+++ b/src/app/creator/resources/page.tsx
@@ -0,0 +1,10 @@
+import ResourceLibrary from "@/components/creator/ResourceLibrary";
+
+export default function CreatorResourcesPage() {
+ return (
+
+ Creator resources
+
+
+ );
+}
diff --git a/src/app/security/page.tsx b/src/app/security/page.tsx
new file mode 100644
index 00000000..d8d6d512
--- /dev/null
+++ b/src/app/security/page.tsx
@@ -0,0 +1,10 @@
+import InvoiceSecurityDashboard from "@/components/security/InvoiceSecurityDashboard";
+
+export default function InvoiceSecurityPage() {
+ return (
+
+ Invoice security
+
+
+ );
+}
diff --git a/src/app/settings/accessibility/page.tsx b/src/app/settings/accessibility/page.tsx
index d754360b..2709f0f2 100644
--- a/src/app/settings/accessibility/page.tsx
+++ b/src/app/settings/accessibility/page.tsx
@@ -2,6 +2,7 @@
import type { Contrast, FontScale } from "@/contexts/AccessibilityContext";
import { useAccessibility } from "@/contexts/AccessibilityContext";
+import AccessibilityAuditPanel from "@/components/AccessibilityAuditPanel";
const FONT_SCALE_OPTIONS: Array<{
value: FontScale;
@@ -146,6 +147,8 @@ export default function AccessibilitySettingsPage() {
})}
+
+
);
diff --git a/src/components/AccessibilityAuditPanel.tsx b/src/components/AccessibilityAuditPanel.tsx
new file mode 100644
index 00000000..436c3896
--- /dev/null
+++ b/src/components/AccessibilityAuditPanel.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useState } from "react";
+import { auditDocument, type A11yIssue } from "@/lib/accessibilityAudit";
+
+/** Runs the accessibility audit over the current page and lists what it finds. */
+export default function AccessibilityAuditPanel() {
+ const [issues, setIssues] = useState(null);
+
+ return (
+
+
+ Accessibility audit
+
+
+ Check this page for missing alt text, unlabeled controls and fields, and skipped heading levels.
+
+
+
+
+ {issues !== null && issues.length === 0 &&
No issues found.
}
+ {issues !== null && issues.length > 0 && (
+ <>
+
+ {issues.length} issue{issues.length === 1 ? "" : "s"} found
+
+
+ {issues.map((issue, i) => (
+ -
+
{issue.message}
+ {issue.element}
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/creator/CommunityEventsCalendar.tsx b/src/components/creator/CommunityEventsCalendar.tsx
new file mode 100644
index 00000000..b2dc97fb
--- /dev/null
+++ b/src/components/creator/CommunityEventsCalendar.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import {
+ EVENT_TYPES,
+ dayKey,
+ filterEvents,
+ groupEventsByDay,
+ monthGrid,
+ toggleRsvp,
+ type CommunityEvent,
+ type EventType,
+} from "@/lib/creatorEvents";
+
+const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+interface Props {
+ events: CommunityEvent[];
+ /** Month to show first; defaults to the current month. */
+ initialDate?: Date;
+ onRsvpChange?: (eventIds: string[]) => void;
+}
+
+export default function CommunityEventsCalendar({ events, initialDate, onRsvpChange }: Props) {
+ const start = initialDate ?? new Date();
+ const [year, setYear] = useState(start.getFullYear());
+ const [month, setMonth] = useState(start.getMonth());
+ const [type, setType] = useState("all");
+ const [rsvps, setRsvps] = useState>(new Set());
+
+ const visible = useMemo(() => filterEvents(events, type), [events, type]);
+ const byDay = useMemo(() => groupEventsByDay(visible), [visible]);
+ const monthEvents = useMemo(
+ () =>
+ visible.filter((e) => {
+ const d = new Date(e.startsAt);
+ return d.getFullYear() === year && d.getMonth() === month;
+ }),
+ [visible, year, month],
+ );
+
+ const shift = (delta: number) => {
+ const next = new Date(year, month + delta, 1);
+ setYear(next.getFullYear());
+ setMonth(next.getMonth());
+ };
+ const rsvp = (id: string) => {
+ const next = toggleRsvp(rsvps, id);
+ setRsvps(next);
+ onRsvpChange?.([...next]);
+ };
+ const title = new Date(year, month, 1).toLocaleString("en-US", { month: "long", year: "numeric" });
+
+ return (
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+ {WEEKDAYS.map((d) => (
+
+ {d}
+
+ ))}
+ {monthGrid(year, month).map((cell, i) => {
+ const dayEvents = cell ? (byDay.get(dayKey(cell)) ?? []) : [];
+ return (
+
+ {cell &&
{cell.getDate()}}
+ {dayEvents.map((e) => (
+
+ {e.title}
+
+ ))}
+
+ );
+ })}
+
+
+
+ {monthEvents.length === 0 && - No events this month.
}
+ {monthEvents.map((e) => (
+ -
+
+
{e.title}
+
+ · {e.type}
+ {e.location ? ` · ${e.location}` : ""}
+
+ {e.description &&
{e.description}
}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/creator/ResourceLibrary.tsx b/src/components/creator/ResourceLibrary.tsx
new file mode 100644
index 00000000..0715cca3
--- /dev/null
+++ b/src/components/creator/ResourceLibrary.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import Link from "next/link";
+import {
+ DEFAULT_RESOURCES,
+ RESOURCE_CATEGORIES,
+ filterResources,
+ loadBookmarks,
+ saveBookmarks,
+ toggleBookmark,
+ type CreatorResource,
+ type ResourceCategory,
+} from "@/lib/creatorResources";
+
+export default function ResourceLibrary({ resources = DEFAULT_RESOURCES }: { resources?: CreatorResource[] }) {
+ const [query, setQuery] = useState("");
+ const [category, setCategory] = useState("all");
+ const [bookmarkedOnly, setBookmarkedOnly] = useState(false);
+ const [bookmarks, setBookmarks] = useState>(new Set());
+
+ useEffect(() => setBookmarks(loadBookmarks()), []);
+
+ const visible = useMemo(
+ () => filterResources(resources, { query, category, bookmarkedOnly }, bookmarks),
+ [resources, query, category, bookmarkedOnly, bookmarks],
+ );
+
+ const toggle = (id: string) => {
+ const next = toggleBookmark(bookmarks, id);
+ setBookmarks(next);
+ saveBookmarks(next);
+ };
+
+ return (
+
+
+ setQuery(e.target.value)}
+ />
+
+
+
+
+
+ {visible.length === 0 && - No resources match.
}
+ {visible.map((r) => (
+ -
+
+
+
+ {r.title}
+
+ {r.category}
+
+
{r.description}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/security/InvoiceSecurityDashboard.tsx b/src/components/security/InvoiceSecurityDashboard.tsx
new file mode 100644
index 00000000..ab2da68e
--- /dev/null
+++ b/src/components/security/InvoiceSecurityDashboard.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { useMemo } from "react";
+import {
+ summarizeInvoiceSecurity,
+ type InvoiceSecurityEvent,
+ type SecurityEventKind,
+ type SecurityLevel,
+} from "@/lib/invoiceSecurity";
+import type { ActivityEvent } from "@/lib/suspiciousActivity";
+import SuspiciousActivityAlerts from "@/components/security/SuspiciousActivityAlerts";
+
+const LEVEL_LABEL: Record = {
+ good: "Good",
+ attention: "Needs attention",
+ at_risk: "At risk",
+};
+const LEVEL_STYLE: Record = {
+ good: "border-green-300 bg-green-50 text-green-800",
+ attention: "border-amber-300 bg-amber-50 text-amber-800",
+ at_risk: "border-red-300 bg-red-50 text-red-800",
+};
+const KIND_LABEL: Record = {
+ failed_auth: "Failed sign-ins",
+ permission_change: "Permission changes",
+ large_payment: "Large payments",
+ link_shared: "Links shared",
+ export: "Exports",
+};
+
+interface Props {
+ events: InvoiceSecurityEvent[];
+ activity?: ActivityEvent[];
+}
+
+export default function InvoiceSecurityDashboard({ events, activity = [] }: Props) {
+ const summary = useMemo(() => summarizeInvoiceSecurity(events, activity), [events, activity]);
+
+ return (
+
+
+
Security score
+
{summary.score}
+
{LEVEL_LABEL[summary.level]}
+
+
+
+ {(Object.keys(KIND_LABEL) as SecurityEventKind[]).map((kind) => (
+
+
- {KIND_LABEL[kind]}
+ - {summary.countsByKind[kind]}
+
+ ))}
+
+
+
+
Highest-risk invoices
+ {summary.riskiestInvoices.length === 0 ? (
+
No security events recorded.
+ ) : (
+
+
+
+
+ | Invoice |
+ Events |
+ Risk |
+
+
+
+ {summary.riskiestInvoices.map((row) => (
+
+ | {row.invoiceId} |
+ {row.events} |
+ {row.risk} |
+
+ ))}
+
+
+
+ )}
+
+
+
+
Suspicious activity
+
+
+
+ );
+}
diff --git a/src/lib/accessibilityAudit.ts b/src/lib/accessibilityAudit.ts
new file mode 100644
index 00000000..07f7d4a4
--- /dev/null
+++ b/src/lib/accessibilityAudit.ts
@@ -0,0 +1,108 @@
+import { WCAG_AA_CONTRAST_RATIO, WCAG_AA_LARGE_TEXT_CONTRAST_RATIO, contrastRatio, isValidHexColor } from "@/lib/contrast";
+
+export type A11yRule = "img-alt" | "control-name" | "form-label" | "heading-order" | "color-contrast";
+
+export interface A11yIssue {
+ rule: A11yRule;
+ message: string;
+ /** Short description of the offending element, e.g. `