From 72018ed9418416aa137cf167139339291f03eca2 Mon Sep 17 00:00:00 2001 From: Jessica Deen Date: Mon, 17 Aug 2026 16:53:23 -0700 Subject: [PATCH 1/3] Fix export responsiveness Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/components/ActionButtonsArea.tsx | 100 ++++++--- src/components/DevemonCard.tsx | 292 ++++++++++---------------- src/components/ReadmeBanner.tsx | 287 ++++++++++--------------- src/components/WallpaperGenerator.tsx | 289 ++++++++----------------- src/components/ui/Button.module.css | 2 + src/components/ui/Button.tsx | 2 +- src/utils/imageExport.ts | 142 +++++++++++++ tests/export-responsiveness.spec.ts | 126 +++++++++++ 8 files changed, 648 insertions(+), 592 deletions(-) create mode 100644 src/utils/imageExport.ts create mode 100644 tests/export-responsiveness.spec.ts diff --git a/src/components/ActionButtonsArea.tsx b/src/components/ActionButtonsArea.tsx index fb30512..c2e66a3 100644 --- a/src/components/ActionButtonsArea.tsx +++ b/src/components/ActionButtonsArea.tsx @@ -1,3 +1,4 @@ +import { useState } from "preact/hooks"; import { PrimaryButton, SecondaryButton } from "./ui/Button"; import { Icon } from "./ui/Icon"; import { SocialIcon } from 'react-social-icons'; @@ -5,17 +6,17 @@ import styles from "./ActionButtonsArea.module.css"; interface ActionButtonsAreaProps { actionType: "wallpaper" | "devemon" | "banner"; - onDownloadDesktop?: () => void; - onDownloadMobile?: () => void; - onDownloadSmall?: () => void; - onDownloadCard?: () => void; - onDownloadBadge?: () => void; - onDownloadBanner?: () => void; - onCopyMarkdown?: () => void; - onShareTwitter?: () => void; - onShareBluesky?: () => void; - onShareThreads?: () => void; - onShareInstagram?: () => void; + onDownloadDesktop?: () => void | Promise; + onDownloadMobile?: () => void | Promise; + onDownloadSmall?: () => void | Promise; + onDownloadCard?: () => void | Promise; + onDownloadBadge?: () => void | Promise; + onDownloadBanner?: () => void | Promise; + onCopyMarkdown?: () => void | Promise; + onShareTwitter?: () => void | Promise; + onShareBluesky?: () => void | Promise; + onShareThreads?: () => void | Promise; + onShareInstagram?: () => void | Promise; } export default function ActionButtonsArea({ @@ -32,13 +33,31 @@ export default function ActionButtonsArea({ onShareThreads, onShareInstagram, }: ActionButtonsAreaProps) { + const [busyAction, setBusyAction] = useState(null); + const isBusy = busyAction !== null; + + const runAction = async ( + actionId: string, + action?: () => void | Promise + ) => { + if (!action || isBusy) return; + + setBusyAction(actionId); + try { + await action(); + } finally { + setBusyAction(null); + } + }; + const renderWallpaperActions = () => (
runAction("wallpaper-desktop", onDownloadDesktop)} + disabled={isBusy} >
Desktop (2560x1440) @@ -47,7 +66,8 @@ export default function ActionButtonsArea({ runAction("wallpaper-mobile", onDownloadMobile)} + disabled={isBusy} >
Mobile (1179x2556) @@ -56,7 +76,8 @@ export default function ActionButtonsArea({ runAction("wallpaper-small", onDownloadSmall)} + disabled={isBusy} >
Badge (320x240)
Download PNG
@@ -64,25 +85,29 @@ export default function ActionButtonsArea({
runAction("wallpaper-twitter", onShareTwitter)} + disabled={isBusy} icon={} > Twitter/X runAction("wallpaper-bluesky", onShareBluesky)} + disabled={isBusy} icon={} > Bluesky runAction("wallpaper-threads", onShareThreads)} + disabled={isBusy} icon={} > Threads runAction("wallpaper-instagram", onShareInstagram)} + disabled={isBusy} icon={} > Instagram @@ -96,7 +121,8 @@ export default function ActionButtonsArea({
runAction("devemon-card", onDownloadCard)} + disabled={isBusy} icon={ runAction("devemon-badge", onDownloadBadge)} + disabled={isBusy} icon={
runAction("devemon-twitter", onShareTwitter)} + disabled={isBusy} icon={} > Twitter/X runAction("devemon-bluesky", onShareBluesky)} + disabled={isBusy} icon={} > Bluesky runAction("devemon-threads", onShareThreads)} + disabled={isBusy} icon={} > Threads runAction("devemon-instagram", onShareInstagram)} + disabled={isBusy} icon={} > Instagram @@ -155,7 +186,8 @@ export default function ActionButtonsArea({
runAction("banner-download", onDownloadBanner)} + disabled={isBusy} icon={ Download - 📋}> + runAction("banner-markdown", onCopyMarkdown)} + disabled={isBusy} + icon={📋} + > Copy Markdown
runAction("banner-twitter", onShareTwitter)} + disabled={isBusy} icon={} > Twitter/X runAction("banner-bluesky", onShareBluesky)} + disabled={isBusy} icon={} > Bluesky runAction("banner-threads", onShareThreads)} + disabled={isBusy} icon={} > Threads runAction("banner-instagram", onShareInstagram)} + disabled={isBusy} icon={} > Instagram diff --git a/src/components/DevemonCard.tsx b/src/components/DevemonCard.tsx index 23b5960..aa75bd1 100644 --- a/src/components/DevemonCard.tsx +++ b/src/components/DevemonCard.tsx @@ -18,6 +18,11 @@ import { Checkbox, PrimerSelect } from "./ui/FormControls"; import { Button } from "./ui/Button"; import styles from "./DevemonCard.module.css"; import sharedStyles from "./shared.module.css"; +import { + captureElementToPngBlob, + copyPngBlobToClipboard, + downloadBlob, +} from "../utils/imageExport"; interface DevemonCardProps { user: GitHubUser; @@ -192,225 +197,150 @@ const DevemonCard = forwardRef( if (!targetRef.current) return; try { - const html2canvas = (await import("html2canvas")).default; - const canvas = await html2canvas(targetRef.current, { + const blob = await captureElementToPngBlob(targetRef.current, { backgroundColor: null, scale: 3, useCORS: true, - logging: false, }); - - canvas.toBlob((blob) => { - if (blob) { - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `devemon-${format}-${user.login}.png`; - link.click(); - URL.revokeObjectURL(url); - } - }, "image/png"); + downloadBlob(blob, `devemon-${format}-${user.login}.png`); } catch (error) { console.error("Failed to download card:", error); } }; - /** - * Share card to Twitter/X - */ - const handleTwitterShare = async () => { + const copyCardAndShare = async ({ + successMessage, + copyFailureMessage, + generationFailureMessage, + shareUrl, + windowName, + windowFeatures, + afterShare, + }: { + successMessage: string; + copyFailureMessage: string; + generationFailureMessage: string; + shareUrl?: string; + windowName: string; + windowFeatures: string; + afterShare?: () => void; + }) => { if (!cardRef.current) return; try { - const html2canvas = (await import("html2canvas")).default; - const canvas = await html2canvas(cardRef.current, { + const blob = await captureElementToPngBlob(cardRef.current, { backgroundColor: null, scale: 3, useCORS: true, - logging: false, }); - canvas.toBlob(async (blob) => { - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Devémon card copied to clipboard! You can now paste it in your tweet." - ); - - const tweetText = - "Just created my custom GitHub Universe Devémon card using Octocanvas from #GitHubUniverse 🎴"; - const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( - tweetText - )}`; - window.open( - twitterUrl, - "twitter-share-dialog", - "width=626,height=436" - ); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy card to clipboard. Please download it manually." - ); - } + try { + await copyPngBlobToClipboard(blob); + alert(successMessage); + + if (shareUrl) { + window.open(shareUrl, windowName, windowFeatures); } - }, "image/png"); + afterShare?.(); + } catch (error) { + console.error("Error copying to clipboard:", error); + alert(copyFailureMessage); + } } catch (error) { - console.error("Error sharing to Twitter:", error); - alert("Failed to generate card image. Please try again."); + console.error(`Error preparing card for ${windowName}:`, error); + alert(generationFailureMessage); } }; + /** + * Share card to Twitter/X + */ + const handleTwitterShare = async () => { + const tweetText = + "Just created my custom GitHub Universe Devémon card using Octocanvas from #GitHubUniverse 🎴"; + const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( + tweetText + )}`; + + await copyCardAndShare({ + successMessage: + "✅ Devémon card copied to clipboard! You can now paste it in your tweet.", + copyFailureMessage: + "Failed to copy card to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate card image. Please try again.", + shareUrl: twitterUrl, + windowName: "twitter-share-dialog", + windowFeatures: "width=626,height=436", + }); + }; + /** * Share card to Bluesky */ const handleBlueskyShare = async () => { - if (!cardRef.current) return; - - try { - const html2canvas = (await import("html2canvas")).default; - const canvas = await html2canvas(cardRef.current, { - backgroundColor: null, - scale: 3, - useCORS: true, - logging: false, - }); - - canvas.toBlob(async (blob) => { - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Devémon card copied to clipboard! You can now paste it in your Bluesky post." - ); - - const postText = - "Just created my custom GitHub Universe Devémon card using Octocanvas from #GitHubUniverse 🎴"; - const blueskyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent( - postText - )}`; - window.open( - blueskyUrl, - "bluesky-share-dialog", - "width=626,height=600" - ); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy card to clipboard. Please download it manually." - ); - } - } - }, "image/png"); - } catch (error) { - console.error("Error sharing to Bluesky:", error); - alert("Failed to generate card image. Please try again."); - } + const postText = + "Just created my custom GitHub Universe Devémon card using Octocanvas from #GitHubUniverse 🎴"; + const blueskyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent( + postText + )}`; + + await copyCardAndShare({ + successMessage: + "✅ Devémon card copied to clipboard! You can now paste it in your Bluesky post.", + copyFailureMessage: + "Failed to copy card to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate card image. Please try again.", + shareUrl: blueskyUrl, + windowName: "bluesky-share-dialog", + windowFeatures: "width=626,height=600", + }); }; /** * Share card to Threads */ const handleThreadsShare = async () => { - if (!cardRef.current) return; - - try { - const html2canvas = (await import("html2canvas")).default; - const canvas = await html2canvas(cardRef.current, { - backgroundColor: null, - scale: 3, - useCORS: true, - logging: false, - }); - - canvas.toBlob(async (blob) => { - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Devémon card copied to clipboard! You can now paste it in your Threads post." - ); - - const postText = - "Just created my custom GitHub Universe Devémon card using Octocanvas from #GitHubUniverse 🎴"; - const threadsUrl = `https://www.threads.net/intent/post?text=${encodeURIComponent( - postText - )}`; - window.open( - threadsUrl, - "threads-share-dialog", - "width=626,height=600" - ); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy card to clipboard. Please download it manually." - ); - } - } - }, "image/png"); - } catch (error) { - console.error("Error sharing to Threads:", error); - alert("Failed to generate card image. Please try again."); - } + const postText = + "Just created my custom GitHub Universe Devémon card using Octocanvas from #GitHubUniverse 🎴"; + const threadsUrl = `https://www.threads.net/intent/post?text=${encodeURIComponent( + postText + )}`; + + await copyCardAndShare({ + successMessage: + "✅ Devémon card copied to clipboard! You can now paste it in your Threads post.", + copyFailureMessage: + "Failed to copy card to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate card image. Please try again.", + shareUrl: threadsUrl, + windowName: "threads-share-dialog", + windowFeatures: "width=626,height=600", + }); }; /** * Share card to Instagram */ const handleInstagramShare = async () => { - if (!cardRef.current) return; - - try { - const html2canvas = (await import("html2canvas")).default; - const canvas = await html2canvas(cardRef.current, { - backgroundColor: null, - scale: 3, - useCORS: true, - logging: false, - }); - - canvas.toBlob(async (blob) => { - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Devémon card copied to clipboard!\n\n📱 To share on Instagram:\n1. Open the Instagram app on your device\n2. Tap the + button to create a new post\n3. Paste the image from your clipboard\n4. Add your caption and share!" - ); - - window.location.href = "instagram://library"; - setTimeout(() => { - window.open( - "https://www.instagram.com/", - "instagram-share", - "width=626,height=600" - ); - }, 1500); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy card to clipboard. Please download it manually." - ); - } - } - }, "image/png"); - } catch (error) { - console.error("Error sharing to Instagram:", error); - alert("Failed to generate card image. Please try again."); - } + await copyCardAndShare({ + successMessage: + "✅ Devémon card copied to clipboard!\n\n📱 To share on Instagram:\n1. Open the Instagram app on your device\n2. Tap the + button to create a new post\n3. Paste the image from your clipboard\n4. Add your caption and share!", + copyFailureMessage: + "Failed to copy card to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate card image. Please try again.", + windowName: "instagram-share", + windowFeatures: "width=626,height=600", + afterShare: () => { + window.location.href = "instagram://library"; + setTimeout(() => { + window.open( + "https://www.instagram.com/", + "instagram-share", + "width=626,height=600" + ); + }, 1500); + }, + }); }; if (loading) { diff --git a/src/components/ReadmeBanner.tsx b/src/components/ReadmeBanner.tsx index 53786aa..969b4a0 100644 --- a/src/components/ReadmeBanner.tsx +++ b/src/components/ReadmeBanner.tsx @@ -21,6 +21,11 @@ import { Icon } from "./ui/Icon"; import { PrimaryButton, SecondaryButton } from "./ui/Button"; import styles from "./ReadmeBanner.module.css"; import sharedStyles from "./shared.module.css"; +import { + captureElementToPngBlob, + copyPngBlobToClipboard, + downloadBlob, +} from "../utils/imageExport"; export interface ReadmeBannerRef { downloadBanner: () => Promise; @@ -494,23 +499,12 @@ const ReadmeBanner = forwardRef( if (!standardBannerRef.current) return; try { - const html2canvas = (await import("html2canvas")).default; - const canvas = await html2canvas(standardBannerRef.current, { + const blob = await captureElementToPngBlob(standardBannerRef.current, { scale: 2, backgroundColor: null, useCORS: true, }); - - canvas.toBlob((blob) => { - if (!blob) return; - - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `${user.login}-readme-banner.png`; - link.click(); - URL.revokeObjectURL(url); - }); + downloadBlob(blob, `${user.login}-readme-banner.png`); } catch (error) { console.error("Error generating banner:", error); } @@ -536,198 +530,139 @@ const ReadmeBanner = forwardRef( }); }; - /** - * Share banner to Twitter/X - */ - const handleTwitterShare = async () => { + const copyBannerAndShare = async ({ + successMessage, + copyFailureMessage, + generationFailureMessage, + shareUrl, + windowName, + windowFeatures, + afterShare, + }: { + successMessage: string; + copyFailureMessage: string; + generationFailureMessage: string; + shareUrl?: string; + windowName: string; + windowFeatures: string; + afterShare?: () => void; + }) => { if (!standardBannerRef.current) return; try { - const html2canvas = (await import("html2canvas")).default; - const canvas = await html2canvas(standardBannerRef.current, { + const blob = await captureElementToPngBlob(standardBannerRef.current, { scale: 2, backgroundColor: null, useCORS: true, }); - canvas.toBlob(async (blob) => { - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Banner copied to clipboard! You can now paste it in your tweet." - ); - - const tweetText = - "Just created my custom GitHub Universe README banner using Octocanvas from #GitHubUniverse 🎉"; - const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( - tweetText - )}`; - window.open( - twitterUrl, - "twitter-share-dialog", - "width=626,height=436" - ); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy banner to clipboard. Please download it manually." - ); - } + try { + await copyPngBlobToClipboard(blob); + alert(successMessage); + + if (shareUrl) { + window.open(shareUrl, windowName, windowFeatures); } - }, "image/png"); + afterShare?.(); + } catch (error) { + console.error("Error copying to clipboard:", error); + alert(copyFailureMessage); + } } catch (error) { - console.error("Error sharing to Twitter:", error); - alert("Failed to generate banner image. Please try again."); + console.error(`Error preparing banner for ${windowName}:`, error); + alert(generationFailureMessage); } }; + /** + * Share banner to Twitter/X + */ + const handleTwitterShare = async () => { + const tweetText = + "Just created my custom GitHub Universe README banner using Octocanvas from #GitHubUniverse 🎉"; + const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( + tweetText + )}`; + + await copyBannerAndShare({ + successMessage: + "✅ Banner copied to clipboard! You can now paste it in your tweet.", + copyFailureMessage: + "Failed to copy banner to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate banner image. Please try again.", + shareUrl: twitterUrl, + windowName: "twitter-share-dialog", + windowFeatures: "width=626,height=436", + }); + }; + /** * Share banner to Bluesky */ const handleBlueskyShare = async () => { - if (!standardBannerRef.current) return; - - try { - const html2canvas = (await import("html2canvas")).default; - const canvas = await html2canvas(standardBannerRef.current, { - scale: 2, - backgroundColor: null, - useCORS: true, - }); - - canvas.toBlob(async (blob) => { - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Banner copied to clipboard! You can now paste it in your Bluesky post." - ); - - const postText = - "Just created my custom GitHub Universe README banner using Octocanvas from #GitHubUniverse 🎉"; - const blueskyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent( - postText - )}`; - window.open( - blueskyUrl, - "bluesky-share-dialog", - "width=626,height=600" - ); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy banner to clipboard. Please download it manually." - ); - } - } - }, "image/png"); - } catch (error) { - console.error("Error sharing to Bluesky:", error); - alert("Failed to generate banner image. Please try again."); - } + const postText = + "Just created my custom GitHub Universe README banner using Octocanvas from #GitHubUniverse 🎉"; + const blueskyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent( + postText + )}`; + + await copyBannerAndShare({ + successMessage: + "✅ Banner copied to clipboard! You can now paste it in your Bluesky post.", + copyFailureMessage: + "Failed to copy banner to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate banner image. Please try again.", + shareUrl: blueskyUrl, + windowName: "bluesky-share-dialog", + windowFeatures: "width=626,height=600", + }); }; /** * Share banner to Threads */ const handleThreadsShare = async () => { - if (!standardBannerRef.current) return; - - try { - const html2canvas = (await import("html2canvas")).default; - const canvas = await html2canvas(standardBannerRef.current, { - scale: 2, - backgroundColor: null, - useCORS: true, - }); - - canvas.toBlob(async (blob) => { - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Banner copied to clipboard! You can now paste it in your Threads post." - ); - - const postText = - "Just created my custom GitHub Universe README banner using Octocanvas from #GitHubUniverse 🎉"; - const threadsUrl = `https://www.threads.net/intent/post?text=${encodeURIComponent( - postText - )}`; - window.open( - threadsUrl, - "threads-share-dialog", - "width=626,height=600" - ); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy banner to clipboard. Please download it manually." - ); - } - } - }, "image/png"); - } catch (error) { - console.error("Error sharing to Threads:", error); - alert("Failed to generate banner image. Please try again."); - } + const postText = + "Just created my custom GitHub Universe README banner using Octocanvas from #GitHubUniverse 🎉"; + const threadsUrl = `https://www.threads.net/intent/post?text=${encodeURIComponent( + postText + )}`; + + await copyBannerAndShare({ + successMessage: + "✅ Banner copied to clipboard! You can now paste it in your Threads post.", + copyFailureMessage: + "Failed to copy banner to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate banner image. Please try again.", + shareUrl: threadsUrl, + windowName: "threads-share-dialog", + windowFeatures: "width=626,height=600", + }); }; /** * Share banner to Instagram */ const handleInstagramShare = async () => { - if (!standardBannerRef.current) return; - - try { - const html2canvas = (await import("html2canvas")).default; - const canvas = await html2canvas(standardBannerRef.current, { - scale: 2, - backgroundColor: null, - useCORS: true, - }); - - canvas.toBlob(async (blob) => { - if (blob) { - try { - await navigator.clipboard.write([ - new ClipboardItem({ "image/png": blob }), - ]); - - alert( - "✅ Banner copied to clipboard!\n\n📱 To share on Instagram:\n1. Open the Instagram app on your device\n2. Tap the + button to create a new post\n3. Paste the image from your clipboard\n4. Add your caption and share!" - ); - - window.location.href = "instagram://library"; - setTimeout(() => { - window.open( - "https://www.instagram.com/", - "instagram-share", - "width=626,height=600" - ); - }, 1500); - } catch (error) { - console.error("Error copying to clipboard:", error); - alert( - "Failed to copy banner to clipboard. Please download it manually." - ); - } - } - }, "image/png"); - } catch (error) { - console.error("Error sharing to Instagram:", error); - alert("Failed to generate banner image. Please try again."); - } + await copyBannerAndShare({ + successMessage: + "✅ Banner copied to clipboard!\n\n📱 To share on Instagram:\n1. Open the Instagram app on your device\n2. Tap the + button to create a new post\n3. Paste the image from your clipboard\n4. Add your caption and share!", + copyFailureMessage: + "Failed to copy banner to clipboard. Please download it manually.", + generationFailureMessage: "Failed to generate banner image. Please try again.", + windowName: "instagram-share", + windowFeatures: "width=626,height=600", + afterShare: () => { + window.location.href = "instagram://library"; + setTimeout(() => { + window.open( + "https://www.instagram.com/", + "instagram-share", + "width=626,height=600" + ); + }, 1500); + }, + }); }; // Expose functions via ref diff --git a/src/components/WallpaperGenerator.tsx b/src/components/WallpaperGenerator.tsx index 5f01aa0..d28e35c 100644 --- a/src/components/WallpaperGenerator.tsx +++ b/src/components/WallpaperGenerator.tsx @@ -13,6 +13,12 @@ import { forwardRef } from "preact/compat"; import type { GitHubUser } from "./GitHubWallpaperApp"; import styles from "./WallpaperGenerator.module.css"; import sharedStyles from "./shared.module.css"; +import { + copyPngBlobToClipboard, + downloadBlob, + svgToPngBlob, + yieldToBrowser, +} from "../utils/imageExport"; interface WallpaperGeneratorProps { user: GitHubUser; @@ -865,7 +871,7 @@ const WallpaperGenerator = forwardRef< * Convert SVG to PNG blob (for clipboard or download) * Reusable function for both download and share operations */ - const copyToClipboard = async ( + const createWallpaperPngBlob = async ( sizeKey: SizeKey = "desktop" ): Promise => { const size = SIZES[sizeKey]; @@ -876,63 +882,9 @@ const WallpaperGenerator = forwardRef< throw new Error("Avatar not loaded yet"); } + await yieldToBrowser(); const svgString = generateSVG(size.width, size.height, true); // true = static version - - // Create canvas for PNG conversion - const canvas = document.createElement("canvas"); - canvas.width = size.width; - canvas.height = size.height; - const ctx = canvas.getContext("2d", { willReadFrequently: false }); - - if (!ctx) { - throw new Error("Failed to get canvas context"); - } - - // Encode SVG string properly for data URL - const encodedSvg = encodeURIComponent(svgString) - .replace(/'/g, "%27") - .replace(/"/g, "%22"); - - const dataUrl = `data:image/svg+xml,${encodedSvg}`; - - // Create image and wait for it to load - const img = new Image(); - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Image load timeout after 10 seconds")); - }, 10000); // 10 second timeout - - img.onload = () => { - clearTimeout(timeout); - resolve(); - }; - - img.onerror = (e) => { - clearTimeout(timeout); - console.error("Failed to load SVG image:", e); - reject(new Error("Failed to load SVG image")); - }; - - img.src = dataUrl; - }); - - // Draw the SVG image onto canvas - ctx.drawImage(img, 0, 0); - - // Convert canvas to PNG blob - const blob = await new Promise((resolve, reject) => { - canvas.toBlob( - (b) => { - if (b) resolve(b); - else reject(new Error("Failed to create blob from canvas")); - }, - "image/png", - 1.0 - ); - }); - - return blob; + return svgToPngBlob(svgString, size.width, size.height); }; /** @@ -943,20 +895,8 @@ const WallpaperGenerator = forwardRef< setDownloadingSize(sizeKey); try { - const blob = await copyToClipboard(sizeKey); - - const pngUrl = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = pngUrl; - link.download = `github-wallpaper-${user.login}-${sizeKey}.png`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // Clean up - setTimeout(() => { - URL.revokeObjectURL(pngUrl); - }, 100); + const blob = await createWallpaperPngBlob(sizeKey); + downloadBlob(blob, `github-wallpaper-${user.login}-${sizeKey}.png`); console.log(`Successfully downloaded ${sizeKey} wallpaper`); } catch (error) { @@ -970,13 +910,21 @@ const WallpaperGenerator = forwardRef< } }; - /** - * Share wallpaper to Twitter/X - * Copies desktop wallpaper to clipboard and opens Twitter with pre-filled text - */ - const handleTwitterShare = async () => { - // Check if avatar is loaded before proceeding - // Use ref to get current value (not closure value) + const copyDesktopWallpaperAndShare = async ({ + successMessage, + failureMessage, + shareUrl, + windowName, + windowFeatures, + afterShare, + }: { + successMessage: string; + failureMessage: string; + shareUrl?: string; + windowName: string; + windowFeatures: string; + afterShare?: () => void; + }) => { const currentAvatarBase64 = avatarBase64Ref.current; if (!currentAvatarBase64) { alert( @@ -986,46 +934,40 @@ const WallpaperGenerator = forwardRef< } try { - // Copy wallpaper to clipboard first - const blob = await copyToClipboard("desktop"); - - await navigator.clipboard.write([ - new ClipboardItem({ - "image/png": blob, - }), - ]); - - // Show success message - alert( - "✅ Wallpaper copied to clipboard! You can now paste it in your tweet." - ); - - // Open Twitter with pre-filled text - const tweetText = SHARE_POST_TEXT; - const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( - tweetText - )}`; - - window.open( - twitterUrl, - "twitter-share-dialog", - "width=626,height=436,toolbar=0,menubar=0,location=0,status=0" - ); + const blob = await createWallpaperPngBlob("desktop"); + await copyPngBlobToClipboard(blob); + alert(successMessage); } catch (error) { - console.error("Error sharing to Twitter:", error); - alert( - "Failed to copy wallpaper to clipboard. Please download it manually and attach to your tweet." - ); - - // Still open Twitter even if clipboard fails - const tweetText = SHARE_POST_TEXT; - const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( - tweetText - )}`; - window.open(twitterUrl, "twitter-share-dialog", "width=626,height=436"); + console.error(`Error preparing wallpaper for ${windowName}:`, error); + alert(failureMessage); + } finally { + if (shareUrl) { + window.open(shareUrl, windowName, windowFeatures); + } + afterShare?.(); } }; + /** + * Share wallpaper to Twitter/X + * Copies desktop wallpaper to clipboard and opens Twitter with pre-filled text + */ + const handleTwitterShare = async () => { + const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( + SHARE_POST_TEXT + )}`; + + await copyDesktopWallpaperAndShare({ + successMessage: + "✅ Wallpaper copied to clipboard! You can now paste it in your tweet.", + failureMessage: + "Failed to copy wallpaper to clipboard. Please download it manually and attach to your tweet.", + shareUrl: twitterUrl, + windowName: "twitter-share-dialog", + windowFeatures: "width=626,height=436,toolbar=0,menubar=0,location=0,status=0", + }); + }; + /** * Share wallpaper to Bluesky * Copies desktop wallpaper to clipboard and opens Bluesky with pre-filled text @@ -1044,32 +986,15 @@ const WallpaperGenerator = forwardRef< postText )}`; - try { - const blob = await copyToClipboard("desktop"); - - await navigator.clipboard.write([ - new ClipboardItem({ - "image/png": blob, - }), - ]); - - alert( - "✅ Wallpaper copied to clipboard! You can now paste it in your Bluesky post." - ); - - window.open( - blueskyUrl, - "bluesky-share-dialog", - "width=626,height=600,toolbar=0,menubar=0,location=0,status=0" - ); - } catch (error) { - console.error("Error sharing to Bluesky:", error); - alert( - "Failed to copy wallpaper to clipboard. Please download it manually and attach to your post." - ); - - window.open(blueskyUrl, "bluesky-share-dialog", "width=626,height=600"); - } + await copyDesktopWallpaperAndShare({ + successMessage: + "✅ Wallpaper copied to clipboard! You can now paste it in your Bluesky post.", + failureMessage: + "Failed to copy wallpaper to clipboard. Please download it manually and attach to your post.", + shareUrl: blueskyUrl, + windowName: "bluesky-share-dialog", + windowFeatures: "width=626,height=600,toolbar=0,menubar=0,location=0,status=0", + }); }; /** @@ -1090,32 +1015,15 @@ const WallpaperGenerator = forwardRef< postText )}`; - try { - const blob = await copyToClipboard("desktop"); - - await navigator.clipboard.write([ - new ClipboardItem({ - "image/png": blob, - }), - ]); - - alert( - "✅ Wallpaper copied to clipboard! You can now paste it in your Threads post." - ); - - window.open( - threadsUrl, - "threads-share-dialog", - "width=626,height=600,toolbar=0,menubar=0,location=0,status=0" - ); - } catch (error) { - console.error("Error sharing to Threads:", error); - alert( - "Failed to copy wallpaper to clipboard. Please download it manually and attach to your post." - ); - - window.open(threadsUrl, "threads-share-dialog", "width=626,height=600"); - } + await copyDesktopWallpaperAndShare({ + successMessage: + "✅ Wallpaper copied to clipboard! You can now paste it in your Threads post.", + failureMessage: + "Failed to copy wallpaper to clipboard. Please download it manually and attach to your post.", + shareUrl: threadsUrl, + windowName: "threads-share-dialog", + windowFeatures: "width=626,height=600,toolbar=0,menubar=0,location=0,status=0", + }); }; /** @@ -1123,31 +1031,9 @@ const WallpaperGenerator = forwardRef< * Copies desktop wallpaper to clipboard and shows instructions for Instagram app */ const handleInstagramShare = async () => { - const currentAvatarBase64 = avatarBase64Ref.current; - if (!currentAvatarBase64) { - alert( - "⏳ Please wait - your avatar is still loading. Try again in a moment!" - ); - return; - } - - try { - const blob = await copyToClipboard("desktop"); - - await navigator.clipboard.write([ - new ClipboardItem({ - "image/png": blob, - }), - ]); - - alert( - "✅ Wallpaper copied to clipboard!\n\n📱 To share on Instagram:\n1. Open the Instagram app on your device\n2. Tap the + button to create a new post\n3. Paste the image from your clipboard\n4. Add your caption and share!" - ); - - // Try to open Instagram app (works on mobile devices) + const openInstagram = () => { window.location.href = "instagram://library"; - // Fallback to web after a delay (if app doesn't open) setTimeout(() => { window.open( "https://www.instagram.com/", @@ -1155,22 +1041,17 @@ const WallpaperGenerator = forwardRef< "width=626,height=600" ); }, 1500); - } catch (error) { - console.error("Error sharing to Instagram:", error); - alert( - "Failed to copy wallpaper to clipboard. Please download it manually and upload to Instagram." - ); + }; - // Still try to open Instagram - window.location.href = "instagram://library"; - setTimeout(() => { - window.open( - "https://www.instagram.com/", - "instagram-share", - "width=626,height=600" - ); - }, 1500); - } + await copyDesktopWallpaperAndShare({ + successMessage: + "✅ Wallpaper copied to clipboard!\n\n📱 To share on Instagram:\n1. Open the Instagram app on your device\n2. Tap the + button to create a new post\n3. Paste the image from your clipboard\n4. Add your caption and share!", + failureMessage: + "Failed to copy wallpaper to clipboard. Please download it manually and upload to Instagram.", + windowName: "instagram-share", + windowFeatures: "width=626,height=600", + afterShare: openInstagram, + }); }; // Generate responsive preview SVG based on screen size diff --git a/src/components/ui/Button.module.css b/src/components/ui/Button.module.css index fbf12ca..0dd69a9 100644 --- a/src/components/ui/Button.module.css +++ b/src/components/ui/Button.module.css @@ -35,6 +35,8 @@ .button:disabled { cursor: not-allowed; + opacity: 0.65; + pointer-events: none; } /* Button text styling */ diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 59aa945..7354c16 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -24,7 +24,7 @@ export interface ButtonProps { size?: "medium" | "large"; /** Click handler */ - onClick?: () => void; + onClick?: () => void | Promise; /** Button type */ type?: "button" | "submit" | "reset"; diff --git a/src/utils/imageExport.ts b/src/utils/imageExport.ts new file mode 100644 index 0000000..2091a92 --- /dev/null +++ b/src/utils/imageExport.ts @@ -0,0 +1,142 @@ +import type { Options as Html2CanvasOptions } from "html2canvas"; + +const nextFrame = () => + new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + +export const yieldToBrowser = async () => { + await nextFrame(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +}; + +export const canvasToPngBlob = async (canvas: HTMLCanvasElement) => { + await yieldToBrowser(); + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((value) => { + if (value) { + resolve(value); + } else { + reject(new Error("Failed to create PNG blob from canvas")); + } + }, "image/png"); + }); + + await yieldToBrowser(); + return blob; +}; + +const loadSvgBlobIntoImage = async (svgBlob: Blob) => { + if ("createImageBitmap" in window) { + try { + return await window.createImageBitmap(svgBlob); + } catch (error) { + console.warn( + "createImageBitmap failed for SVG export; using Image fallback.", + error + ); + } + } + + const objectUrl = URL.createObjectURL(svgBlob); + const image = new Image(); + + try { + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + reject(new Error("Image load timeout after 10 seconds")); + }, 10000); + + image.onload = () => { + window.clearTimeout(timeout); + resolve(); + }; + + image.onerror = () => { + window.clearTimeout(timeout); + reject(new Error("Failed to load SVG image")); + }; + + image.src = objectUrl; + }); + + return image; + } finally { + URL.revokeObjectURL(objectUrl); + } +}; + +export const svgToPngBlob = async ( + svgString: string, + width: number, + height: number +) => { + await yieldToBrowser(); + + const svgBlob = new Blob([svgString], { + type: "image/svg+xml;charset=utf-8", + }); + const image = await loadSvgBlobIntoImage(svgBlob); + + await yieldToBrowser(); + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext("2d", { willReadFrequently: false }); + if (!context) { + throw new Error("Failed to get canvas context"); + } + + context.drawImage(image, 0, 0, width, height); + if ("close" in image && typeof image.close === "function") { + image.close(); + } + + const blob = await canvasToPngBlob(canvas); + canvas.width = 0; + canvas.height = 0; + return blob; +}; + +export const captureElementToPngBlob = async ( + element: HTMLElement, + options: Partial +) => { + await yieldToBrowser(); + + const html2canvas = (await import("html2canvas")).default; + const canvas = await html2canvas(element, { + logging: false, + ...options, + }); + const blob = await canvasToPngBlob(canvas); + + canvas.width = 0; + canvas.height = 0; + return blob; +}; + +export const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + window.setTimeout(() => URL.revokeObjectURL(url), 100); +}; + +export const copyPngBlobToClipboard = async (blob: Blob) => { + await navigator.clipboard.write([ + new ClipboardItem({ + "image/png": blob, + }), + ]); +}; diff --git a/tests/export-responsiveness.spec.ts b/tests/export-responsiveness.spec.ts new file mode 100644 index 0000000..2e6066b --- /dev/null +++ b/tests/export-responsiveness.spec.ts @@ -0,0 +1,126 @@ +import { expect, test, type Page } from "@playwright/test"; + +const avatarPng = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", + "base64" +); + +async function mockGitHubProfile(page: Page) { + await page.route("**/users/octocat", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + login: "octocat", + avatar_url: "http://localhost:4321/avatar.png", + name: "The Octocat", + followers: 1234, + public_repos: 42, + bio: "GitHub mascot", + created_at: "2011-01-25T18:44:36Z", + company: "@github", + location: "San Francisco", + blog: "https://github.com", + }), + }); + }); + + await page.route("**/octocat.contribs", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + total_contributions: 1234, + weeks: Array.from({ length: 53 }, () => ({ + contribution_days: Array.from({ length: 7 }, () => ({ count: 2 })), + })), + }), + }); + }); + + await page.route( + "**/users/octocat/repos**", + async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify([ + { + stargazers_count: 10, + forks_count: 5, + language: "TypeScript", + }, + ]), + }); + } + ); + + await page.route("**/avatar.png", async (route) => { + await route.fulfill({ + contentType: "image/png", + body: avatarPng, + }); + }); +} + +async function loadGeneratedProfile(page: Page) { + await page.addInitScript(() => { + window.alert = () => undefined; + window.open = () => null; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + write: () => + new Promise((resolve) => { + window.setTimeout(resolve, 300); + }), + writeText: () => Promise.resolve(), + }, + }); + + const originalToBlob = HTMLCanvasElement.prototype.toBlob; + HTMLCanvasElement.prototype.toBlob = function delayedToBlob( + callback, + type, + quality + ) { + return originalToBlob.call( + this, + (blob) => window.setTimeout(() => callback(blob), 300), + type, + quality + ); + }; + }); + + await mockGitHubProfile(page); + await page.goto("/"); + const usernameInput = page.getByLabel("GitHub Username"); + await usernameInput.click(); + await page.keyboard.type("octocat"); + await expect(usernameInput).toHaveValue("octocat"); + await page.keyboard.press("Tab"); + await page.getByRole("button", { name: "Generate" }).click(); + await expect(page.getByText("Your Wallpaper")).toBeVisible(); +} + +test("wallpaper sharing keeps the rest of the page interactive", async ({ + page, +}) => { + await loadGeneratedProfile(page); + + await page.getByRole("button", { name: "Twitter/X" }).click(); + const devemonTab = page.getByRole("tab", { name: "Devémon Card" }); + await devemonTab.click(); + + await expect(devemonTab).toHaveAttribute("aria-selected", "true"); +}); + +test("wallpaper downloads keep the rest of the page interactive", async ({ + page, +}) => { + await loadGeneratedProfile(page); + + await page.getByRole("button", { name: /Desktop/ }).click(); + const devemonTab = page.getByRole("tab", { name: "Devémon Card" }); + await devemonTab.click(); + + await expect(devemonTab).toHaveAttribute("aria-selected", "true"); +}); From c821962a6d55ede4c3d4ecb44f03b8dfd06d033e Mon Sep 17 00:00:00 2001 From: Jessica Deen Date: Mon, 17 Aug 2026 17:00:05 -0700 Subject: [PATCH 2/3] Cover Devemon card export responsiveness Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/export-responsiveness.spec.ts | 46 ++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/export-responsiveness.spec.ts b/tests/export-responsiveness.spec.ts index 2e6066b..e4cab5c 100644 --- a/tests/export-responsiveness.spec.ts +++ b/tests/export-responsiveness.spec.ts @@ -62,7 +62,12 @@ async function mockGitHubProfile(page: Page) { async function loadGeneratedProfile(page: Page) { await page.addInitScript(() => { - window.alert = () => undefined; + window.alert = (message) => { + (window as typeof window & { __octocanvasAlerts: string[] }) + .__octocanvasAlerts ??= []; + (window as typeof window & { __octocanvasAlerts: string[] }) + .__octocanvasAlerts.push(String(message)); + }; window.open = () => null; Object.defineProperty(navigator, "clipboard", { configurable: true, @@ -101,6 +106,21 @@ async function loadGeneratedProfile(page: Page) { await expect(page.getByText("Your Wallpaper")).toBeVisible(); } +async function showDevemonCard(page: Page) { + await page.getByRole("tab", { name: "Devémon Card" }).click(); + await expect(page.getByText("Your Devémon Card")).toBeVisible(); +} + +async function expectNoGenerationFailureAlert(page: Page) { + const alerts = await page.evaluate( + () => + (window as typeof window & { __octocanvasAlerts?: string[] }) + .__octocanvasAlerts ?? [] + ); + + expect(alerts).not.toContain("Failed to generate card image. Please try again."); +} + test("wallpaper sharing keeps the rest of the page interactive", async ({ page, }) => { @@ -124,3 +144,27 @@ test("wallpaper downloads keep the rest of the page interactive", async ({ await expect(devemonTab).toHaveAttribute("aria-selected", "true"); }); + +test("devemon card sharing generates an image without the failure alert", async ({ + page, +}) => { + await loadGeneratedProfile(page); + await showDevemonCard(page); + + await page.getByRole("button", { name: "Twitter/X" }).click(); + + await expectNoGenerationFailureAlert(page); +}); + +test("devemon card sharing keeps the rest of the page interactive", async ({ + page, +}) => { + await loadGeneratedProfile(page); + await showDevemonCard(page); + + await page.getByRole("button", { name: "Twitter/X" }).click(); + const bannerTab = page.getByRole("tab", { name: "README Banner" }); + await bannerTab.click(); + + await expect(bannerTab).toHaveAttribute("aria-selected", "true"); +}); From f819f0f93da8114ca4e382d19ef27f8876c393f9 Mon Sep 17 00:00:00 2001 From: Jessica Deen Date: Mon, 17 Aug 2026 18:02:03 -0700 Subject: [PATCH 3/3] Address export responsiveness review Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/components/ActionButtonsArea.tsx | 125 +++++++++++++++----------- src/components/GitHubWallpaperApp.tsx | 32 +++++++ src/components/ui/Button.tsx | 5 ++ src/utils/imageExport.ts | 60 +++++++------ tests/export-responsiveness.spec.ts | 74 +++++++++++++-- 5 files changed, 212 insertions(+), 84 deletions(-) diff --git a/src/components/ActionButtonsArea.tsx b/src/components/ActionButtonsArea.tsx index c2e66a3..f9aa93e 100644 --- a/src/components/ActionButtonsArea.tsx +++ b/src/components/ActionButtonsArea.tsx @@ -1,22 +1,25 @@ -import { useState } from "preact/hooks"; import { PrimaryButton, SecondaryButton } from "./ui/Button"; import { Icon } from "./ui/Icon"; import { SocialIcon } from 'react-social-icons'; import styles from "./ActionButtonsArea.module.css"; +type ActionHandler = () => void | Promise; + interface ActionButtonsAreaProps { actionType: "wallpaper" | "devemon" | "banner"; - onDownloadDesktop?: () => void | Promise; - onDownloadMobile?: () => void | Promise; - onDownloadSmall?: () => void | Promise; - onDownloadCard?: () => void | Promise; - onDownloadBadge?: () => void | Promise; - onDownloadBanner?: () => void | Promise; - onCopyMarkdown?: () => void | Promise; - onShareTwitter?: () => void | Promise; - onShareBluesky?: () => void | Promise; - onShareThreads?: () => void | Promise; - onShareInstagram?: () => void | Promise; + busyAction: string | null; + onRunAction: (actionId: string, action?: ActionHandler) => void; + onDownloadDesktop?: ActionHandler; + onDownloadMobile?: ActionHandler; + onDownloadSmall?: ActionHandler; + onDownloadCard?: ActionHandler; + onDownloadBadge?: ActionHandler; + onDownloadBanner?: ActionHandler; + onCopyMarkdown?: ActionHandler; + onShareTwitter?: ActionHandler; + onShareBluesky?: ActionHandler; + onShareThreads?: ActionHandler; + onShareInstagram?: ActionHandler; } export default function ActionButtonsArea({ @@ -32,23 +35,29 @@ export default function ActionButtonsArea({ onShareBluesky, onShareThreads, onShareInstagram, + busyAction, + onRunAction, }: ActionButtonsAreaProps) { - const [busyAction, setBusyAction] = useState(null); const isBusy = busyAction !== null; - - const runAction = async ( - actionId: string, - action?: () => void | Promise - ) => { - if (!action || isBusy) return; - - setBusyAction(actionId); - try { - await action(); - } finally { - setBusyAction(null); - } + const isActionBusy = (actionId: string) => busyAction === actionId; + const runAction = (actionId: string, action?: ActionHandler) => { + onRunAction(actionId, action); }; + const actionLabel = (actionId: string, label: string) => + isActionBusy(actionId) ? "Generating image..." : label; + const copyMarkdownLabel = isActionBusy("banner-markdown") + ? "Copying..." + : "Copy Markdown"; + const wallpaperDownloadLabel = (actionId: string, label: string) => ( + <> +
+ {isActionBusy(actionId) ? "Generating image..." : label} +
+
+ {isActionBusy(actionId) ? "Please wait" : "Download PNG"} +
+ + ); const renderWallpaperActions = () => (
@@ -58,59 +67,59 @@ export default function ActionButtonsArea({ className={styles.WallpaperButton} onClick={() => runAction("wallpaper-desktop", onDownloadDesktop)} disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-desktop")} > -
- Desktop (2560x1440) -
-
Download PNG
+ {wallpaperDownloadLabel("wallpaper-desktop", "Desktop (2560x1440)")} runAction("wallpaper-mobile", onDownloadMobile)} disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-mobile")} > -
- Mobile (1179x2556) -
-
Download PNG
+ {wallpaperDownloadLabel("wallpaper-mobile", "Mobile (1179x2556)")}
runAction("wallpaper-small", onDownloadSmall)} disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-small")} > -
Badge (320x240)
-
Download PNG
+ {wallpaperDownloadLabel("wallpaper-small", "Badge (320x240)")}
runAction("wallpaper-twitter", onShareTwitter)} disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-twitter")} icon={} > - Twitter/X + {actionLabel("wallpaper-twitter", "Twitter/X")} runAction("wallpaper-bluesky", onShareBluesky)} disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-bluesky")} icon={} > - Bluesky + {actionLabel("wallpaper-bluesky", "Bluesky")} runAction("wallpaper-threads", onShareThreads)} disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-threads")} icon={} > - Threads + {actionLabel("wallpaper-threads", "Threads")} runAction("wallpaper-instagram", onShareInstagram)} disabled={isBusy} + ariaBusy={isActionBusy("wallpaper-instagram")} icon={} > - Instagram + {actionLabel("wallpaper-instagram", "Instagram")}
@@ -123,6 +132,7 @@ export default function ActionButtonsArea({ runAction("devemon-card", onDownloadCard)} disabled={isBusy} + ariaBusy={isActionBusy("devemon-card")} icon={ } > - Download Card + {actionLabel("devemon-card", "Download Card")} runAction("devemon-badge", onDownloadBadge)} disabled={isBusy} + ariaBusy={isActionBusy("devemon-badge")} icon={ } > - Download Badge + {actionLabel("devemon-badge", "Download Badge")}
runAction("devemon-twitter", onShareTwitter)} disabled={isBusy} + ariaBusy={isActionBusy("devemon-twitter")} icon={} > - Twitter/X + {actionLabel("devemon-twitter", "Twitter/X")} runAction("devemon-bluesky", onShareBluesky)} disabled={isBusy} + ariaBusy={isActionBusy("devemon-bluesky")} icon={} > - Bluesky + {actionLabel("devemon-bluesky", "Bluesky")} runAction("devemon-threads", onShareThreads)} disabled={isBusy} + ariaBusy={isActionBusy("devemon-threads")} icon={} > - Threads + {actionLabel("devemon-threads", "Threads")} runAction("devemon-instagram", onShareInstagram)} disabled={isBusy} + ariaBusy={isActionBusy("devemon-instagram")} icon={} > - Instagram + {actionLabel("devemon-instagram", "Instagram")}
@@ -188,6 +203,7 @@ export default function ActionButtonsArea({ runAction("banner-download", onDownloadBanner)} disabled={isBusy} + ariaBusy={isActionBusy("banner-download")} icon={ } > - Download + {actionLabel("banner-download", "Download")} runAction("banner-markdown", onCopyMarkdown)} disabled={isBusy} + ariaBusy={isActionBusy("banner-markdown")} icon={📋} > - Copy Markdown + {copyMarkdownLabel}
runAction("banner-twitter", onShareTwitter)} disabled={isBusy} + ariaBusy={isActionBusy("banner-twitter")} icon={} > - Twitter/X + {actionLabel("banner-twitter", "Twitter/X")} runAction("banner-bluesky", onShareBluesky)} disabled={isBusy} + ariaBusy={isActionBusy("banner-bluesky")} icon={} > - Bluesky + {actionLabel("banner-bluesky", "Bluesky")} runAction("banner-threads", onShareThreads)} disabled={isBusy} + ariaBusy={isActionBusy("banner-threads")} icon={} > - Threads + {actionLabel("banner-threads", "Threads")} runAction("banner-instagram", onShareInstagram)} disabled={isBusy} + ariaBusy={isActionBusy("banner-instagram")} icon={} > - Instagram + {actionLabel("banner-instagram", "Instagram")}
diff --git a/src/components/GitHubWallpaperApp.tsx b/src/components/GitHubWallpaperApp.tsx index d2f119d..5eb5b81 100644 --- a/src/components/GitHubWallpaperApp.tsx +++ b/src/components/GitHubWallpaperApp.tsx @@ -16,6 +16,7 @@ // @ts-nocheck import { useState, useRef } from "preact/hooks"; +import { flushSync } from "preact/compat"; import type { JSX } from "preact"; import WallpaperGenerator, { type WallpaperGeneratorRef, @@ -79,6 +80,7 @@ export default function GitHubWallpaperApp() { const [error, setError] = useState(""); const [selectedTab, setSelectedTab] = useState(0); const [hasSubmitted, setHasSubmitted] = useState(false); + const [busyAction, setBusyAction] = useState(null); // Wallpaper form controls const [wallpaperTheme, setWallpaperTheme] = useState< @@ -114,6 +116,30 @@ export default function GitHubWallpaperApp() { { label: "README Banner", shortLabel: "Banner" }, ]; + const runAction = async (actionId: string, action?: () => void | Promise) => { + if (!action || busyAction) return; + + const minimumBusyMs = actionId === "banner-markdown" ? 0 : 500; + const startedAt = performance.now(); + flushSync(() => { + setBusyAction(actionId); + }); + try { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + await action(); + } finally { + const remainingBusyMs = minimumBusyMs - (performance.now() - startedAt); + if (remainingBusyMs > 0) { + await new Promise((resolve) => { + setTimeout(resolve, remainingBusyMs); + }); + } + setBusyAction(null); + } + }; + // Support left/right arrow key navigation (adapted from Universe Tickets) const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement; @@ -593,6 +619,8 @@ export default function GitHubWallpaperApp() { {selectedTab === 0 && ( wallpaperGeneratorRef.current?.downloadWallpaper("desktop") } @@ -620,6 +648,8 @@ export default function GitHubWallpaperApp() { {selectedTab === 1 && ( devemonCardRef.current?.downloadCard("card") } @@ -644,6 +674,8 @@ export default function GitHubWallpaperApp() { {selectedTab === 2 && ( readmeBannerRef.current?.downloadBanner()} onCopyMarkdown={() => readmeBannerRef.current?.copyMarkdown()} onShareTwitter={() => diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 7354c16..ca629d7 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -32,6 +32,9 @@ export interface ButtonProps { /** Disabled state */ disabled?: boolean; + /** Busy state for assistive technology */ + ariaBusy?: boolean; + /** Full width button */ fullWidth?: boolean; @@ -49,6 +52,7 @@ export function Button({ onClick, type = "button", disabled = false, + ariaBusy = false, fullWidth = false, className = "", icon, @@ -70,6 +74,7 @@ export function Button({ className={buttonClasses} onClick={onClick} disabled={disabled} + aria-busy={ariaBusy || undefined} > {/* Button text with GitHub Universe styling */} {children} diff --git a/src/utils/imageExport.ts b/src/utils/imageExport.ts index 2091a92..d1b849b 100644 --- a/src/utils/imageExport.ts +++ b/src/utils/imageExport.ts @@ -79,28 +79,32 @@ export const svgToPngBlob = async ( const svgBlob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8", }); - const image = await loadSvgBlobIntoImage(svgBlob); + let image: ImageBitmap | HTMLImageElement | undefined; + const canvas = document.createElement("canvas"); - await yieldToBrowser(); + try { + image = await loadSvgBlobIntoImage(svgBlob); - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; + await yieldToBrowser(); - const context = canvas.getContext("2d", { willReadFrequently: false }); - if (!context) { - throw new Error("Failed to get canvas context"); - } + canvas.width = width; + canvas.height = height; - context.drawImage(image, 0, 0, width, height); - if ("close" in image && typeof image.close === "function") { - image.close(); - } + const context = canvas.getContext("2d", { willReadFrequently: false }); + if (!context) { + throw new Error("Failed to get canvas context"); + } - const blob = await canvasToPngBlob(canvas); - canvas.width = 0; - canvas.height = 0; - return blob; + context.drawImage(image, 0, 0, width, height); + return await canvasToPngBlob(canvas); + } finally { + if (image && "close" in image && typeof image.close === "function") { + image.close(); + } + + canvas.width = 0; + canvas.height = 0; + } }; export const captureElementToPngBlob = async ( @@ -110,15 +114,21 @@ export const captureElementToPngBlob = async ( await yieldToBrowser(); const html2canvas = (await import("html2canvas")).default; - const canvas = await html2canvas(element, { - logging: false, - ...options, - }); - const blob = await canvasToPngBlob(canvas); + let canvas: HTMLCanvasElement | undefined; - canvas.width = 0; - canvas.height = 0; - return blob; + try { + canvas = await html2canvas(element, { + logging: false, + ...options, + }); + + return await canvasToPngBlob(canvas); + } finally { + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } + } }; export const downloadBlob = (blob: Blob, filename: string) => { diff --git a/tests/export-responsiveness.spec.ts b/tests/export-responsiveness.spec.ts index e4cab5c..27681d0 100644 --- a/tests/export-responsiveness.spec.ts +++ b/tests/export-responsiveness.spec.ts @@ -4,6 +4,7 @@ const avatarPng = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", "base64" ); +const interactionTimeoutMs = 750; async function mockGitHubProfile(page: Page) { await page.route("**/users/octocat", async (route) => { @@ -80,6 +81,13 @@ async function loadGeneratedProfile(page: Page) { }, }); + (window as typeof window & { __octocanvasTicks: number }) + .__octocanvasTicks = 0; + window.setInterval(() => { + (window as typeof window & { __octocanvasTicks: number }) + .__octocanvasTicks += 1; + }, 50); + const originalToBlob = HTMLCanvasElement.prototype.toBlob; HTMLCanvasElement.prototype.toBlob = function delayedToBlob( callback, @@ -88,7 +96,7 @@ async function loadGeneratedProfile(page: Page) { ) { return originalToBlob.call( this, - (blob) => window.setTimeout(() => callback(blob), 300), + (blob) => window.setTimeout(() => callback(blob), 1000), type, quality ); @@ -121,14 +129,50 @@ async function expectNoGenerationFailureAlert(page: Page) { expect(alerts).not.toContain("Failed to generate card image. Please try again."); } +async function expectResponsiveInteraction( + page: Page, + action: () => Promise, + description: string +) { + const startingTicks = await page.evaluate( + () => + (window as typeof window & { __octocanvasTicks: number }) + .__octocanvasTicks + ); + const startedAt = Date.now(); + + await action(); + + expect( + Date.now() - startedAt, + `${description} should not wait for image generation to finish` + ).toBeLessThan(interactionTimeoutMs); + + await page.waitForFunction( + (ticks) => + (window as typeof window & { __octocanvasTicks: number }) + .__octocanvasTicks > ticks, + startingTicks, + { timeout: interactionTimeoutMs } + ); +} + test("wallpaper sharing keeps the rest of the page interactive", async ({ page, }) => { await loadGeneratedProfile(page); - await page.getByRole("button", { name: "Twitter/X" }).click(); + await expectResponsiveInteraction( + page, + () => page.getByRole("button", { name: "Twitter/X" }).click(), + "Wallpaper share click" + ); const devemonTab = page.getByRole("tab", { name: "Devémon Card" }); - await devemonTab.click(); + await expectResponsiveInteraction( + page, + () => devemonTab.click(), + "Tab switch during wallpaper share" + ); await expect(devemonTab).toHaveAttribute("aria-selected", "true"); }); @@ -138,9 +182,17 @@ test("wallpaper downloads keep the rest of the page interactive", async ({ }) => { await loadGeneratedProfile(page); - await page.getByRole("button", { name: /Desktop/ }).click(); + await expectResponsiveInteraction( + page, + () => page.getByRole("button", { name: /Desktop/ }).click(), + "Wallpaper download click" + ); const devemonTab = page.getByRole("tab", { name: "Devémon Card" }); - await devemonTab.click(); + await expectResponsiveInteraction( + page, + () => devemonTab.click(), + "Tab switch during wallpaper download" + ); await expect(devemonTab).toHaveAttribute("aria-selected", "true"); }); @@ -162,9 +214,17 @@ test("devemon card sharing keeps the rest of the page interactive", async ({ await loadGeneratedProfile(page); await showDevemonCard(page); - await page.getByRole("button", { name: "Twitter/X" }).click(); + await expectResponsiveInteraction( + page, + () => page.getByRole("button", { name: "Twitter/X" }).click(), + "Devémon share click" + ); const bannerTab = page.getByRole("tab", { name: "README Banner" }); - await bannerTab.click(); + await expectResponsiveInteraction( + page, + () => bannerTab.click(), + "Tab switch during Devémon share" + ); await expect(bannerTab).toHaveAttribute("aria-selected", "true"); });