From c1b1022b4dd3ac70886ce71cd82e92be8d7f793e Mon Sep 17 00:00:00 2001 From: untamedbreed11 Date: Tue, 29 Sep 2026 13:10:43 +0100 Subject: [PATCH] feat: events calendar, invoice security dashboard, resource library, accessibility audit - #860: community events calendar with month grid, type filter and RSVP (lib/creatorEvents, CommunityEventsCalendar, /creator/events). - #861: invoice security dashboard with weighted score, per-kind counts, highest-risk invoices and suspicious-activity alerts (lib/invoiceSecurity, InvoiceSecurityDashboard, /security). - #862: creator resource library with search, category filter and persisted bookmarks (lib/creatorResources, ResourceLibrary, /creator/resources). - #863: accessibility audit (alt text, control names, form labels, heading order, colour contrast) surfaced on the accessibility settings page (lib/accessibilityAudit, AccessibilityAuditPanel). Each feature ships with tests and responsive layouts. Closes #860 Closes #861 Closes #862 Closes #863 --- src/__tests__/AccessibilityAudit.test.tsx | 80 +++++++++++ .../CommunityEventsCalendar.test.tsx | 72 ++++++++++ .../InvoiceSecurityDashboard.test.tsx | 64 +++++++++ src/__tests__/ResourceLibrary.test.tsx | 54 ++++++++ src/app/creator/events/page.tsx | 10 ++ src/app/creator/resources/page.tsx | 10 ++ src/app/security/page.tsx | 10 ++ src/app/settings/accessibility/page.tsx | 3 + src/components/AccessibilityAuditPanel.tsx | 46 ++++++ .../creator/CommunityEventsCalendar.tsx | 131 ++++++++++++++++++ src/components/creator/ResourceLibrary.tsx | 92 ++++++++++++ .../security/InvoiceSecurityDashboard.tsx | 90 ++++++++++++ src/lib/accessibilityAudit.ts | 108 +++++++++++++++ src/lib/creatorEvents.ts | 64 +++++++++ src/lib/creatorResources.ts | 99 +++++++++++++ src/lib/invoiceSecurity.ts | 73 ++++++++++ 16 files changed, 1006 insertions(+) create mode 100644 src/__tests__/AccessibilityAudit.test.tsx create mode 100644 src/__tests__/CommunityEventsCalendar.test.tsx create mode 100644 src/__tests__/InvoiceSecurityDashboard.test.tsx create mode 100644 src/__tests__/ResourceLibrary.test.tsx create mode 100644 src/app/creator/events/page.tsx create mode 100644 src/app/creator/resources/page.tsx create mode 100644 src/app/security/page.tsx create mode 100644 src/components/AccessibilityAuditPanel.tsx create mode 100644 src/components/creator/CommunityEventsCalendar.tsx create mode 100644 src/components/creator/ResourceLibrary.tsx create mode 100644 src/components/security/InvoiceSecurityDashboard.tsx create mode 100644 src/lib/accessibilityAudit.ts create mode 100644 src/lib/creatorEvents.ts create mode 100644 src/lib/creatorResources.ts create mode 100644 src/lib/invoiceSecurity.ts 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

+ + + + Home + + + + `); + 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.

+ ) : ( +
+ + + + + + + + + + {summary.riskiestInvoices.map((row) => ( + + + + + + ))} + +
InvoiceEventsRisk
{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. `