Improve skin tone rendering and add site Twemoji fallback

This commit is contained in:
Dwindi Ramadhana
2026-02-23 05:33:44 +07:00
parent 32e9282349
commit 87e947ee95
4 changed files with 571 additions and 15 deletions

View File

@@ -161,7 +161,7 @@
</div>
@if($supportsTone)
<div class="glass-card rounded-xl p-3">
<div id="tone-panel" class="glass-card rounded-xl p-3">
<div class="text-xs font-mono text-gray-500 mb-2">Skin tone</div>
<div class="flex flex-wrap gap-2">
<button type="button" data-tone="off" class="tone-chip px-3 py-1.5 rounded-lg border border-white/10 text-sm text-gray-200 bg-white/5 hover:bg-white/10">Default</button>
@@ -339,6 +339,151 @@ const DETAIL_SUBCATEGORY = @json($subcategory);
const DETAIL_SUPPORTS_TONE = @json($supportsTone);
const DETAIL_VARIANTS_LIST = @json(array_values($toneVariants));
const TONE_VARIANTS = @json($toneVariants);
const TONE_CHAR_BY_SLUG = {
light: '\u{1F3FB}',
'medium-light': '\u{1F3FC}',
medium: '\u{1F3FD}',
'medium-dark': '\u{1F3FE}',
dark: '\u{1F3FF}',
};
const TONE_CPS = new Set([0x1F3FB, 0x1F3FC, 0x1F3FD, 0x1F3FE, 0x1F3FF]);
const MODIFIABLE_BASES = new Set([
0x1F9D1, 0x1F468, 0x1F469, 0x1F466, 0x1F467, 0x1F476,
0x1F575, 0x1F93C, 0x26F9, 0x1F3CB, 0x1F93D, 0x1F93E, 0x1F926,
]);
const NOT_TONEABLE_BASES = new Set([
0x1F9BE, 0x1F9BF, 0x1FAC0, 0x1F9E0, 0x1FAC1, 0x1F9B7, 0x1F9B4,
0x1F440, 0x1F441, 0x1F445, 0x1F444, 0x1FAE6,
0x1F9DE, 0x1F9DF, 0x1F9CC, 0x26F7, 0x1F3C2,
]);
const NON_TONEABLE_NAME_PATTERNS = [
/\bspeaking head\b/i,
/\bbust in silhouette\b/i,
/\bbusts in silhouette\b/i,
/\bfootprints?\b/i,
/\bfingerprint\b/i,
/\bfamily\b/i,
/\bpeople hugging\b/i,
/\bpeople with bunny ears\b/i,
/\bpeople wrestling\b/i,
/\bpeople fencing\b/i,
/\bperson fencing\b/i,
/\bmen wrestling\b/i,
/\bwomen wrestling\b/i,
/\bmerman\b/i,
/\bmermaid\b/i,
/\bdeaf man\b/i,
/\bdeaf woman\b/i,
/\bman: beard\b/i,
/\bwoman: beard\b/i,
/\bperson running facing right\b/i,
/\bperson walking facing right\b/i,
/\bperson kneeling facing right\b/i,
];
const ROLE_TONEABLE_NAME_PATTERNS = [
/\btechnologist\b/i,
/\bscientist\b/i,
/\boffice worker\b/i,
/\bfactory worker\b/i,
/\bmechanic\b/i,
/\bcook\b/i,
/\bfarmer\b/i,
/\bjudge\b/i,
/\bteacher\b/i,
/\bstudent\b/i,
/\bhealth worker\b/i,
/\bsinger\b/i,
/\bastronaut\b/i,
/\bfirefighter\b/i,
/\bfacepalming\b/i,
/\bdancing\b/i,
/\bdetective\b/i,
/\bfencing\b/i,
/\bbouncing ball\b/i,
/\blifting weights\b/i,
/\bwrestling\b/i,
];
const TONE_CATEGORY_ALLOWLIST = new Set(['people & body', 'activities']);
const ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS = [
/\bperson fencing\b/i,
/\bmen wrestling\b/i,
/\bwomen wrestling\b/i,
];
const ANDROID_TONE_RENDER_BLOCKLIST_SLUGS = new Set([
'person-fencing',
'men-wrestling',
'women-wrestling',
]);
const ANDROID_TONE_IMAGE_FALLBACK_SLUGS = new Set([
'person-fencing',
'men-wrestling',
'women-wrestling',
]);
const IS_ANDROID_RENDERER = (() => {
try {
if (/Android/i.test(navigator.userAgent || '')) return true;
const platform = window.Capacitor?.getPlatform?.();
return platform === 'android';
} catch (_) {
return /Android/i.test(navigator.userAgent || '');
}
})();
function twemojiSvgUrlFromCodepoints(cp) {
return `https://twemoji.maxcdn.com/v/latest/svg/${cp}.svg`;
}
function notoEmojiSvgUrlFromCodepoints(cp) {
return `https://cdn.jsdelivr.net/gh/googlefonts/noto-emoji@main/svg/emoji_u${String(cp || '').replace(/-/g, '_')}.svg`;
}
function shouldUseDetailToneImageFallback(emojiStr, tone = 'off') {
if (!IS_ANDROID_RENDERER) return false;
if (!tone || tone === 'off') return false;
if (!ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(String(DETAIL_SLUG || '').toLowerCase())) return false;
return /\u200D/.test(String(emojiStr || ''));
}
function renderDetailHeroEmoji(emojiStr, tone = 'off') {
const hero = document.getElementById('emoji-hero-symbol');
if (!hero) return;
const glyph = String(emojiStr || '');
if (!glyph) {
hero.textContent = '';
return;
}
if (!shouldUseDetailToneImageFallback(glyph, tone) || !window.twemoji?.convert?.toCodePoint) {
hero.textContent = glyph;
return;
}
let cp = '';
try {
cp = window.twemoji.convert.toCodePoint(glyph);
} catch (_) {
hero.textContent = glyph;
return;
}
if (!cp) {
hero.textContent = glyph;
return;
}
const img = new Image();
img.alt = glyph;
img.draggable = false;
img.decoding = 'async';
img.className = 'inline-block align-middle select-none';
img.style.width = '1em';
img.style.height = '1em';
img.style.objectFit = 'contain';
img.onerror = () => {
img.onerror = () => {
hero.textContent = glyph;
};
img.src = twemojiSvgUrlFromCodepoints(cp);
};
img.src = notoEmojiSvgUrlFromCodepoints(cp);
hero.replaceChildren(img);
}
let currentDisplayEmoji = SYMBOL_DEFAULT;
function getStoredTone() {
@@ -351,13 +496,17 @@ function setStoredTone(value) {
function emojiByTone(tone) {
if (!tone || tone === 'off') return SYMBOL_DEFAULT;
return TONE_VARIANTS[tone] || SYMBOL_DEFAULT;
if (!isDetailToneAllowed()) return stripSkinTone(SYMBOL_DEFAULT) || SYMBOL_DEFAULT;
const toneChar = TONE_CHAR_BY_SLUG[tone];
if (!toneChar) return TONE_VARIANTS[tone] || SYMBOL_DEFAULT;
const base = stripSkinTone(SYMBOL_DEFAULT);
if (!canApplyToneTo(base)) return base;
return applyToneSmart(base, toneChar) || TONE_VARIANTS[tone] || base || SYMBOL_DEFAULT;
}
function applyTone(tone) {
currentDisplayEmoji = emojiByTone(tone);
const hero = document.getElementById('emoji-hero-symbol');
if (hero) hero.textContent = currentDisplayEmoji;
renderDetailHeroEmoji(currentDisplayEmoji, tone);
document.querySelectorAll('.tone-chip').forEach((chip) => {
const active = chip.dataset.tone === tone;
chip.classList.toggle('bg-brand-ocean/20', active);
@@ -387,6 +536,125 @@ function isRecentEmojiToken(value) {
}
}
function isDetailNameNonToneable() {
return NON_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(DETAIL_NAME || ''));
}
function isDetailRoleToneable() {
return ROLE_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(DETAIL_NAME || ''));
}
function isDetailToneAllowed() {
if (!DETAIL_SUPPORTS_TONE) return false;
const category = String(DETAIL_CATEGORY || '').toLowerCase();
if (!TONE_CATEGORY_ALLOWLIST.has(category)) return false;
if (isDetailNameNonToneable()) return false;
if (IS_ANDROID_RENDERER) {
const slug = String(DETAIL_SLUG || '').toLowerCase();
const usesImageFallback = ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(slug);
if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_SLUGS.has(slug)) return false;
if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS.some((rx) => rx.test(DETAIL_NAME || ''))) return false;
}
const base = stripSkinTone(SYMBOL_DEFAULT);
if (!base || !canApplyToneTo(base)) return false;
const name = String(DETAIL_NAME || '').toLowerCase();
const looksGenderedRole =
category === 'people & body' &&
(name.startsWith('man ') || name.startsWith('woman ') || /[:\-,]\s*(man|woman)\b/.test(name));
if (looksGenderedRole && !isDetailRoleToneable()) return false;
return true;
}
function stripSkinTone(emojiChar) {
return String(emojiChar || '').replace(/[\u{1F3FB}-\u{1F3FF}]/gu, '');
}
function containsZWJ(s) {
return /\u200D/.test(String(s || ''));
}
function countHumans(s) {
let n = 0;
for (const ch of Array.from(String(s || ''))) {
const cp = ch.codePointAt(0);
if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) n++;
}
return n;
}
function canApplyToneTo(s) {
if (!s) return false;
try {
const cp0 = String(s).codePointAt(0);
if (NOT_TONEABLE_BASES.has(cp0)) return false;
} catch (_) {}
if (!containsZWJ(s)) return true;
return countHumans(s) <= 2;
}
function toCodePoints(str) {
const out = [];
for (const ch of String(str || '')) out.push(ch.codePointAt(0));
return out;
}
function fromCodePoints(arr) {
return String.fromCodePoint(...arr);
}
function applyToneSmart(emojiChar, toneChar) {
if (!emojiChar || !toneChar) return emojiChar;
const toneCp = String(toneChar).codePointAt(0);
if (!TONE_CPS.has(toneCp)) return emojiChar;
const cps = toCodePoints(emojiChar);
for (const cp of cps) {
if (TONE_CPS.has(cp)) return emojiChar;
}
const VS16 = 0xFE0F;
const ZWJ = 0x200D;
const idxs = [];
for (let i = 0; i < cps.length; i += 1) {
if (MODIFIABLE_BASES.has(cps[i])) idxs.push(i);
}
if (idxs.length === 0) {
if (cps.includes(ZWJ)) return emojiChar;
let pos = 1;
let rightStart = pos;
if (cps[1] === VS16) {
rightStart = 2;
}
const left = cps.slice(0, pos);
left.push(toneCp);
return fromCodePoints(left.concat(cps.slice(rightStart)));
}
if (idxs.length === 2) {
const out = cps.slice();
const insertAfter = (baseIdx) => {
let pos = baseIdx + 1;
if (out[pos] === VS16) out.splice(pos, 1);
out.splice(pos, 0, toneCp);
};
insertAfter(idxs[1]);
insertAfter(idxs[0]);
return fromCodePoints(out);
}
let pos = idxs[0] + 1;
let rightStart = pos;
if (cps[pos] === VS16) {
rightStart = pos + 1;
}
const left = cps.slice(0, pos);
left.push(toneCp);
return fromCodePoints(left.concat(cps.slice(rightStart)));
}
function saveRecent(items) {
const clean = items.filter(isRecentEmojiToken);
localStorage.setItem(RECENT_KEY, JSON.stringify(clean.slice(0, 8)));
@@ -504,6 +772,12 @@ document.addEventListener('keydown', (e) => {
});
(() => {
const tonePanel = document.getElementById('tone-panel');
if (!isDetailToneAllowed()) {
if (tonePanel) tonePanel.classList.add('hidden');
applyTone('off');
return;
}
const initialTone = getStoredTone();
applyTone(initialTone);
document.querySelectorAll('.tone-chip').forEach((chip) => {

View File

@@ -383,6 +383,158 @@
'medium-dark': 3,
dark: 4,
};
const toneCharBySlug = {
light: '\u{1F3FB}',
'medium-light': '\u{1F3FC}',
medium: '\u{1F3FD}',
'medium-dark': '\u{1F3FE}',
dark: '\u{1F3FF}',
};
const TONE_CPS = new Set([0x1F3FB, 0x1F3FC, 0x1F3FD, 0x1F3FE, 0x1F3FF]);
const MODIFIABLE_BASES = new Set([
0x1F9D1, 0x1F468, 0x1F469, 0x1F466, 0x1F467, 0x1F476,
0x1F575, 0x1F93C, 0x26F9, 0x1F3CB, 0x1F93D, 0x1F93E, 0x1F926,
]);
const NOT_TONEABLE_BASES = new Set([
0x1F9BE, 0x1F9BF, 0x1FAC0, 0x1F9E0, 0x1FAC1, 0x1F9B7, 0x1F9B4,
0x1F440, 0x1F441, 0x1F445, 0x1F444, 0x1FAE6,
0x1F9DE, 0x1F9DF, 0x1F9CC, 0x26F7, 0x1F3C2,
]);
const NON_TONEABLE_NAME_PATTERNS = [
/\bspeaking head\b/i,
/\bbust in silhouette\b/i,
/\bbusts in silhouette\b/i,
/\bfootprints?\b/i,
/\bfingerprint\b/i,
/\bfamily\b/i,
/\bpeople hugging\b/i,
/\bpeople with bunny ears\b/i,
/\bpeople wrestling\b/i,
/\bpeople fencing\b/i,
/\bperson fencing\b/i,
/\bmen wrestling\b/i,
/\bwomen wrestling\b/i,
/\bmerman\b/i,
/\bmermaid\b/i,
/\bdeaf man\b/i,
/\bdeaf woman\b/i,
/\bman: beard\b/i,
/\bwoman: beard\b/i,
/\bperson running facing right\b/i,
/\bperson walking facing right\b/i,
/\bperson kneeling facing right\b/i,
];
const ROLE_TONEABLE_NAME_PATTERNS = [
/\btechnologist\b/i,
/\bscientist\b/i,
/\boffice worker\b/i,
/\bfactory worker\b/i,
/\bmechanic\b/i,
/\bcook\b/i,
/\bfarmer\b/i,
/\bjudge\b/i,
/\bteacher\b/i,
/\bstudent\b/i,
/\bhealth worker\b/i,
/\bsinger\b/i,
/\bastronaut\b/i,
/\bfirefighter\b/i,
/\bfacepalming\b/i,
/\bdancing\b/i,
/\bdetective\b/i,
/\bfencing\b/i,
/\bbouncing ball\b/i,
/\blifting weights\b/i,
/\bwrestling\b/i,
];
const TONE_CATEGORY_ALLOWLIST = new Set(['people & body', 'activities']);
const ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS = [
/\bperson fencing\b/i,
/\bmen wrestling\b/i,
/\bwomen wrestling\b/i,
];
const ANDROID_TONE_RENDER_BLOCKLIST_SLUGS = new Set([
'person-fencing',
'men-wrestling',
'women-wrestling',
]);
const ANDROID_TONE_IMAGE_FALLBACK_SLUGS = new Set([
'person-fencing',
'men-wrestling',
'women-wrestling',
]);
const IS_ANDROID_RENDERER = (() => {
try {
if (/Android/i.test(navigator.userAgent || '')) return true;
const platform = window.Capacitor?.getPlatform?.();
return platform === 'android';
} catch (_) {
return /Android/i.test(navigator.userAgent || '');
}
})();
function twemojiSvgUrlFromCodepoints(cp) {
return `https://twemoji.maxcdn.com/v/latest/svg/${cp}.svg`;
}
function notoEmojiSvgUrlFromCodepoints(cp) {
return `https://cdn.jsdelivr.net/gh/googlefonts/noto-emoji@main/svg/emoji_u${String(cp || '').replace(/-/g, '_')}.svg`;
}
function shouldUseToneImageFallback(item, emojiStr) {
if (!IS_ANDROID_RENDERER) return false;
const glyph = String(emojiStr || '');
if (!glyph) return false;
const hasToneModifier = /[\u{1F3FB}-\u{1F3FF}]/u.test(glyph);
if (!hasToneModifier) return false;
if (selectedTone() === 'off' && item) return false;
const slug = String(item?.slug || '').toLowerCase();
if (slug) {
if (!ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(slug)) return false;
}
return /\u200D/.test(glyph);
}
function renderEmojiGlyph(container, emojiStr, { item = null } = {}) {
if (!container) return;
const glyph = String(emojiStr || '');
if (!glyph) {
container.textContent = '';
return;
}
if (!shouldUseToneImageFallback(item, glyph) || !window.twemoji?.convert?.toCodePoint) {
container.textContent = glyph;
return;
}
let cp = '';
try {
cp = window.twemoji.convert.toCodePoint(glyph);
} catch (_) {
container.textContent = glyph;
return;
}
if (!cp) {
container.textContent = glyph;
return;
}
const img = new Image();
img.alt = glyph;
img.draggable = false;
img.decoding = 'async';
img.loading = 'lazy';
img.className = 'inline-block align-middle select-none';
img.style.width = '1em';
img.style.height = '1em';
img.style.objectFit = 'contain';
img.onerror = () => {
img.onerror = () => {
container.textContent = glyph;
};
img.src = twemojiSvgUrlFromCodepoints(cp);
};
img.src = notoEmojiSvgUrlFromCodepoints(cp);
container.replaceChildren(img);
}
function isLabelsEnabled() {
return localStorage.getItem(labelsStorageKey) === '1';
@@ -520,11 +672,139 @@
function emojiWithTone(item) {
const tone = selectedTone();
if (tone === 'off') return item.emoji;
if (!item?.supports_skin_tone) return item.emoji;
const variants = Array.isArray(item?.variants) ? item.variants : [];
const idx = toneIndexMap[tone];
if (typeof idx === 'number' && variants[idx]) return variants[idx];
return item.emoji_base || item.emoji;
if (!isToneAllowedForItem(item)) return item.emoji_base || item.emoji;
const toneChar = toneCharBySlug[tone];
if (!toneChar) return item.emoji;
const base = stripSkinTone(item?.emoji_base || item?.emoji || '');
if (!base) return item.emoji;
const toned = applyToneSmart(base, toneChar);
return toned || base;
}
function isNameNonToneable(item) {
const name = String(item?.name || '').toLowerCase();
return NON_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(name));
}
function isRoleToneable(item) {
const name = String(item?.name || '').toLowerCase();
return ROLE_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(name));
}
function isToneAllowedForItem(item) {
if (!item?.supports_skin_tone) return false;
const category = String(item?.category || '').toLowerCase();
if (!TONE_CATEGORY_ALLOWLIST.has(category)) return false;
if (isNameNonToneable(item)) return false;
if (IS_ANDROID_RENDERER) {
const name = String(item?.name || '').toLowerCase();
const slug = String(item?.slug || '').toLowerCase();
const usesImageFallback = ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(slug);
if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_SLUGS.has(slug)) return false;
if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS.some((rx) => rx.test(name))) return false;
}
const base = stripSkinTone(item?.emoji_base || item?.emoji || '');
if (!base || !canApplyToneTo(base)) return false;
const name = String(item?.name || '').toLowerCase();
const looksGenderedRole =
category === 'people & body' &&
(name.startsWith('man ') || name.startsWith('woman ') || /[:\-,]\s*(man|woman)\b/.test(name));
if (looksGenderedRole && !isRoleToneable(item)) return false;
return true;
}
function stripSkinTone(emojiChar) {
return String(emojiChar || '').replace(/[\u{1F3FB}-\u{1F3FF}]/gu, '');
}
function containsZWJ(s) {
return /\u200D/.test(String(s || ''));
}
function countHumans(s) {
let n = 0;
for (const ch of Array.from(String(s || ''))) {
const cp = ch.codePointAt(0);
if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) n++;
}
return n;
}
function canApplyToneTo(s) {
if (!s) return false;
try {
const cp0 = String(s).codePointAt(0);
if (NOT_TONEABLE_BASES.has(cp0)) return false;
} catch (_) {}
if (!containsZWJ(s)) return true;
return countHumans(s) <= 2;
}
function toCodePoints(str) {
const out = [];
for (const ch of String(str || '')) out.push(ch.codePointAt(0));
return out;
}
function fromCodePoints(arr) {
return String.fromCodePoint(...arr);
}
function applyToneSmart(emojiChar, toneChar) {
if (!emojiChar || !toneChar) return emojiChar;
const toneCp = String(toneChar).codePointAt(0);
if (!TONE_CPS.has(toneCp)) return emojiChar;
const cps = toCodePoints(emojiChar);
for (const cp of cps) {
if (TONE_CPS.has(cp)) return emojiChar;
}
const VS16 = 0xFE0F;
const ZWJ = 0x200D;
const idxs = [];
for (let i = 0; i < cps.length; i += 1) {
if (MODIFIABLE_BASES.has(cps[i])) idxs.push(i);
}
if (idxs.length === 0) {
if (cps.includes(ZWJ)) return emojiChar;
let pos = 1;
let rightStart = pos;
if (cps[1] === VS16) {
// Android renders a tone swatch on some sequences when VS16 stays before the modifier.
// Build modifier sequence as base + tone (+ rest), dropping the immediate VS16.
rightStart = 2;
}
const left = cps.slice(0, pos);
left.push(toneCp);
return fromCodePoints(left.concat(cps.slice(rightStart)));
}
if (idxs.length === 2) {
const out = cps.slice();
const insertAfter = (baseIdx) => {
let pos = baseIdx + 1;
if (out[pos] === VS16) out.splice(pos, 1);
out.splice(pos, 0, toneCp);
};
insertAfter(idxs[1]);
insertAfter(idxs[0]);
return fromCodePoints(out);
}
let pos = idxs[0] + 1;
let rightStart = pos;
if (cps[pos] === VS16) {
rightStart = pos + 1;
}
const left = cps.slice(0, pos);
left.push(toneCp);
return fromCodePoints(left.concat(cps.slice(rightStart)));
}
function applyGridDensity(level) {
@@ -650,7 +930,7 @@
source.forEach((emoji) => {
const btn = document.createElement('button');
btn.className = 'w-8 h-8 rounded bg-white/5 hover:bg-white/10 flex items-center justify-center text-lg';
btn.textContent = emoji;
renderEmojiGlyph(btn, emoji);
btn.addEventListener('click', () => window.copyEmoji(emoji));
recentList.appendChild(btn);
});
@@ -724,7 +1004,7 @@
a.href = `/emoji/${encodeURIComponent(fav.slug)}`;
a.className = 'w-8 h-8 rounded bg-white/5 hover:bg-white/10 flex items-center justify-center text-lg';
a.title = fav.name || fav.slug;
a.textContent = fav.emoji;
renderEmojiGlyph(a, fav.emoji);
favoritesList.appendChild(a);
});
}
@@ -884,7 +1164,7 @@
? `
${favoriteActive ? '<span class="absolute top-1.5 left-1.5 z-10 text-yellow-300 text-sm leading-none select-none pointer-events-none" title="Favorited">★</span>' : ''}
<a href="/emoji/${encodeURIComponent(item.slug)}" class="absolute inset-0 flex items-center justify-center pb-10">
<span class="leading-none" style="font-size:var(--emoji-size)">${esc(renderedEmoji)}</span>
<span class="emoji-glyph leading-none" style="font-size:var(--emoji-size)">${esc(renderedEmoji)}</span>
</a>
<div class="emoji-card-bar absolute bottom-0 left-0 right-0 border-t border-white/10 bg-black/20 px-2 py-1.5 flex items-start gap-1">
<span class="emoji-name-clamp text-[10px] text-gray-300 text-left flex-1">${esc(item.name)}</span>
@@ -894,10 +1174,11 @@
: `
${favoriteActive ? '<span class="absolute top-1.5 left-1.5 z-10 text-yellow-300 text-sm leading-none select-none pointer-events-none" title="Favorited">★</span>' : ''}
<a href="/emoji/${encodeURIComponent(item.slug)}" class="absolute inset-0 flex items-center justify-center">
<span class="leading-none" style="font-size:var(--emoji-size)">${esc(renderedEmoji)}</span>
<span class="emoji-glyph leading-none" style="font-size:var(--emoji-size)">${esc(renderedEmoji)}</span>
</a>
<button type="button" class="copy-btn emoji-card-copy absolute top-1.5 right-1.5 w-7 h-7 rounded bg-black/35 hover:bg-brand-ocean/40 border border-white/20 text-[11px] text-gray-100 hover:text-white transition-colors" title="Copy emoji"></button>
`;
renderEmojiGlyph(card.querySelector('.emoji-glyph'), renderedEmoji, { item });
const copyBtn = card.querySelector('.copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', (e) => {

View File

@@ -71,6 +71,7 @@
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preload" href="/assets/fonts/PlusJakartaSans-Regular.ttf" as="font" type="font/ttf" crossorigin>
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/twemoji@14.0.2/dist/twemoji.min.js"></script>
@vite(['resources/js/app.js'])
<script>
tailwind.config = {