From 2635d12f896bb827e1f89cb134c8abff98ea1772 Mon Sep 17 00:00:00 2001 From: Jack Date: Sun, 25 Jan 2026 19:11:47 +0100 Subject: [PATCH 1/2] fix: screenshots not supporting css @layers (@miodec) (#7450) AI goes BRRRRRR this moves fonts and fontawesome back to layers where they belong. Thirds time the charm. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontend/src/styles/index.scss | 7 +- frontend/src/styles/vendor.scss | 23 +--- frontend/src/ts/test/test-screenshot.ts | 163 +++++++++++++++++++++++- 3 files changed, 165 insertions(+), 28 deletions(-) diff --git a/frontend/src/styles/index.scss b/frontend/src/styles/index.scss index 9d1b742572ce..98f89eaf2c94 100644 --- a/frontend/src/styles/index.scss +++ b/frontend/src/styles/index.scss @@ -15,13 +15,10 @@ } } -// the screenshotting library has some issues with css layers -@import "fonts"; - @layer custom-styles { @import "buttons", "404", "ads", "account", "animations", "caret", - "commandline", "core", "inputs", "keymap", "login", "monkey", "nav", - "notifications", "popups", "profile", "scroll", "settings", + "commandline", "core", "fonts", "inputs", "keymap", "login", "monkey", + "nav", "notifications", "popups", "profile", "scroll", "settings", "account-settings", "leaderboards", "test", "loading", "friends", "media-queries"; diff --git a/frontend/src/styles/vendor.scss b/frontend/src/styles/vendor.scss index 0b716f23a71d..d3845c1879d7 100644 --- a/frontend/src/styles/vendor.scss +++ b/frontend/src/styles/vendor.scss @@ -1,27 +1,6 @@ -@import "fontawesome-5"; // the screenshotting library has some issues with css layers - -/* fontawesome icon styles do not respect the hidden class from the hidden layer. -* By having these rules outside any layer we make sure that the display none is -* correctly applied when an element possesses both a .fa* class and the hidden class */ -.fas.hidden, -.fab.hidden, -.fa.hidden, -.far.hidden { - display: none; -} - -// same for invisible -.fas.invisible, -.fab.invisible, -.fa.invisible, -.far.invisible { - opacity: 0; - pointer-events: none; - visibility: hidden; -} - @import "normalize.css" layer(normalize); @layer vendor { + @import "fontawesome-5"; @import "slim-select/styles"; @import "balloon-css/src/balloon"; } diff --git a/frontend/src/ts/test/test-screenshot.ts b/frontend/src/ts/test/test-screenshot.ts index 0349011e6cc4..1939d3095b04 100644 --- a/frontend/src/ts/test/test-screenshot.ts +++ b/frontend/src/ts/test/test-screenshot.ts @@ -110,6 +110,14 @@ async function generateCanvas(): Promise { (document.querySelector("html") as HTMLElement).style.scrollBehavior = "auto"; window.scrollTo({ top: 0, behavior: "auto" }); + // --- Build embedded font CSS --- + let embeddedFontCss = ""; + try { + embeddedFontCss = await buildEmbeddedFontCss(); + } catch (e) { + console.warn("Failed to embed fonts:", e); + } + // --- Target Element Calculation --- const src = qs("#result .wrapper"); if (src === null) { @@ -118,7 +126,8 @@ async function generateCanvas(): Promise { revert(); return null; } - await Misc.sleep(50); // Small delay for render updates + // Wait a frame to ensure all UI changes are rendered + await new Promise((resolve) => requestAnimationFrame(resolve)); const sourceX = src.screenBounds().left ?? 0; const sourceY = src.screenBounds().top ?? 0; @@ -140,6 +149,10 @@ async function generateCanvas(): Promise { backgroundColor: getTheme().bg, // Sharp output scale: window.devicePixelRatio ?? 1, + + // Pass embedded font CSS with data URLs + font: embeddedFontCss ? { cssText: embeddedFontCss } : undefined, + style: { width: `${targetWidth}px`, height: `${targetHeight}px`, @@ -380,3 +393,151 @@ document.addEventListener("keyup", (event) => { ?.removeClass(["fas", "fa-download"]) ?.addClass(["far", "fa-image"]); }); + +//below is all ai magic + +/** + * Recursively extracts all @font-face rules from stylesheets, including those inside @layer + */ +function extractAllFontFaceRules(): CSSFontFaceRule[] { + const fontRules: CSSFontFaceRule[] = []; + + function traverseRules(rules: CSSRuleList): void { + for (const rule of rules) { + if (rule instanceof CSSFontFaceRule) { + fontRules.push(rule); + } else if ( + "cssRules" in rule && + typeof rule.cssRules === "object" && + rule.cssRules !== null + ) { + traverseRules(rule.cssRules as CSSRuleList); + } + } + } + + for (const sheet of document.styleSheets) { + try { + if (sheet?.cssRules?.length && sheet.cssRules.length > 0) { + traverseRules(sheet.cssRules); + } + } catch (e) { + console.warn("Cannot access stylesheet:", e); + } + } + + return fontRules; +} + +/** + * Fetches a font file and converts it to a data URL + */ +async function fontUrlToDataUrl(url: string): Promise { + try { + const absoluteUrl = new URL(url, window.location.href).href; + const response = await fetch(absoluteUrl, { + mode: "cors", + credentials: "omit", + }); + if (!response.ok) return null; + const blob = await response.blob(); + return await new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } +} + +/** + * Converts a @font-face rule to CSS text with embedded data URLs + */ +async function fontFaceRuleToEmbeddedCss( + rule: CSSFontFaceRule, +): Promise { + let cssText = rule.cssText; + const srcProperty = rule.style.getPropertyValue("src"); + + if (!srcProperty) return null; + + // Extract all url() references + const urlRegex = /url\(['"]?([^'"]+?)['"]?\)/g; + const matches = [...srcProperty.matchAll(urlRegex)]; + + if (matches.length === 0) return cssText; + + for (const match of matches) { + const originalUrl = match[1]; + if ( + typeof originalUrl !== "string" || + originalUrl === "" || + originalUrl.startsWith("data:") + ) { + continue; + } + const dataUrl = await fontUrlToDataUrl(originalUrl); + if (typeof dataUrl === "string" && dataUrl !== "") { + const urlPattern = new RegExp( + `url\\(['"]?${originalUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"]?\\)`, + "g", + ); + cssText = cssText.replace(urlPattern, () => `url(${dataUrl})`); + } + } + + return cssText; +} + +/** + * Collects all used font families in the document + */ +function getUsedFontFamilies(): Set { + const families = new Set(); + + // Walk through all elements + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_ELEMENT, + null, + ); + + let node: Node | null; + while ((node = walker.nextNode())) { + if (node instanceof HTMLElement) { + const fontFamily = getComputedStyle(node).fontFamily; + if (fontFamily) { + fontFamily.split(",").forEach((family) => { + families.add(family.trim().replace(/['"]/g, "").toLowerCase()); + }); + } + } + } + + return families; +} + +/** + * Builds font CSS with data URLs embedded, including fonts from @layer + */ +async function buildEmbeddedFontCss(): Promise { + const allFontRules = extractAllFontFaceRules(); + const usedFamilies = getUsedFontFamilies(); + const embeddedRules: string[] = []; + + for (const rule of allFontRules) { + const fontFamily = rule.style.getPropertyValue("font-family"); + if (!fontFamily) continue; + const normalizedFamily = fontFamily + .trim() + .replace(/['"]/g, "") + .toLowerCase(); + if (!usedFamilies.has(normalizedFamily)) continue; + const embeddedCss = await fontFaceRuleToEmbeddedCss(rule); + if (embeddedCss !== null) embeddedRules.push(embeddedCss); + } + + return embeddedRules.join("\n"); +} From a9ad61847e8ea26c4054812001e04d1c40d0d5b2 Mon Sep 17 00:00:00 2001 From: Jack Date: Sun, 25 Jan 2026 19:21:14 +0100 Subject: [PATCH 2/2] fix: fa component breaking font-subset (@miodec) (#7446) --- frontend/__tests__/components/Button.spec.tsx | 8 +- .../src/ts/components/common/AsyncContent.tsx | 4 +- frontend/src/ts/components/common/Fa.tsx | 2 +- .../ts/components/layout/footer/Footer.tsx | 16 +- .../components/layout/footer/ScrollToTop.tsx | 2 +- .../layout/footer/ThemeIndicator.tsx | 4 +- .../layout/footer/VersionButton.tsx | 2 +- .../ts/components/layout/overlays/Banners.tsx | 4 +- .../components/layout/overlays/Overlays.tsx | 2 +- .../src/ts/components/modals/ContactModal.tsx | 12 +- .../src/ts/components/modals/SupportModal.tsx | 10 +- .../src/ts/components/pages/AboutPage.tsx | 40 +- frontend/src/ts/types/font-awesome.d.ts | 3224 +++++++++-------- 13 files changed, 1666 insertions(+), 1664 deletions(-) diff --git a/frontend/__tests__/components/Button.spec.tsx b/frontend/__tests__/components/Button.spec.tsx index ba13e6ee0581..4c0ca6db2746 100644 --- a/frontend/__tests__/components/Button.spec.tsx +++ b/frontend/__tests__/components/Button.spec.tsx @@ -52,7 +52,7 @@ describe("Button component", () => { // }} fa={{ - icon: "keyboard", + icon: "fa-keyboard", }} /> )); @@ -70,7 +70,7 @@ describe("Button component", () => { // }} fa={{ - icon: "keyboard", + icon: "fa-keyboard", fixedWidth: true, }} /> @@ -88,7 +88,7 @@ describe("Button component", () => { }} fa={{ fixedWidth: true, - icon: "keyboard", + icon: "fa-keyboard", }} text="Hello" /> @@ -105,7 +105,7 @@ describe("Button component", () => { // }} fa={{ - icon: "keyboard", + icon: "fa-keyboard", }} text="Hello" /> diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index a36f82caa3fb..5d235672e904 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -52,7 +52,7 @@ export default function AsyncContent( <>
- +
{p.children(value())} @@ -66,7 +66,7 @@ export default function AsyncContent( - + } > diff --git a/frontend/src/ts/components/common/Fa.tsx b/frontend/src/ts/components/common/Fa.tsx index aa994ac26dfd..60604d84f930 100644 --- a/frontend/src/ts/components/common/Fa.tsx +++ b/frontend/src/ts/components/common/Fa.tsx @@ -12,7 +12,7 @@ export function Fa(props: FaProps): JSXElement { const variant = (): string => props.variant ?? "solid"; return ( showModal("Contact")} @@ -38,7 +38,7 @@ export function Footer(): JSXElement { type="text" text="support" fa={{ - icon: "donate", + icon: "fa-donate", fixedWidth: true, }} onClick={() => showModal("Support")} @@ -47,7 +47,7 @@ export function Footer(): JSXElement { type="text" text="github" fa={{ - icon: "code", + icon: "fa-code", fixedWidth: true, }} href="https://github.com/monkeytypegame/monkeytype" @@ -56,7 +56,7 @@ export function Footer(): JSXElement { type="text" text="discord" fa={{ - icon: "discord", + icon: "fa-discord", variant: "brand", fixedWidth: true, }} @@ -66,7 +66,7 @@ export function Footer(): JSXElement { type="text" text="twitter" fa={{ - icon: "twitter", + icon: "fa-twitter", variant: "brand", fixedWidth: true, }} @@ -76,7 +76,7 @@ export function Footer(): JSXElement { type="text" text="terms" fa={{ - icon: "file-contract", + icon: "fa-file-contract", fixedWidth: true, }} href="/terms-of-service.html" @@ -86,7 +86,7 @@ export function Footer(): JSXElement { type="text" text="security" fa={{ - icon: "shield-alt", + icon: "fa-shield-alt", fixedWidth: true, }} /> @@ -95,7 +95,7 @@ export function Footer(): JSXElement { type="text" text="privacy" fa={{ - icon: "lock", + icon: "fa-lock", fixedWidth: true, }} /> diff --git a/frontend/src/ts/components/layout/footer/ScrollToTop.tsx b/frontend/src/ts/components/layout/footer/ScrollToTop.tsx index 7c6238a0bb2f..81aa3602c901 100644 --- a/frontend/src/ts/components/layout/footer/ScrollToTop.tsx +++ b/frontend/src/ts/components/layout/footer/ScrollToTop.tsx @@ -43,7 +43,7 @@ export function ScrollToTop(): JSXElement { }); }} > - + ); diff --git a/frontend/src/ts/components/layout/footer/ThemeIndicator.tsx b/frontend/src/ts/components/layout/footer/ThemeIndicator.tsx index ed4d13ff6098..3be0b6f498f8 100644 --- a/frontend/src/ts/components/layout/footer/ThemeIndicator.tsx +++ b/frontend/src/ts/components/layout/footer/ThemeIndicator.tsx @@ -43,10 +43,10 @@ export function ThemeIndicator(): JSXElement {
- +
- +
{getThemeIndicator().text}
diff --git a/frontend/src/ts/components/layout/footer/VersionButton.tsx b/frontend/src/ts/components/layout/footer/VersionButton.tsx index a335878452e0..a390ec4e9c19 100644 --- a/frontend/src/ts/components/layout/footer/VersionButton.tsx +++ b/frontend/src/ts/components/layout/footer/VersionButton.tsx @@ -46,7 +46,7 @@ export function VersionButton(): JSXElement { return ( } /> @@ -96,7 +96,7 @@ export function Banners(): JSXElement { createEffectOn(() => getBanners().length, setGlobalOffsetSignal); return ( -
+
{(banner) => }
); diff --git a/frontend/src/ts/components/layout/overlays/Overlays.tsx b/frontend/src/ts/components/layout/overlays/Overlays.tsx index 0c66dd4f836b..798c85e0d795 100644 --- a/frontend/src/ts/components/layout/overlays/Overlays.tsx +++ b/frontend/src/ts/components/layout/overlays/Overlays.tsx @@ -29,7 +29,7 @@ export function Overlays(): JSXElement { }} tabIndex="-1" > - + diff --git a/frontend/src/ts/components/modals/ContactModal.tsx b/frontend/src/ts/components/modals/ContactModal.tsx index 6df442935430..9f5e0c31e16a 100644 --- a/frontend/src/ts/components/modals/ContactModal.tsx +++ b/frontend/src/ts/components/modals/ContactModal.tsx @@ -25,7 +25,7 @@ export function ContactModal(): JSXElement { text="Question" class={buttonClass} fa={{ - icon: "question-circle", + icon: "fa-question-circle", fixedWidth: true, }} /> @@ -33,7 +33,7 @@ export function ContactModal(): JSXElement { type="button" href="mailto:contact@monkeytype.com?subject=[Feedback] " fa={{ - icon: "comment-dots", + icon: "fa-comment-dots", fixedWidth: true, }} text="Feedback" @@ -43,7 +43,7 @@ export function ContactModal(): JSXElement { type="button" href="mailto:support@monkeytype.com?subject=[Bug] " fa={{ - icon: "bug", + icon: "fa-bug", fixedWidth: true, }} text="Bug Report" @@ -53,7 +53,7 @@ export function ContactModal(): JSXElement { type="button" href="mailto:support@monkeytype.com?subject=[Account] " fa={{ - icon: "user-circle", + icon: "fa-user-circle", fixedWidth: true, }} text="Account Help" @@ -63,7 +63,7 @@ export function ContactModal(): JSXElement { type="button" href="mailto:jack@monkeytype.com?subject=[Business] " fa={{ - icon: "briefcase", + icon: "fa-briefcase", fixedWidth: true, }} text="Business Inquiry" @@ -73,7 +73,7 @@ export function ContactModal(): JSXElement { type="button" href="mailto:contact@monkeytype.com?subject=[Other] " fa={{ - icon: "ellipsis-h", + icon: "fa-ellipsis-h", fixedWidth: true, }} text="Other" diff --git a/frontend/src/ts/components/modals/SupportModal.tsx b/frontend/src/ts/components/modals/SupportModal.tsx index c60b3b33710e..82349c3ee841 100644 --- a/frontend/src/ts/components/modals/SupportModal.tsx +++ b/frontend/src/ts/components/modals/SupportModal.tsx @@ -19,7 +19,7 @@ export function SupportModal(): JSXElement {
Thank you so much for thinking about supporting this project. It would not be possible without you and your continued support.{" "} - +
@@ -241,13 +241,13 @@ export function AboutPage(): JSXElement { class="ad advertisement ad-h-s place-self-center" >
- +
-

+

After completing a test you will be able to see your wpm, raw wpm, accuracy, character stats, test length, leaderboards info and test @@ -259,7 +259,7 @@ export function AboutPage(): JSXElement {

-

+

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 @@ -268,7 +268,7 @@ export function AboutPage(): JSXElement {

-

+

Thanks to everyone who has supported this project. It would not be possible without you and your continued support. @@ -276,7 +276,7 @@ export function AboutPage(): JSXElement {

-

+

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. @@ -294,25 +294,25 @@ export function AboutPage(): JSXElement {

-

+