Files
Randify.pro/src/components/generators/FontPairGenerator.astro
T
emil 75038780d5 fix(lint): fix pre-existing unused vars in 5 generators
- EmojiGenerator: remove unused clickToCopyLabel, CopyFeedback import, forEach i
- FontPairGenerator: prefer-const loadedFonts, remove unused headingFamily, bodyFamily, copyIconSvg
- HashGenerator: remove unused copyIcon, checkIcon, copyLabel
- Magic8BallGenerator: remove unused getTypeLabel
- TeamsGenerator: remove unused teamPrefix, errAddTwo, errTeamsMin, errTeamsMax, errMorePlayers
2026-05-13 21:29:57 +03:00

484 lines
26 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import { useT } from "../../i18n/translations";
const isRu = Astro.url.pathname.startsWith("/ru");
const T = useT(isRu ? "ru" : "en");
const i18n = {
generate: T.generate,
copied: T.copied,
copy: T.copy,
headingLabel: isRu ? "Заголовок" : "Heading",
bodyLabel: isRu ? "Основной текст" : "Body Text",
copyHtmlCss: isRu ? "Копировать HTML и CSS" : "Copy HTML & CSS",
shuffleHeading: isRu ? "Перемешать заголовок" : "Shuffle heading",
shuffleBody: isRu ? "Перемешать текст" : "Shuffle body",
lockHeading: isRu ? "Закрепить заголовок" : "Lock heading",
lockBody: isRu ? "Закрепить текст" : "Lock body",
unlockHeading: isRu ? "Открепить заголовок" : "Unlock heading",
unlockBody: isRu ? "Открепить текст" : "Unlock body",
googleFonts: isRu ? "Открыть в Google Fonts" : "Open in Google Fonts",
previewHeading: isRu ? "Быстрая коричневая лиса прыгает через ленивого пса" : "The quick brown fox jumps over the lazy dog",
previewBody: isRu
? "Lorem ipsum — это текст-рыба, часто используемый в печати и веб-дизайне. Он демонстрирует, как выглядит шрифт в реальном абзаце."
: "Lorem ipspsum dolor sit amet, consectetur adipiscing elit. This sample text shows how the body font looks in a real paragraph with multiple lines of content.",
categories: {
serif: isRu ? "С засечками" : "Serif",
sans: isRu ? "Без засечек" : "Sans-Serif",
display: isRu ? "Декоративный" : "Display",
handwriting: isRu ? "Рукописный" : "Handwriting",
monospace: isRu ? "Моноширинный" : "Monospace",
},
};
---
<div id="fontpair-generator" class="mt-8">
<!-- Controls -->
<div class="flex flex-col sm:flex-row gap-4 mb-6 items-center justify-center">
<button
id="fp-shuffle-heading"
type="button"
class="px-5 py-2.5 bg-zinc-800 text-zinc-200 font-medium rounded-xl border border-zinc-700 hover:bg-zinc-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer text-sm"
title={i18n.shuffleHeading}
>
<span class="inline-flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l14.4-12.6"/><path d="M22 6h-1.4c-1.3 0-2.5.6-3.3 1.7L7.7 18"/><path d="m16 3 6 3-3 6"/><path d="m8 21-6-3 3-6"/></svg>
{i18n.headingLabel}
</span>
</button>
<button
id="fp-shuffle-body"
type="button"
class="px-5 py-2.5 bg-zinc-800 text-zinc-200 font-medium rounded-xl border border-zinc-700 hover:bg-zinc-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer text-sm"
title={i18n.shuffleBody}
>
<span class="inline-flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l14.4-12.6"/><path d="M22 6h-1.4c-1.3 0-2.5.6-3.3 1.7L7.7 18"/><path d="m16 3 6 3-3 6"/><path d="m8 21-6-3 3-6"/></svg>
{i18n.bodyLabel}
</span>
</button>
</div>
<!-- Result container -->
<div
id="fp-result"
class="rounded-2xl bg-zinc-900/80 border border-zinc-800/80 p-6 sm:p-8 opacity-0"
style="transition: opacity 0.25s ease;"
aria-live="polite"
>
<!-- Font info row -->
<div class="flex flex-col sm:flex-row gap-4 mb-6 justify-between items-start">
<!-- Heading font info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-semibold uppercase tracking-wider text-zinc-500">{i18n.headingLabel}</span>
<button
id="fp-lock-heading"
type="button"
class="p-1 rounded-lg hover:bg-zinc-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
title={i18n.lockHeading}
aria-pressed="false"
>
<svg id="fp-lock-heading-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-zinc-500"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/><circle cx="12" cy="16" r="1"/></svg>
</button>
</div>
<a
id="fp-heading-link"
href="#"
target="_blank"
rel="noopener noreferrer"
class="text-lg font-semibold text-accent hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
<span id="fp-heading-name">—</span>
</a>
<span id="fp-heading-tag" class="ml-2 inline-block px-2 py-0.5 text-xs font-medium bg-zinc-800 text-zinc-400 rounded-full border border-zinc-700/50">—</span>
</div>
<!-- Body font info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-semibold uppercase tracking-wider text-zinc-500">{i18n.bodyLabel}</span>
<button
id="fp-lock-body"
type="button"
class="p-1 rounded-lg hover:bg-zinc-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors cursor-pointer"
title={i18n.lockBody}
aria-pressed="false"
>
<svg id="fp-lock-body-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-zinc-500"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/><circle cx="12" cy="16" r="1"/></svg>
</button>
</div>
<a
id="fp-body-link"
href="#"
target="_blank"
rel="noopener noreferrer"
class="text-lg font-semibold text-accent hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
<span id="fp-body-name">—</span>
</a>
<span id="fp-body-tag" class="ml-2 inline-block px-2 py-0.5 text-xs font-medium bg-zinc-800 text-zinc-400 rounded-full border border-zinc-700/50">—</span>
</div>
</div>
<!-- Preview card -->
<div class="rounded-xl bg-zinc-950/60 border border-zinc-800/60 p-6 sm:p-8 mb-6">
<p
id="fp-preview-heading"
class="text-2xl sm:text-3xl font-bold text-zinc-100 mb-4 leading-tight"
style="font-family: serif;"
>
{i18n.previewHeading}
</p>
<p
id="fp-preview-body"
class="text-base text-zinc-300 leading-relaxed"
style="font-family: sans-serif;"
>
{i18n.previewBody}
</p>
</div>
<!-- Copy button -->
<div class="flex justify-center">
<button
id="fp-copy"
type="button"
class="w-full sm:w-auto px-6 py-2.5 bg-zinc-800 text-zinc-200 font-medium rounded-xl border border-zinc-700 hover:bg-zinc-700 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
<span class="inline-flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
{i18n.copyHtmlCss}
</span>
</button>
</div>
</div>
<!-- Error display -->
<div id="fp-error" class="hidden mt-4 text-sm text-red-400 text-center" role="alert"></div>
<!-- Generate button -->
<div class="flex justify-center mt-6">
<button
id="fp-btn"
type="button"
class="w-full sm:w-auto px-8 py-3 bg-accent text-white font-semibold rounded-xl shadow-lg shadow-accent/25 hover:bg-accent-hover active:bg-accent-active focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 transition-colors duration-100 cursor-pointer"
>
{i18n.generate}
</button>
</div>
</div>
<script>
import { createErrorDisplay } from "@/lib/client/validation";
import { randomInt } from "@/lib/client/random";
import { getClientLang, getClientT } from "@/lib/client/i18n";
const isRu = getClientLang() === "ru";
const clientT = getClientT();
const generateBtn = document.getElementById("fp-btn") as HTMLButtonElement;
const shuffleHeadingBtn = document.getElementById("fp-shuffle-heading") as HTMLButtonElement;
const shuffleBodyBtn = document.getElementById("fp-shuffle-body") as HTMLButtonElement;
const resultEl = document.getElementById("fp-result") as HTMLDivElement;
const copyBtn = document.getElementById("fp-copy") as HTMLButtonElement;
const errorEl = document.getElementById("fp-error") as HTMLDivElement;
const errorDisplay = createErrorDisplay(errorEl);
const headingNameEl = document.getElementById("fp-heading-name") as HTMLSpanElement;
const headingLinkEl = document.getElementById("fp-heading-link") as HTMLAnchorElement;
const headingTagEl = document.getElementById("fp-heading-tag") as HTMLSpanElement;
const previewHeadingEl = document.getElementById("fp-preview-heading") as HTMLParagraphElement;
const lockHeadingBtn = document.getElementById("fp-lock-heading") as HTMLButtonElement;
const lockHeadingIcon = document.getElementById("fp-lock-heading-icon") as SVGElement;
const bodyNameEl = document.getElementById("fp-body-name") as HTMLSpanElement;
const bodyLinkEl = document.getElementById("fp-body-link") as HTMLAnchorElement;
const bodyTagEl = document.getElementById("fp-body-tag") as HTMLSpanElement;
const previewBodyEl = document.getElementById("fp-preview-body") as HTMLParagraphElement;
const lockBodyBtn = document.getElementById("fp-lock-body") as HTMLButtonElement;
const lockBodyIcon = document.getElementById("fp-lock-body-icon") as SVGElement;
// Category labels
const categoryLabels: Record<string, string> = isRu
? { serif: "С засечками", sans: "Без засечек", display: "Декоративный", handwriting: "Рукописный", monospace: "Моноширинный" }
: { serif: "Serif", sans: "Sans-Serif", display: "Display", handwriting: "Handwriting", monospace: "Monospace" };
// Font database
interface Font {
name: string;
apiName: string;
category: string;
}
const headingFonts: Font[] = [
{ name: "Playfair Display", apiName: "Playfair+Display:wght@400;700;900&display=swap", category: "serif" },
{ name: "Montserrat", apiName: "Montserrat:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Oswald", apiName: "Oswald:wght@400;500;700&display=swap", category: "sans" },
{ name: "Raleway", apiName: "Raleway:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Bebas Neue", apiName: "Bebas+Neue&display=swap", category: "display" },
{ name: "Lora", apiName: "Lora:wght@400;600;700&display=swap", category: "serif" },
{ name: "Merriweather", apiName: "Merriweather:wght@400;700;900&display=swap", category: "serif" },
{ name: "Poppins", apiName: "Poppins:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Roboto Slab", apiName: "Roboto+Slab:wght@400;700&display=swap", category: "serif" },
{ name: "Nunito", apiName: "Nunito:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Quicksand", apiName: "Quicksand:wght@400;600;700&display=swap", category: "sans" },
{ name: "Work Sans", apiName: "Work+Sans:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Inter", apiName: "Inter:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Fira Sans", apiName: "Fira+Sans:wght@400;600;700&display=swap", category: "sans" },
{ name: "Source Sans Pro", apiName: "Source+Sans+3:wght@400;600;700;900&display=swap", category: "sans" },
{ name: "Open Sans", apiName: "Open+Sans:wght@400;600;700;800&display=swap", category: "sans" },
{ name: "Lato", apiName: "Lato:wght@400;700;900&display=swap", category: "sans" },
{ name: "Space Grotesk", apiName: "Space+Grotesk:wght@400;600;700&display=swap", category: "sans" },
{ name: "DM Sans", apiName: "DM+Sans:wght@400;600;700&display=swap", category: "sans" },
{ name: "Rubik", apiName: "Rubik:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Barlow", apiName: "Barlow:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Karla", apiName: "Karla:wght@400;600;700;800&display=swap", category: "sans" },
{ name: "Josefin Sans", apiName: "Josefin+Sans:wght@400;600;700&display=swap", category: "sans" },
{ name: "Outfit", apiName: "Outfit:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Syne", apiName: "Syne:wght@400;600;700;800&display=swap", category: "display" },
{ name: "Kanit", apiName: "Kanit:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Archivo", apiName: "Archivo:wght@400;600;700;800;900&display=swap", category: "sans" },
{ name: "Teko", apiName: "Teko:wght@400;600;700&display=swap", category: "sans" },
{ name: "Anton", apiName: "Anton&display=swap", category: "display" },
{ name: "Righteous", apiName: "Righteous&display=swap", category: "display" },
{ name: "Fredoka", apiName: "Fredoka:wght@400;600;700&display=swap", category: "display" },
{ name: "Pacifico", apiName: "Pacifico&display=swap", category: "handwriting" },
{ name: "Amatic SC", apiName: "Amatic+SC:wght@400;700&display=swap", category: "handwriting" },
{ name: "Caveat", apiName: "Caveat:wght@400;600;700&display=swap", category: "handwriting" },
{ name: "Dancing Script", apiName: "Dancing+Script:wght@400;600;700&display=swap", category: "handwriting" },
{ name: "Great Vibes", apiName: "Great+Vibes&display=swap", category: "handwriting" },
{ name: "Lobster", apiName: "Lobster&display=swap", category: "display" },
{ name: "Satisfy", apiName: "Satisfy&display=swap", category: "handwriting" },
{ name: "Sacramento", apiName: "Sacramento&display=swap", category: "handwriting" },
{ name: "Allura", apiName: "Allura&display=swap", category: "handwriting" },
{ name: "Cookie", apiName: "Cookie&display=swap", category: "handwriting" },
];
const bodyFonts: Font[] = [
{ name: "Inter", apiName: "Inter:wght@400;500;600&display=swap", category: "sans" },
{ name: "Roboto", apiName: "Roboto:wght@400;500;700&display=swap", category: "sans" },
{ name: "Open Sans", apiName: "Open+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Lato", apiName: "Lato:wght@400;700&display=swap", category: "sans" },
{ name: "Source Sans Pro", apiName: "Source+Sans+3:wght@400;500;600&display=swap", category: "sans" },
{ name: "Noto Sans", apiName: "Noto+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Nunito", apiName: "Nunito:wght@400;500;600&display=swap", category: "sans" },
{ name: "Work Sans", apiName: "Work+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "DM Sans", apiName: "DM+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Karla", apiName: "Karla:wght@400;500;600&display=swap", category: "sans" },
{ name: "Barlow", apiName: "Barlow:wght@400;500;600&display=swap", category: "sans" },
{ name: "Nunito Sans", apiName: "Nunito+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Noto Serif", apiName: "Noto+Serif:wght@400;500;600&display=swap", category: "serif" },
{ name: "Merriweather", apiName: "Merriweather:wght@400;700&display=swap", category: "serif" },
{ name: "Lora", apiName: "Lora:wght@400;500;600&display=swap", category: "serif" },
{ name: "Crimson Text", apiName: "Crimson+Text:wght@400;600;700&display=swap", category: "serif" },
{ name: "Quicksand", apiName: "Quicksand:wght@400;500;600&display=swap", category: "sans" },
{ name: "Space Grotesk", apiName: "Space+Grotesk:wght@400;500;600&display=swap", category: "sans" },
{ name: "Rubik", apiName: "Rubik:wght@400;500;600&display=swap", category: "sans" },
{ name: "Outfit", apiName: "Outfit:wght@400;500;600&display=swap", category: "sans" },
{ name: "IBM Plex Sans", apiName: "IBM+Plex+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "IBM Plex Serif", apiName: "IBM+Plex+Serif:wght@400;500;600&display=swap", category: "serif" },
{ name: "PT Sans", apiName: "PT+Sans:wght@400;700&display=swap", category: "sans" },
{ name: "PT Serif", apiName: "PT+Serif:wght@400;700&display=swap", category: "serif" },
{ name: "Cantarell", apiName: "Cantarell:wght@400;700&display=swap", category: "sans" },
{ name: "Fira Sans", apiName: "Fira+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Oxygen", apiName: "Oxygen:wght@400;700&display=swap", category: "sans" },
{ name: "Ubuntu", apiName: "Ubuntu:wght@400;500;700&display=swap", category: "sans" },
{ name: "Libre Franklin", apiName: "Libre+Franklin:wght@400;500;600&display=swap", category: "sans" },
{ name: "Libre Baskerville", apiName: "Libre+Baskerville:wght@400;700&display=swap", category: "serif" },
{ name: "Spectral", apiName: "Spectral:wght@400;500;600&display=swap", category: "serif" },
{ name: "Literata", apiName: "Literata:wght@400;500;600&display=swap", category: "serif" },
{ name: "Public Sans", apiName: "Public+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Sen", apiName: "Sen:wght@400;700&display=swap", category: "sans" },
{ name: "Mulish", apiName: "Mulish:wght@400;500;600&display=swap", category: "sans" },
{ name: "Manrope", apiName: "Manrope:wght@400;500;600&display=swap", category: "sans" },
{ name: "Sora", apiName: "Sora:wght@400;500;600&display=swap", category: "sans" },
{ name: "Plus Jakarta Sans", apiName: "Plus+Jakarta+Sans:wght@400;500;600&display=swap", category: "sans" },
{ name: "Geist", apiName: "Geist:wght@400;500;600&display=swap", category: "sans" },
{ name: "Onest", apiName: "Onest:wght@400;500;600&display=swap", category: "sans" },
{ name: "Instrument Sans", apiName: "Instrument+Sans:wght@400;500;600;700&display=swap", category: "sans" },
{ name: "Familjen Grotesk", apiName: "Familjen+Grotesk:wght@400;500;600;700&display=swap", category: "sans" },
{ name: "Readex Pro", apiName: "Readex+Pro:wght@400;500;600;700&display=swap", category: "sans" },
{ name: "Markazi Text", apiName: "Markazi+Text:wght@400;500;600;700&display=swap", category: "serif" },
{ name: "Exo 2", apiName: "Exo+2:wght@400;500;600&display=swap", category: "sans" },
{ name: "Cabin", apiName: "Cabin:wght@400;500;600;700&display=swap", category: "sans" },
{ name: "Raleway", apiName: "Raleway:wght@400;500;600&display=swap", category: "sans" },
{ name: "Heebo", apiName: "Heebo:wght@400;500;600;700&display=swap", category: "sans" },
];
let currentHeading: Font | null = null;
let currentBody: Font | null = null;
let headingLocked = false;
let bodyLocked = false;
const loadedFonts = new Set<string>();
function randomItem<T>(arr: T[]): T {
return arr[randomInt(0, arr.length - 1)];
}
function getGoogleFontsUrl(heading: Font, body: Font): string {
return `https://fonts.googleapis.com/css2?family=${heading.apiName}&family=${body.apiName}&display=swap`;
}
function getSpecimenUrl(fontName: string): string {
return `https://fonts.google.com/specimen/${fontName.replace(/\s+/g, "+")}`;
}
function loadFont(url: string) {
if (loadedFonts.has(url)) return;
loadedFonts.add(url);
const link = document.createElement("link");
link.href = url;
link.rel = "stylesheet";
document.head.appendChild(link);
}
function getFontFamily(font: Font): string {
return `"${font.name}", ${font.category === "serif" ? "serif" : font.category === "monospace" ? "monospace" : "sans-serif"}`;
}
const lockedIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/><circle cx="12" cy="16" r="1"/></svg>`;
const unlockedIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-zinc-500"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/><circle cx="12" cy="16" r="1"/></svg>`;
const checkIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="text-accent"><path d="M20 6 9 17l-5-5"/></svg>`;
function render() {
if (!currentHeading || !currentBody) return;
// Update heading info
headingNameEl.textContent = currentHeading.name;
headingLinkEl.href = getSpecimenUrl(currentHeading.name);
headingTagEl.textContent = categoryLabels[currentHeading.category] || currentHeading.category;
// Update body info
bodyNameEl.textContent = currentBody.name;
bodyLinkEl.href = getSpecimenUrl(currentBody.name);
bodyTagEl.textContent = categoryLabels[currentBody.category] || currentBody.category;
// Update preview fonts
previewHeadingEl.style.fontFamily = getFontFamily(currentHeading);
previewBodyEl.style.fontFamily = getFontFamily(currentBody);
// Load fonts
const fontUrl = getGoogleFontsUrl(currentHeading, currentBody);
loadFont(fontUrl);
// Show result
resultEl.style.opacity = "1";
errorDisplay.clear();
}
function generatePair() {
if (!headingLocked || !currentHeading) {
currentHeading = randomItem(headingFonts);
}
if (!bodyLocked || !currentBody) {
let nextBody = randomItem(bodyFonts);
// Try to avoid picking the same font for both (if possible)
if (currentHeading && nextBody.name === currentHeading.name && bodyFonts.length > 1) {
let attempts = 0;
while (nextBody.name === currentHeading.name && attempts < 10) {
nextBody = randomItem(bodyFonts);
attempts++;
}
}
currentBody = nextBody;
}
render();
}
function shuffleHeadingOnly() {
const prevName = currentHeading?.name;
let next = randomItem(headingFonts);
// Try to pick a different font
if (prevName && headingFonts.length > 1) {
let attempts = 0;
while (next.name === prevName && attempts < 10) {
next = randomItem(headingFonts);
attempts++;
}
}
currentHeading = next;
render();
}
function shuffleBodyOnly() {
const prevName = currentBody?.name;
let next = randomItem(bodyFonts);
// Try to pick a different font
if (prevName && bodyFonts.length > 1) {
let attempts = 0;
while (next.name === prevName && attempts < 10) {
next = randomItem(bodyFonts);
attempts++;
}
}
// Also try to avoid matching heading
if (currentHeading && next.name === currentHeading.name && bodyFonts.length > 1) {
let attempts = 0;
while (next.name === currentHeading.name && attempts < 10) {
next = randomItem(bodyFonts);
attempts++;
}
}
currentBody = next;
render();
}
// Lock toggle handlers
lockHeadingBtn.addEventListener("click", () => {
headingLocked = !headingLocked;
lockHeadingBtn.setAttribute("aria-pressed", String(headingLocked));
lockHeadingIcon.innerHTML = headingLocked ? lockedIconSvg : unlockedIconSvg;
if (headingLocked) {
lockHeadingIcon.classList.remove("text-zinc-500");
lockHeadingIcon.classList.add("text-accent");
} else {
lockHeadingIcon.classList.remove("text-accent");
lockHeadingIcon.classList.add("text-zinc-500");
}
});
lockBodyBtn.addEventListener("click", () => {
bodyLocked = !bodyLocked;
lockBodyBtn.setAttribute("aria-pressed", String(bodyLocked));
lockBodyIcon.innerHTML = bodyLocked ? lockedIconSvg : unlockedIconSvg;
if (bodyLocked) {
lockBodyIcon.classList.remove("text-zinc-500");
lockBodyIcon.classList.add("text-accent");
} else {
lockBodyIcon.classList.remove("text-accent");
lockBodyIcon.classList.add("text-zinc-500");
}
});
// Generate button
generateBtn.addEventListener("click", generatePair);
// Shuffle buttons
shuffleHeadingBtn.addEventListener("click", shuffleHeadingOnly);
shuffleBodyBtn.addEventListener("click", shuffleBodyOnly);
// Copy button
copyBtn.addEventListener("click", async () => {
if (!currentHeading || !currentBody) return;
const headingFamily = getFontFamily(currentHeading);
const bodyFamily = getFontFamily(currentBody);
const fontUrl = getGoogleFontsUrl(currentHeading, currentBody);
const htmlLink = `<link href="${fontUrl}" rel="stylesheet">`;
const cssCode = `font-family: ${headingFamily}; /* heading */\nfont-family: ${bodyFamily}; /* body */`;
const fullText = `${htmlLink}\n\n/* CSS */\n${cssCode}`;
await navigator.clipboard.writeText(fullText);
const originalHtml = copyBtn.innerHTML;
copyBtn.innerHTML = `<span class="inline-flex items-center gap-2">${checkIconSvg} ${clientT.copied}</span>`;
setTimeout(() => {
copyBtn.innerHTML = originalHtml;
}, 1500);
});
// Generate on load
generatePair();
</script>