From c31dd1e05f23d10ed80f072f6f32acf3d033df0e Mon Sep 17 00:00:00 2001 From: okaybro Date: Wed, 14 Jan 2026 22:24:11 +0530 Subject: [PATCH 1/4] fix: correct streak offset validation to allow .5 decrements (@Chaitanya-Keyal) (#7363) ### Description The current streak hour offset validation fails for values like `-5.5` because `-5.5 % 1` evaluates to `-0.5`, causing the fractional check to fail. This PR simplifies the validation logic by multiplying the value by 2 and checking whether the result is an integer. This correctly allows only whole-hour and half-hour (`.5`) offsets while handling negative values properly. Screenshot 2026-01-14 203300 ### Checks - [x] Check if any open issues are related to this PR; if so, be sure to tag them below. - [x] Make sure the PR title follows the Conventional Commits standard. (https://www.conventionalcommits.org for more info) - [x] Make sure to include your GitHub username prefixed with @ inside parentheses at the end of the PR title. Closes #7204, related PRs #7269, #7362 --- frontend/src/ts/modals/streak-hour-offset.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/ts/modals/streak-hour-offset.ts b/frontend/src/ts/modals/streak-hour-offset.ts index 1bb295d21c52..6a84d22ff990 100644 --- a/frontend/src/ts/modals/streak-hour-offset.ts +++ b/frontend/src/ts/modals/streak-hour-offset.ts @@ -84,7 +84,8 @@ async function apply(): Promise { return; } - if (value < -11 || value > 12 || (value % 1 !== 0 && value % 1 !== 0.5)) { + // Check if value is whole number or ends in .5 (multiply by 2 to check if result is integer) + if (value < -11 || value > 12 || (value * 2) % 1 !== 0) { Notifications.add( "Streak offset must be between -11 and 12. Times ending in .5 can be used for 30-minute increments.", 0, From a795b9dab30893ae4fcabcd97704622ca4045d91 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Wed, 14 Jan 2026 22:08:54 +0100 Subject: [PATCH 2/4] refactor: convert about page to a component (@fehmer, @miodec) (#7350) Co-authored-by: Miodec --- frontend/src/html/pages/about.html | 322 ----------- frontend/src/index.html | 7 +- frontend/src/styles/about.scss | 115 ---- frontend/src/styles/ads.scss | 1 - frontend/src/styles/index.scss | 6 +- frontend/src/styles/media-queries-blue.scss | 5 - frontend/src/styles/media-queries-green.scss | 7 - frontend/src/styles/media-queries-purple.scss | 7 - frontend/src/ts/components/AboutPage.scss | 159 ++++++ frontend/src/ts/components/AboutPage.tsx | 515 ++++++++++++++++++ frontend/src/ts/components/AsyncContent.tsx | 98 +++- frontend/src/ts/components/Button.tsx | 5 +- frontend/src/ts/components/ChartJs.tsx | 67 +++ frontend/src/ts/components/mount.tsx | 2 + frontend/src/ts/controllers/ad-controller.ts | 4 - .../src/ts/controllers/chart-controller.ts | 95 +--- .../src/ts/controllers/page-controller.ts | 51 +- .../src/ts/controllers/theme-controller.ts | 2 - frontend/src/ts/elements/theme-colors.ts | 76 +-- frontend/src/ts/event-handlers/about.ts | 10 - frontend/src/ts/index.ts | 2 - frontend/src/ts/pages/about.ts | 217 -------- frontend/src/ts/pages/account.ts | 2 - frontend/src/ts/signals/config.ts | 13 + frontend/src/ts/signals/theme.ts | 31 ++ 25 files changed, 948 insertions(+), 871 deletions(-) delete mode 100644 frontend/src/html/pages/about.html delete mode 100644 frontend/src/styles/about.scss create mode 100644 frontend/src/ts/components/AboutPage.scss create mode 100644 frontend/src/ts/components/AboutPage.tsx create mode 100644 frontend/src/ts/components/ChartJs.tsx delete mode 100644 frontend/src/ts/event-handlers/about.ts delete mode 100644 frontend/src/ts/pages/about.ts create mode 100644 frontend/src/ts/signals/config.ts create mode 100644 frontend/src/ts/signals/theme.ts diff --git a/frontend/src/html/pages/about.html b/frontend/src/html/pages/about.html deleted file mode 100644 index 2e06ef79d9f8..000000000000 --- a/frontend/src/html/pages/about.html +++ /dev/null @@ -1,322 +0,0 @@ - diff --git a/frontend/src/index.html b/frontend/src/index.html index 70da78146f8e..75440c591c7a 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -37,7 +37,12 @@
- + diff --git a/frontend/src/styles/about.scss b/frontend/src/styles/about.scss deleted file mode 100644 index 40870fdc58b2..000000000000 --- a/frontend/src/styles/about.scss +++ /dev/null @@ -1,115 +0,0 @@ -.pageAbout { - display: flex; - flex-direction: column; - gap: 2rem; - - h2 { - font-size: 1rem; - margin: 0; - font-weight: unset; - } - - .created { - text-align: center; - color: var(--sub-color); - } - - .section { - display: grid; - - .bigtitle { - font-size: 2rem; - color: var(--sub-color); - margin: 0 0 1rem 0; - display: inline-flex; - align-items: baseline; - margin-bottom: 0.25em; - i { - margin-right: 0.5em; - } - } - - .title { - font-size: 1rem; - color: var(--sub-color); - margin: 0; - font-weight: 300; - display: inline-flex; - align-items: baseline; - margin-bottom: 0.25em; - i { - margin-right: 0.5em; - } - } - - .contactButtons, - .supportButtons { - margin-top: 1rem; - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; - gap: 1rem; - button, - .button { - font-size: 1.5rem; - padding: 2rem 0; - } - } - - .supportButtons { - grid-template-columns: 1fr; - } - - .supporters, - .contributors { - display: grid; - // grid-template-columns: 1fr 1fr 1fr 1fr; - grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); - gap: 0.25rem; - color: var(--text-color); - } - - p { - margin: 0; - padding: 0; - color: var(--text-color); - &.small { - font-size: 0.75em; - color: var(--sub-color); - text-align: right; - } - } - } - - .triplegroup { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: 1rem; - justify-items: center; - margin-top: 1rem; - } - .group { - display: flex; - flex-direction: column; - justify-content: center; - text-align: center; - .label { - color: var(--sub-color); - } - .val { - font-size: 3rem; - line-height: 3.5rem; - } - .valSmall { - font-size: 1.5rem; - line-height: 1.5rem; - } - } - - .chart canvas { - width: 100% !important; - } - .chart { - margin-top: 1rem; - position: relative; - } -} diff --git a/frontend/src/styles/ads.scss b/frontend/src/styles/ads.scss index 4ad1fea09c57..ea56a5cc7063 100644 --- a/frontend/src/styles/ads.scss +++ b/frontend/src/styles/ads.scss @@ -9,7 +9,6 @@ } .pageSettings .ad, -.pageAbout .ad, .pageAccount .ad { margin: 0 auto; } diff --git a/frontend/src/styles/index.scss b/frontend/src/styles/index.scss index 9b0dc5e3bbaf..8166a5af1427 100644 --- a/frontend/src/styles/index.scss +++ b/frontend/src/styles/index.scss @@ -1,5 +1,5 @@ -@import "buttons", "fonts", "404", "ads", "about", "account", "animations", - "banners", "caret", "commandline", "core", "inputs", "keymap", "login", - "monkey", "nav", "notifications", "popups", "profile", "scroll", "settings", +@import "buttons", "fonts", "404", "ads", "account", "animations", "banners", + "caret", "commandline", "core", "inputs", "keymap", "login", "monkey", "nav", + "notifications", "popups", "profile", "scroll", "settings", "account-settings", "leaderboards", "test", "loading", "friends", "media-queries"; diff --git a/frontend/src/styles/media-queries-blue.scss b/frontend/src/styles/media-queries-blue.scss index acd40ab0bf65..8723a0c5360b 100644 --- a/frontend/src/styles/media-queries-blue.scss +++ b/frontend/src/styles/media-queries-blue.scss @@ -75,11 +75,6 @@ } } } - .pageAbout { - .triplegroup { - grid-template-columns: 1fr; - } - } .pageAccount { .triplegroup.stats { .title { diff --git a/frontend/src/styles/media-queries-green.scss b/frontend/src/styles/media-queries-green.scss index 78fca23abc3c..fce0689a95a7 100644 --- a/frontend/src/styles/media-queries-green.scss +++ b/frontend/src/styles/media-queries-green.scss @@ -62,13 +62,6 @@ font-size: 7rem; } } - .pageAbout { - .section { - .contactButtons { - grid-template-columns: 1fr 1fr; - } - } - } .pageSettings { .section { grid-template-columns: 1fr 1fr; diff --git a/frontend/src/styles/media-queries-purple.scss b/frontend/src/styles/media-queries-purple.scss index 0e5fe0534e8a..b4dd2809bd57 100644 --- a/frontend/src/styles/media-queries-purple.scss +++ b/frontend/src/styles/media-queries-purple.scss @@ -113,13 +113,6 @@ } } } - .pageAbout { - .section { - .contactButtons { - grid-template-columns: 1fr; - } - } - } .pageAccount { .accountVerificatinNotice { button { diff --git a/frontend/src/ts/components/AboutPage.scss b/frontend/src/ts/components/AboutPage.scss new file mode 100644 index 000000000000..91c8fd12d3ee --- /dev/null +++ b/frontend/src/ts/components/AboutPage.scss @@ -0,0 +1,159 @@ +@use "../../styles/media.scss" as *; + +.pageAbout { + gap: 2rem; + + h2 { + font-size: 1em; + margin: 0; + font-weight: unset; + } + + .created { + text-align: center; + color: var(--sub-color); + } + + .section { + display: grid; + + &[data-section="credits"] { + button, + a { + margin-left: -0.5em; + padding-block: 0.25em; + text-decoration: underline; + } + } + + &[data-section="supporters"], + &[data-section="contributors"] { + .preloader { + padding-block: 2em; + font-size: 2em; + color: var(--main-color); + text-align: center; + } + .error { + padding-block: 2em; + color: var(--sub-color); + text-align: center; + } + } + + .bigtitle { + font-size: 2em; + color: var(--sub-color); + margin-bottom: 0.25em; + } + + .title { + color: var(--sub-color); + margin-bottom: 0.25em; + } + + .bigtitle, + .title { + i { + margin-right: 0.5em; + } + } + + .contactButtons, + .supportButtons { + margin-top: 1rem; + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + gap: 1rem; + button, + .button { + font-size: 1.5em; + padding: 1em 0; + } + } + + .contactButtons { + @include media-query(green) { + grid-template-columns: 1fr 1fr; + } + @include media-query(purple) { + grid-template-columns: 1fr; + } + } + + .supportButtons { + grid-template-columns: 1fr; + } + + .supporters, + .contributors { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(13em, 1fr)); + gap: 0.25em; + } + + p { + margin: 0; + padding: 0; + color: var(--text-color); + &.small { + font-size: 0.75em; + color: var(--sub-color); + text-align: right; + } + } + } + + .triplegroup { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 1em; + @include media-query(blue) { + grid-template-columns: 1fr; + } + } + + .group { + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + .label { + color: var(--sub-color); + } + .val { + font-size: 3em; + } + .valSmall { + font-size: 1.5em; + } + } + + .chart { + margin-top: 1em; + position: relative; + canvas { + width: 100% !important; + } + } + + .ad { + margin: 0 auto; + } + + dl { + display: contents; + } + + dt { + grid-column: 1; + margin: 0; + padding-right: 0.5em; + padding-bottom: 0.25em; + } + + dd { + grid-column: 2; + margin: 0; + } +} diff --git a/frontend/src/ts/components/AboutPage.tsx b/frontend/src/ts/components/AboutPage.tsx new file mode 100644 index 000000000000..39839cebd9fc --- /dev/null +++ b/frontend/src/ts/components/AboutPage.tsx @@ -0,0 +1,515 @@ +import { createEffect, createResource, For, JSXElement, Show } from "solid-js"; +import "./AboutPage.scss"; +import { Button } from "./Button"; +import { showModal } from "../stores/modals"; +import AsyncContent from "./AsyncContent"; +import { getActivePage } from "../signals/core"; +import { getAds } from "../signals/config"; +import { getContributorsList, getSupportersList } from "../utils/json-data"; +import Ape from "../ape"; +import { intervalToDuration } from "date-fns"; +import { getNumberWithMagnitude, numberWithSpaces } from "../utils/numbers"; +import { ChartJs } from "./ChartJs"; +import { getThemeColors } from "../signals/theme"; + +export function AboutPage(): JSXElement { + const isOpen = (): boolean => getActivePage() === "about"; + const [contributors] = createResource(isOpen, async (open) => + open ? await getContributorsList() : undefined, + ); + const [supporters] = createResource(isOpen, async (open) => + open ? await getSupportersList() : undefined, + ); + + const [typingStats] = createResource(isOpen, async (open) => + open ? await fetchTypingStats() : undefined, + ); + + const [speedHistogram] = createResource(isOpen, async (open) => + open ? await fetchSpeedHistogram() : undefined, + ); + + createEffect(() => { + console.log(getThemeColors()); + }); + + return ( + +
+ Created with love by Miodec. +
+ Supported and{" "} + expanded by many awesome people. +
+ Launched on 15th of May, 2020. +
+
+ + {(data) => ( +
+
+
total tests started
+
{data?.testsStarted.text ?? "-"}
+
{data?.testsStarted.subText ?? "-"}
+
+
+
total typing time
+
{data?.timeTyping.text ?? "-"}
+
{data?.timeTyping.subText ?? "-"}
+
+
+
total tests completed
+
{data?.testsCompleted.text ?? "-"}
+
+ {data?.testsCompleted.subText ?? "-"} +
+
+
+ )} +
+
+
+ + {(data) => ( + + )} + +
+

distribution of time 60 leaderboard results (wpm)

+
+
+
+
+ + about +
+

+ Monkeytype is a minimalistic and customizable typing test. It features + many test modes, an account system to save your typing speed history, + and user-configurable features such as themes, sounds, a smooth caret, + and more. Monkeytype attempts to emulate the experience of natural + keyboard typing during a typing test, by unobtrusively presenting the + text prompts and displaying typed characters in-place, providing + straightforward, real-time feedback on typos, speed, and accuracy. +
+
+ Test yourself in various modes, track your progress and improve your + speed. +

+
+
+
+ + word set +
+

+ By default, this website uses the most common 200 words in the English + language to generate its tests. You can change to an expanded set + (1000 most common words) in the options, or change the language + entirely. +

+
+
+
+ + keybinds +
+

+ You can use tab and enter (or just{" "} + tab if you have quick tab mode enabled) to restart the + typing test. Open the command line by pressing ctrl/cmd +{" "} + shift + p or esc - there you can + access all the functionality you need without touching your mouse. +

+
+
+
+ + stats +
+ +
+
wpm
+
+ - total number of characters in the correctly typed words (including + spaces), divided by 5 and normalised to 60 seconds. +
+ +
raw wpm
+
+ {" "} + - calculated just like wpm, but also includes incorrect words. +
+ +
acc
+
- percentage of correctly pressed keys.
+ +
char
+
+ - correct characters / incorrect characters. Calculated after the + test has ended. +
+ +
consistency
+
+ - based on the variance of your raw wpm. Closer to 100% is better. + Calculated using the coefficient of variation of raw wpm and mapped + onto a scale from 0 to 100. +
+
+
+ + + + +
+
+ + results screen +
+

+ After completing a test you will be able to see your wpm, raw wpm, + accuracy, character stats, test length, leaderboards info and test + info (you can hover over some values to get floating point numbers). + You can also see a graph of your wpm and raw over the duration of the + test. Remember that the wpm line is a global average, while the raw + wpm line is a local, momentary value (meaning if you stop, the value + is 0). +

+
+
+
+ + bug report or feature request +
+

+ If you encounter a bug, or have a feature request - join the Discord + server, send me an email, a direct message on Twitter or create an + issue on GitHub. +

+
+
+
+
+ + support +
+

+ Thanks to everyone who has supported this project. It would not be + possible without you and your continued support. +

+
+
+
+
+
+
+ + contact +
+

+ If you encounter a bug, have a feature request or just want to say hi + - here are the different ways you can contact me directly. +

+
+
+
+
+
+
+ + credits +
+

+

+ + + + +
+
+
+ + top supporters +
+ + {(data) => ( +
+ {(name) =>
{name}
}
+
+ )} +
+
+
+
+
+ + contributors +
+ + {(data) => ( +
+ {(name) =>
{name}
}
+
+ )} +
+
+
+ ); +} + +type GroupDisplay = { + label: string; + text: string; + subText: string; +}; + +async function fetchTypingStats(): Promise<{ + timeTyping: GroupDisplay; + testsStarted: GroupDisplay; + testsCompleted: GroupDisplay; +}> { + const response = await Ape.public.getTypingStats(); + + if (response.status !== 200) { + throw new Error(response.body.message); + } + const data = response.body.data; + + const typingSecondsRounded = Math.round(data.timeTyping); + const typingDuration = intervalToDuration({ + start: 0, + end: typingSecondsRounded * 1000, + }); + const startedWithMagnitude = getNumberWithMagnitude(data.testsStarted); + const completedWithMagnitude = getNumberWithMagnitude(data.testsCompleted); + + const result = { + timeTyping: { + label: + numberWithSpaces(Math.round(typingSecondsRounded / 3600)) + " hours", + text: typingDuration.years?.toString() ?? "", + subText: "years", + }, + testsStarted: { + label: numberWithSpaces(data.testsStarted) + " tests", + text: + startedWithMagnitude.rounded < 10 + ? startedWithMagnitude.roundedTo2.toString() + : startedWithMagnitude.rounded.toString(), + subText: startedWithMagnitude.orderOfMagnitude, + }, + testsCompleted: { + label: numberWithSpaces(data.testsCompleted) + " tests", + text: + completedWithMagnitude.rounded < 10 + ? completedWithMagnitude.roundedTo2.toString() + : completedWithMagnitude.rounded.toString(), + subText: completedWithMagnitude.orderOfMagnitude, + }, + }; + return result; +} + +async function fetchSpeedHistogram(): Promise< + | { + labels: string[]; + data: { x: number; y: number }[]; + } + | undefined +> { + const response = await Ape.public.getSpeedHistogram({ + query: { + language: "english", + mode: "time", + mode2: "60", + }, + }); + + if (response.status !== 200) { + throw new Error(response.body.message); + } + + const data = response.body.data; + + const histogramChartDataBucketed: { x: number; y: number }[] = []; + const labels: string[] = []; + + const keys = Object.keys(data).sort( + (a, b) => parseInt(a, 10) - parseInt(b, 10), + ); + for (const [i, key] of keys.entries()) { + const nextKey = keys[i + 1]; + const bucket = parseInt(key, 10); + histogramChartDataBucketed.push({ + x: bucket, + y: data[bucket] as number, + }); + labels.push(`${bucket} - ${bucket + 9}`); + if (nextKey !== undefined && bucket + 10 !== parseInt(nextKey, 10)) { + for (let j = bucket + 10; j < parseInt(nextKey, 10); j += 10) { + histogramChartDataBucketed.push({ x: j, y: 0 }); + labels.push(`${j} - ${j + 9}`); + } + } + } + return { data: histogramChartDataBucketed, labels }; +} diff --git a/frontend/src/ts/components/AsyncContent.tsx b/frontend/src/ts/components/AsyncContent.tsx index f1b0b0801501..1400fbee665c 100644 --- a/frontend/src/ts/components/AsyncContent.tsx +++ b/frontend/src/ts/components/AsyncContent.tsx @@ -1,28 +1,80 @@ -import { ErrorBoundary, JSXElement, Resource, Suspense } from "solid-js"; +import { ErrorBoundary, JSXElement, Resource, Show, Suspense } from "solid-js"; import { createErrorMessage } from "../utils/misc"; +import * as Notifications from "../elements/notifications"; +import { Conditional } from "./Conditional"; + +export default function AsyncContent( + props: { + resource: Resource; + errorMessage?: string; + } & ( + | { + alwaysShowContent?: never; + children: (data: T) => JSXElement; + } + | { + alwaysShowContent: true; + showLoader?: true; + children: (data: T | undefined) => JSXElement; + } + ), +): JSXElement { + const value = () => { + try { + return props.resource(); + } catch (err) { + const message = createErrorMessage( + err, + props.errorMessage ?? "An error occurred", + ); + console.error("AsyncContent error:", message); + Notifications.add(message, -1); + return undefined; + } + }; + const handleError = (err: unknown): string => { + console.error(err); + return createErrorMessage(err, props.errorMessage ?? "An error occurred"); + }; -export default function AsyncContent(props: { - resource: Resource; - errorMessage?: string; - children: (data: T) => JSXElement; -}): JSXElement { return ( - ( -
- {createErrorMessage(err, props.errorMessage ?? "An error occurred")} -
- )} - > - - - - } - > - {props.children(props.resource() as T)} - -
+ { + const p = props as { + showLoader?: true; + children: (data: T | undefined) => JSXElement; + }; + return ( + <> + +
+ +
+
+ {p.children(value())} + + ); + })()} + else={ +
{handleError(err)}
} + > + + + + } + > + + {props.children(props.resource() as T)} + + +
+ } + /> ); } diff --git a/frontend/src/ts/components/Button.tsx b/frontend/src/ts/components/Button.tsx index 89596d0ea393..8c7be2f8b468 100644 --- a/frontend/src/ts/components/Button.tsx +++ b/frontend/src/ts/components/Button.tsx @@ -13,6 +13,7 @@ type BaseProps = { type ButtonProps = BaseProps & { onClick: () => void; href?: never; + sameTarget?: true; }; type AnchorProps = BaseProps & { @@ -53,8 +54,8 @@ export function Button(props: ButtonProps | AnchorProps): JSXElement { {content} diff --git a/frontend/src/ts/components/ChartJs.tsx b/frontend/src/ts/components/ChartJs.tsx new file mode 100644 index 000000000000..081600fbe5d2 --- /dev/null +++ b/frontend/src/ts/components/ChartJs.tsx @@ -0,0 +1,67 @@ +import { onMount, onCleanup, createEffect, JSXElement } from "solid-js"; +import { + Chart, + ChartType, + ChartData, + ChartOptions, + DefaultDataPoint, +} from "chart.js"; +import { useRefWithUtils } from "../hooks/useRefWithUtils"; +import { ChartWithUpdateColors } from "../controllers/chart-controller"; +import { getThemeColors } from "../signals/theme"; + +type ChartJSProps< + T extends ChartType = ChartType, + TData = DefaultDataPoint, +> = { + type: T; + data: ChartData; + options?: ChartOptions; + onChartInit?: (chart: Chart) => void; +}; + +export function ChartJs>( + props: ChartJSProps, +): JSXElement { + // Refs are assigned by SolidJS via the ref attribute + const [canvasRef, canvasEl] = useRefWithUtils(); + + let chart: ChartWithUpdateColors | undefined; + + onMount(() => { + //oxlint-disable-next-line no-non-null-assertion + chart = new ChartWithUpdateColors(canvasEl()!.native, { + type: props.type, + data: props.data, + options: props.options, + }); + + props.onChartInit?.(chart); + }); + + createEffect(() => { + if (!chart) return; + + chart.config.type = props.type; + chart.data = props.data; + if (props.options) { + chart.options = props.options; + } + chart.update(); + void chart.updateColors(); + }); + + createEffect(() => { + //react on theme changes + const colors = getThemeColors(); + if (!chart) return; + + void chart.updateColors(colors); + }); + + onCleanup(() => { + chart?.destroy(); + }); + + return ; +} diff --git a/frontend/src/ts/components/mount.tsx b/frontend/src/ts/components/mount.tsx index fceb52743005..525e4dfe0af5 100644 --- a/frontend/src/ts/components/mount.tsx +++ b/frontend/src/ts/components/mount.tsx @@ -4,10 +4,12 @@ import { qsa } from "../utils/dom"; import { JSXElement } from "solid-js"; import { Footer } from "./Footer"; import { Modals } from "./Modals"; +import { AboutPage } from "./AboutPage"; const components: Record JSXElement> = { Footer: () =>