ux: improve skintone interaction & active state behavior

- Remove swatch modal → replaced with inline tone row UX
- Add active card highlight and tone selection row spanning 4 columns
- Apply tone immediately on click (copy/insert according to settings)
- Add tooltip on hover for names (replacing marquee on hover)
- Enable marquee animation only for overflowed text when active
- Integrate forbid & non-toneable filters matching site policy (LGBT & fantasy exclusions)
- Sync tone whitelist (roles like technologist, scientist, firefighter, etc.)
- Sync tone blacklist (e.g. merman, mermaid, deaf man/woman, woman beard, men with bunny ears)
- Add overflow-y hidden toggle when settings modal shown
- Move inline styles to style.css for cleaner structure
- Refactor panel.js for maintainable tone row injection logic
This commit is contained in:
dwindown
2025-10-02 11:27:25 +07:00
parent 6b1efab615
commit d689249f3f
4 changed files with 566 additions and 65 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "Dewemoji - Emojis Made Effortless",
"description": "Find and copy emojis instantly. Optional Pro license unlocks tone lock, insert mode, and more.",
"version": "1.0.0",
"version": "1.0.1",
"offline_enabled": false,
"permissions": [
"storage",

View File

@@ -5,6 +5,16 @@
<title>Emoji Widget</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="styles.css" rel="stylesheet">
<style>
.modal { position: fixed; inset: 0; display:flex; align-items:center; justify-content:center; z-index: 1000; }
.modal[hidden] { display:none; }
.modal-card { background: var(--c-bg, #0b1220); color: var(--c-fg, #e5e7eb); border: 1px solid var(--c-border, rgba(255,255,255,.1)); border-radius: 14px; width: min(420px, 92vw); padding: 16px; box-shadow: 0 20px 50px rgba(0,0,0,.45); }
.theme-light .modal-card { background: #fff; color: #111827; border-color: rgba(0,0,0,.08); }
.modal-head { display:flex; align-items:center; gap:12px; margin-bottom: 8px; }
.modal-logo { width: 28px; height: 28px; border-radius: 6px; }
.modal-body { font-size: 14px; opacity: .9; margin: 8px 0 14px; }
.modal-actions { display:flex; justify-content:flex-end; gap:10px; }
</style>
<script src="vendor/twemoji.min.js"></script>
</head>
<body>
@@ -86,7 +96,12 @@
<div class="row" style="margin-top:6px;">
<button id="license-edit" class="link" style="width: fit-content">Change key</button>
<button id="license-cancel-edit" class="link" style="display:none;width: fit-content">Cancel</button>
<span id="license-status" class="muted">Free mode — Pro features locked.</span>
<span id="license-status" class="muted">
Free mode — Pro features locked.
<br><a href="https://dewemoji.com/pricing" target="_blank" class="cta-link" style="margin-left:6px; text-decoration: underline; font-weight: 500;">
🔓 Upgrade to Pro
</a>
</span>
</div>
</div>
@@ -107,7 +122,23 @@
</aside>
<!-- Branded confirm modal -->
<div id="confirm-backdrop" class="backdrop" hidden></div>
<div id="confirm-modal" class="modal" hidden role="dialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message">
<div class="modal-card">
<div class="modal-head">
<img src="assets/icon-128.png" alt="Dewemoji" class="modal-logo" />
<h3 id="confirm-title">Confirm</h3>
</div>
<div id="confirm-message" class="modal-body">Are you sure?</div>
<div class="modal-actions">
<button id="confirm-cancel" class="btn ghost">Cancel</button>
<button id="confirm-ok" class="btn">OK</button>
</div>
</div>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="panel.js"></script>
<script src="panel.js?ver=1.0.1"></script>
</body>
</html>

565
panel.js
View File

@@ -23,25 +23,85 @@ const licenseDeactivateBtn = document.getElementById('license-deactivate');
const licenseEditBtn = document.getElementById('license-edit');
const licenseCancelEditBtn = document.getElementById('license-cancel-edit');
// --- Branded confirm modal helper ---
function showConfirmModal(opts = {}) {
return new Promise((resolve) => {
const modal = document.getElementById('confirm-modal');
const back = document.getElementById('confirm-backdrop');
const title = document.getElementById('confirm-title');
const msg = document.getElementById('confirm-message');
const okBtn = document.getElementById('confirm-ok');
const noBtn = document.getElementById('confirm-cancel');
if (!modal || !back) return resolve(window.confirm(opts.message || 'Are you sure?'));
title.textContent = opts.title || 'Confirm';
msg.textContent = opts.message || 'Are you sure?';
okBtn.textContent = opts.okText || 'OK';
noBtn.textContent = opts.cancelText || 'Cancel';
function close(val){
modal.setAttribute('hidden','');
back.setAttribute('hidden','');
document.removeEventListener('keydown', onKey);
okBtn.removeEventListener('click', onOk);
noBtn.removeEventListener('click', onNo);
back.removeEventListener('click', onNo);
resolve(val);
}
function onOk(){ close(true); }
function onNo(){ close(false); }
function onKey(e){ if (e.key === 'Escape') close(false); if (e.key === 'Enter') close(true); }
back.removeAttribute('hidden');
modal.removeAttribute('hidden');
document.addEventListener('keydown', onKey);
okBtn.addEventListener('click', onOk);
noBtn.addEventListener('click', onNo);
back.addEventListener('click', onNo);
// focus primary
setTimeout(()=> okBtn.focus(), 0);
});
}
// --- License busy helpers (UI feedback for activate/deactivate) ---
let __licensePrev = { text: '', disabled: false };
let __licensePrev = {
text: '',
activateDisabled: false,
keyDisabled: false,
deactivateDisabled: false,
editDisabled: false,
cancelDisabled: false,
};
function setLicenseBusy(on, label){
const btn = licenseActivateBtn;
if (!btn) return;
if (on) {
// snapshot current states
__licensePrev.text = btn.textContent;
__licensePrev.disabled = btn.disabled;
__licensePrev.activateDisabled = !!btn.disabled;
__licensePrev.keyDisabled = !!(licenseKeyEl && licenseKeyEl.disabled);
__licensePrev.deactivateDisabled = !!(licenseDeactivateBtn && licenseDeactivateBtn.disabled);
__licensePrev.editDisabled = !!(licenseEditBtn && licenseEditBtn.disabled);
__licensePrev.cancelDisabled = !!(licenseCancelEditBtn && licenseCancelEditBtn.disabled);
// set busy states
btn.textContent = label || 'Verifying…';
btn.disabled = true;
licenseKeyEl && (licenseKeyEl.disabled = true);
licenseDeactivateBtn && (licenseDeactivateBtn.disabled = true);
licenseEditBtn && (licenseEditBtn.disabled = true);
licenseCancelEditBtn && (licenseCancelEditBtn.disabled = true);
licenseStatusEl && (licenseStatusEl.textContent = 'Checking license…');
if (licenseKeyEl) licenseKeyEl.disabled = true;
if (licenseDeactivateBtn) licenseDeactivateBtn.disabled = true;
if (licenseEditBtn) licenseEditBtn.disabled = true;
if (licenseCancelEditBtn) licenseCancelEditBtn.disabled = true;
if (licenseStatusEl) licenseStatusEl.textContent = 'Checking license…';
} else {
// restore previous states
btn.textContent = __licensePrev.text || 'Activate';
btn.disabled = __licensePrev.disabled || false;
// Restore enabled state based on current license view
btn.disabled = !!__licensePrev.activateDisabled;
if (licenseKeyEl) licenseKeyEl.disabled = !!__licensePrev.keyDisabled;
if (licenseDeactivateBtn) licenseDeactivateBtn.disabled = !!__licensePrev.deactivateDisabled;
if (licenseEditBtn) licenseEditBtn.disabled = !!__licensePrev.editDisabled;
if (licenseCancelEditBtn) licenseCancelEditBtn.disabled = !!__licensePrev.cancelDisabled;
// Re-apply UI (visibility/display) for current license state
applyLicenseUI();
}
}
@@ -99,8 +159,28 @@ async function loadCategories() {
try {
const res = await fetch(`${API.base}${API.cats}`);
if (res && res.ok) {
CAT_MAP = await res.json();
ok = true;
const data = await res.json();
// Normalize payloads:
// A) New shape: { items: [ { name: 'People & Body', subcategories: ['person','person-gesture'] }, ... ] }
// B) Legacy shape: { 'People & Body': ['person','person-gesture'], ... }
if (data && Array.isArray(data.items)) {
const map = {};
for (const it of data.items) {
if (!it || typeof it.name !== 'string') continue;
const name = it.name;
const subsRaw = Array.isArray(it.subcategories) ? it.subcategories : [];
const subs = subsRaw
.map(s => (typeof s === 'string') ? s : (s && (s.slug || s.name) || ''))
.filter(Boolean);
map[name] = subs;
}
CAT_MAP = map;
} else if (data && typeof data === 'object') {
CAT_MAP = data; // assume already in object-map form
} else {
throw new Error('bad_categories_payload');
}
ok = Object.keys(CAT_MAP || {}).length > 0;
}
} catch (_) {}
@@ -134,12 +214,16 @@ function populateSubcategorySelect(category) {
const subSel = document.getElementById('sub');
if (!subSel) return;
subSel.innerHTML = `<option value="">All subcategories</option>`;
const subs = (CAT_MAP?.[category] || []).slice().sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }));
const raw = (CAT_MAP?.[category] || []).slice();
const subs = raw
.map(s => (typeof s === 'string') ? s : (s && (s.slug || s.name) || ''))
.filter(Boolean)
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }));
subs.forEach(sc => {
const opt = document.createElement('option');
opt.value = sc;
opt.textContent = sc;
subSel.appendChild(opt);
const opt = document.createElement('option');
opt.value = sc;
opt.textContent = sc;
subSel.appendChild(opt);
});
subSel.disabled = subs.length === 0;
subSel.classList.toggle('opacity-50', subs.length === 0);
@@ -284,7 +368,145 @@ const SKIN_TONES = [
const TONE_SLUGS = SKIN_TONES.map(t => t.slug);
const STRIP_TONE_RE = /[\u{1F3FB}-\u{1F3FF}]/gu;
function stripSkinTone(s){ return (s||'').replace(STRIP_TONE_RE,''); }
function withSkinTone(base, ch){ return `${base||''}${ch||''}`; }
// === Policy: forbid list & nontoneable list (mirror of site script.js) ===
const FORBID_NAMES = new Set([
'woman: beard',
'man with veil',
'pregnant man',
'man with bunny ears',
'men with bunny ears',
]);
const NON_TONEABLE_NAMES = new Set([
// Core non-toneable bases
'mechanical arm', 'mechanical leg','anatomical heart','brain','lungs','tooth','bone','eyes','eye','tongue','mouth','biting lips',
// Fantasy subset (problematic gendered variants remain but not toneable)
'genie','man genie','woman genie','zombie','man zombie','woman zombie','troll',
// Activities & gestures we treat as non-toneable
'skier','snowboarder','speaking head','bust in silhouette','busts in silhouette','people hugging','family','footprints','fingerprint',
'people fencing',
// Directional variants we keep but not toneable
'person walking facing right','person running facing right','person kneeling facing right',
// Accessibility
'deaf man','deaf woman',
// Merfolk
'merman','mermaid'
]);
function normName(e){
return String(e?.name || '').trim().toLowerCase();
}
function isForbiddenEntry(e){
const n = normName(e);
return FORBID_NAMES.has(n);
}
function isNonToneableByPolicy(e){
const n = normName(e);
if (!n) return false;
// Families never toneable
if (isFamilyEntry(e)) return true;
// Explicit list
if (NON_TONEABLE_NAMES.has(n)) return true;
// Buckets by subcategory we consider symbol-like (person-symbol) or family
const sc = String(e?.subcategory || '').toLowerCase();
if (sc === 'family' || sc === 'person-symbol') return true;
return false;
}
function isToneableByPolicy(e){
// Must advertise tone support, must not be family, and not on our non-toneable list.
if (!e?.supports_skin_tone) return false;
if (isNonToneableByPolicy(e)) return false;
return true;
}
// --- Tone applicability heuristic ---
function canApplyToneTo(emojiStr){
if (!emojiStr) return false;
const cps = Array.from(emojiStr);
// If any explicit Emoji_Modifier_Base exists, it's safe
try {
for (let i = 0; i < cps.length; i++) {
if (/\p{Emoji_Modifier_Base}/u.test(cps[i])) return true;
}
} catch { /* property escapes may not be supported; fall through */ }
// Fallback heuristic:
// Count human bases (👨 U+1F468, 👩 U+1F469, 🧑 U+1F9D1)
let humanCount = 0;
for (let i = 0; i < cps.length; i++) {
const cp = cps[i].codePointAt(0);
if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) humanCount++;
}
// Allow tones for couples (2 humans) and single-person emojis; block families (3+ humans)
if (humanCount >= 3) return false; // families
if (humanCount === 2) return true; // couples (kiss, couple-with-heart, etc.)
if (humanCount === 1) return true; // single human (ok)
// Non-human (hands etc.) — many support tone, but those have modifier base property
// Without the property present, avoid applying to prevent stray squares
return false;
}
// Helper: Detect multi-human ZWJ sequences
function isMultiHuman(s){
if (!s) return false;
const hasZWJ = /\u200D/.test(s);
if (!hasZWJ) return false;
let count = 0;
for (const ch of Array.from(s)) {
const cp = ch.codePointAt(0);
if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) count++;
}
return count >= 2;
}
// Helper: Detect family entries by metadata
function isFamilyEntry(e){
const n = String(e?.name||'').toLowerCase();
const sc = String(e?.subcategory||'').toLowerCase();
return n.startsWith('family:') || sc === 'family';
}
function withSkinTone(base, ch){
const emojiChar = base || '';
const modifierChar = ch || '';
if (!emojiChar || !modifierChar) return emojiChar;
// Guard: if we heuristically think tone shouldn't be applied, return base
if (!canApplyToneTo(emojiChar)) return emojiChar;
// Split into codepoints (keeps surrogate pairs intact)
const cps = Array.from(emojiChar);
// Try to find the last Emoji_Modifier_Base in the sequence
let lastBaseIdx = -1;
try {
for (let i = 0; i < cps.length; i++) {
if (/\p{Emoji_Modifier_Base}/u.test(cps[i])) lastBaseIdx = i;
}
} catch (_) {
// Property escapes not supported → handled by fallback below
}
// Fallback: if no base detected, treat first human as base (man, woman, person)
if (lastBaseIdx === -1) {
const cp0 = cps[0] ? cps[0].codePointAt(0) : 0;
const HUMAN_BASES = new Set([0x1F468, 0x1F469, 0x1F9D1]); // 👨, 👩, 🧑
if (HUMAN_BASES.has(cp0)) lastBaseIdx = 0;
}
if (lastBaseIdx === -1) return emojiChar; // avoid appending stray square
// Insert after the base (and after VS16 if present)
const afterBase = (idx) => {
if (cps[idx + 1] && cps[idx + 1].codePointAt(0) === 0xFE0F) return idx + 2; // VS16
return idx + 1;
};
const insertPos = afterBase(lastBaseIdx);
const out = cps.slice(0, insertPos).concat([modifierChar], cps.slice(insertPos));
return out.join('');
}
// Tone helpers: keep globals in sync and expose helpers for slug
async function getPreferredToneIndex(){
@@ -795,6 +1017,87 @@ function showToast(text='Done') {
showToast._t = setTimeout(() => toastEl.classList.remove('show'), 1400);
}
// === Tone row UX (row-spanning picker) =========================
const GRID_COLS = 4; // grid is 4 columns in the panel
let __toneRow = null; // the expanded row element (if any)
let __activeCard = null; // the card that owns the expanded row
let __prevOverflowY = '';
function closeToneRow(){
if (__activeCard) {
__activeCard.classList.remove('active');
__activeCard.style.background = '';
__activeCard.style.boxShadow = '';
if (typeof __activeCard.__setNameStatic === 'function') {
__activeCard.__setNameStatic();
}
__activeCard = null;
}
if (__toneRow && __toneRow.parentNode) __toneRow.parentNode.removeChild(__toneRow);
__toneRow = null;
}
function buildToneRow(baseGlyph, activeIdx, onPick){
const row = document.createElement('div');
row.className = 'tone-row';
SKIN_TONES.forEach((t,i)=>{
const btn = document.createElement('button');
btn.className = 'tone-option';
const variant = canApplyToneTo(baseGlyph) ? withSkinTone(baseGlyph, t.ch) : baseGlyph;
const span = document.createElement('span');
ensureRenderableAndGlyph(variant, span);
btn.appendChild(span);
if (i === activeIdx) btn.classList.add('selected');
btn.addEventListener('click', ()=> onPick(i, t, variant));
row.appendChild(btn);
});
return row;
}
function openToneRowFor(card, item, baseGlyph){
// Toggle behavior
if (__activeCard === card) { closeToneRow(); return; }
// Close any previous
closeToneRow();
__activeCard = card;
// Visual highlight for the active owner card
card.classList.add('active');
card.style.background = 'var(--c-chip,#eef2ff)';
card.style.boxShadow = '0 0 0 2px rgba(59,130,246,.65) inset';
if (isToneableByPolicy(item) && typeof card.__setNameMarquee === 'function') {
card.__setNameMarquee();
}
// Decide where to insert: after the end of this 4-col row
const cardsOnly = Array.from(list.querySelectorAll('.card'));
const idx = cardsOnly.indexOf(card);
const rowStart = Math.floor(idx / GRID_COLS) * GRID_COLS;
const rowEnd = Math.min(rowStart + GRID_COLS - 1, cardsOnly.length - 1);
const anchor = cardsOnly[rowEnd] || card;
const prefIdxPromise = getPreferredToneIndex();
(async ()=>{
const currentIdx = await prefIdxPromise;
const base = stripSkinTone(item.emoji_base || baseGlyph);
__toneRow = buildToneRow(base, currentIdx, async (newIdx, tone, variant)=>{
if (toneLock && currentIdx < 0) {
await setPreferredToneIndex(newIdx);
showToast('Preferred tone set');
}
await performEmojiAction(variant);
closeToneRow();
});
anchor.insertAdjacentElement('afterend', __toneRow);
try { __toneRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch(_){}
})();
}
// (obsolete) placeholder; real renderCard defined later with Windows support + Pro tone UI
function renderCard(e) {}
// --- Popover helpers for skin tone picker ---
@@ -823,14 +1126,23 @@ function buildTonePopover(base, activeIdx, onPick){
el.setAttribute('role','dialog');
el.style.cssText = 'background: var(--c-bg, #fff); border: 1px solid var(--c-border, rgba(0,0,0,.12)); padding:6px; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.15); display:flex; gap:6px;';
SKIN_TONES.forEach((t,i) => {
const b = document.createElement('button');
b.className = 'tone-btn';
b.textContent = withSkinTone(base, t.ch);
b.title = t.label;
b.style.cssText = 'min-width:32px;height:32px;border-radius:6px;border:1px solid transparent;display:flex;align-items:center;justify-content:center;background:var(--c-chip,#f3f4f6)';
if (i === activeIdx) b.classList.add('selected');
b.addEventListener('click', () => onPick(i, t));
el.appendChild(b);
const btn = document.createElement('button');
btn.className = 'tone-btn';
btn.title = t.label;
btn.style.cssText = 'min-width:32px;height:32px;border-radius:6px;border:1px solid transparent;display:flex;align-items:center;justify-content:center;background:var(--c-chip,#f3f4f6)';
// Build the toned variant only when applicable; else preview base to avoid squares
const variant = canApplyToneTo(base) ? withSkinTone(base, t.ch) : base;
const span = document.createElement('span');
span.style.display = 'inline-flex';
span.style.alignItems = 'center';
span.style.justifyContent = 'center';
ensureRenderableAndGlyph(variant, span);
btn.appendChild(span);
if (i === activeIdx) btn.classList.add('selected');
btn.addEventListener('click', () => onPick(i, t));
el.appendChild(btn);
});
setTimeout(() => document.addEventListener('click', handleOutsideClose, true), 0);
return el;
@@ -869,6 +1181,15 @@ async function saveSettings() {
await chrome.storage.local.set({ licenseValid, actionMode, licenseKey: licenseKeyEl?.value || '' });
}
// --- Helper to render Free status with CTA ---
function freeStatusHTML(){
return 'Free mode — Pro features locked.' +
'<br><a href="https://dewemoji.com/pricing" target="_blank" class="cta-link"' +
' style="margin-left:6px; text-decoration: underline; font-weight:500;">' +
'🔓 Upgrade to Pro' +
'</a>';
}
function setLicenseEditMode(on) {
if (!licenseKeyEl) return;
const isOn = !!on;
@@ -909,7 +1230,7 @@ function applyLicenseUI() {
// Force visible selection to 'copy' for clarity
const copy = modeGroup.querySelector('input[type="radio"][value="copy"]');
if (copy) copy.checked = true;
licenseStatusEl && (licenseStatusEl.textContent = 'Free mode — Pro features locked.');
licenseStatusEl && (licenseStatusEl.innerHTML = freeStatusHTML());
} else {
// Restore selected mode if Pro; if current actionMode is pro, check it
const target = modeGroup.querySelector(`input[type="radio"][value="${actionMode}"]`) ||
@@ -932,6 +1253,8 @@ function applyLicenseUI() {
if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none';
}
setStatusTag();
// --- PATCH: Refresh tone section after license state changes ---
try { renderToneSettingsSection(); } catch(_) {}
}
function applyModeUI() {
@@ -1023,6 +1346,13 @@ function renderToneSettingsSection(){
pal.innerHTML = '';
const lockEl = sec.querySelector('#tone-lock');
lockEl.checked = !!toneLock;
// Disable lock checkbox and visually dim if license is invalid
lockEl.disabled = !licenseValid;
if (!licenseValid) {
lockEl.parentElement.style.opacity = '0.5';
} else {
lockEl.parentElement.style.opacity = '';
}
lockEl.onchange = async (e)=>{ await setToneLock(!!e.target.checked); showToast(toneLock ? 'Tone lock enabled' : 'Tone lock disabled'); };
const idx = TONE_SLUGS.indexOf(preferredToneSlug);
const basePreview = '✋';
@@ -1033,6 +1363,12 @@ function renderToneSettingsSection(){
b.textContent = basePreview + t.ch; // small preview
b.style.cssText = 'height:32px;border-radius:8px;padding:0 8px;border:1px solid var(--c-border, rgba(0,0,0,.12));background:var(--c-chip,#f3f4f6)';
if (i === idx) b.classList.add('selected');
// Disable tone chips and visually dim if license is invalid
if (!licenseValid) {
b.disabled = true;
b.style.opacity = '0.5';
b.style.pointerEvents = 'none';
}
b.addEventListener('click', async ()=>{
await setPreferredToneIndex(i);
showToast('Preferred tone set');
@@ -1051,10 +1387,24 @@ function renderToneSettingsSection(){
function openSheet() {
sheet.removeAttribute('hidden'); backdrop.removeAttribute('hidden');
requestAnimationFrame(() => { sheet.classList.add('show'); backdrop.classList.add('show'); });
// lock page scroll while sheet is open
try {
__prevOverflowY = document.documentElement.style.overflowY || '';
document.documentElement.style.overflowY = 'hidden';
// also lock body in case the site CSS scrolls body instead of root
document.body && (document.body.style.overflowY = 'hidden');
} catch(_) {}
}
function closeSheet() {
sheet.classList.remove('show'); backdrop.classList.remove('show');
setTimeout(() => { sheet.setAttribute('hidden',''); backdrop.setAttribute('hidden',''); }, 180);
setTimeout(() => {
sheet.setAttribute('hidden',''); backdrop.setAttribute('hidden','');
// restore page scroll after the closing animation completes
try {
document.documentElement.style.overflowY = __prevOverflowY || '';
document.body && (document.body.style.overflowY = '');
} catch(_) {}
}, 180);
}
settingsBtn?.addEventListener('click', openSheet);
@@ -1113,6 +1463,8 @@ licenseActivateBtn?.addEventListener('click', async () => {
applyLicenseUI();
setLicenseEditMode(false);
setStatusTag();
// PATCH: Immediately re-render tone section to enable controls after activation
renderToneSettingsSection();
showToast('License activated ✓');
// Refresh results so Pro headers/tier take effect immediately
@@ -1140,7 +1492,12 @@ licenseCancelEditBtn?.addEventListener('click', async () => {
licenseDeactivateBtn?.addEventListener('click', async () => {
if (!licenseValid) return;
const ok = confirm('Deactivate Pro on this device?');
const ok = await showConfirmModal({
title: 'Deactivate Dewemoji Pro?',
message: 'Deactivate Pro on this device?',
okText: 'Deactivate',
cancelText: 'Cancel'
});
if (!ok) return;
setLicenseBusy(true, 'Deactivating…');
try {
@@ -1155,6 +1512,8 @@ licenseDeactivateBtn?.addEventListener('click', async () => {
await chrome.storage.local.set({ licenseValid: false, licenseKey: '' });
if (licenseKeyEl) licenseKeyEl.value = '';
applyLicenseUI();
// PATCH: Immediately re-render tone section to disable controls after deactivation
renderToneSettingsSection();
licenseKeyCurrent = '';
setLicenseEditMode(false);
showToast('License deactivated');
@@ -1312,60 +1671,125 @@ function renderCard(e) {
const card = document.createElement('div');
card.className = 'card';
card.title = e.name || '';
// Policy: skip forbidden entries entirely
if (isForbiddenEntry(e)) return;
const FAMILY = isFamilyEntry(e);
const emo = document.createElement('div');
emo.className = 'emo';
let baseGlyph = e.emoji || e.symbol || e.text || '';
// Strictly sanitize: families & any nontoneable policy items must not carry tone modifiers
if (FAMILY || !isToneableByPolicy(e) || isMultiHuman(baseGlyph)) {
baseGlyph = stripSkinTone(baseGlyph);
}
const prefIdxPromise = getPreferredToneIndex();
(async () => {
let renderGlyph = baseGlyph;
if (licenseValid && toneLock && e.supports_skin_tone) {
if (licenseValid && toneLock && isToneableByPolicy(e) && !FAMILY) {
const idx = await prefIdxPromise;
if (idx >= 0) {
const base = e.emoji_base || stripSkinTone(baseGlyph);
renderGlyph = withSkinTone(base, SKIN_TONES[idx].ch);
const base = stripSkinTone(e.emoji_base || baseGlyph);
if (isToneableByPolicy(e) && canApplyToneTo(base)) {
renderGlyph = withSkinTone(base, SKIN_TONES[idx].ch);
}
}
}
const res = ensureRenderableAndGlyph(renderGlyph, emo);
baseGlyph = res.glyph;
})();
const name = document.createElement('div');
name.className = 'nm';
name.textContent = e.name || '';
// --- Name (truncated by default; marquee on hover/active) ---
function buildNameStatic(text){
const el = document.createElement('div');
el.className = 'nm';
el.textContent = text;
return el;
}
function buildNameMarquee(text){
const mq = document.createElement('marquee');
mq.className = 'nm';
mq.setAttribute('behavior','scroll');
mq.setAttribute('direction','left');
mq.setAttribute('scrollamount','3');
mq.textContent = text;
return mq;
}
// --- Name: truncated (tooltip on hover); marquee only when .card.active ---
function buildNameStatic(text){
const el = document.createElement('div');
el.className = 'nm';
el.textContent = text;
// tooltip for hover
el.title = text;
return el;
}
function buildNameMarquee(text){
const mq = document.createElement('marquee');
mq.className = 'nm';
mq.setAttribute('behavior','scroll');
mq.setAttribute('direction','left');
mq.setAttribute('scrollamount','3');
mq.setAttribute('truespeed','');
// keep tooltip text
mq.title = text;
mq.textContent = text;
return mq;
}
let nameEl = buildNameStatic(e.name || '');
// Toggle helpers invoked by tone-row open/close
card.__setNameStatic = () => {
if (!nameEl || nameEl.tagName !== 'MARQUEE') return;
const s = buildNameStatic(e.name || '');
nameEl.replaceWith(s);
nameEl = s;
};
card.__setNameMarquee = () => {
// If already marquee, do nothing
if (nameEl && nameEl.tagName === 'MARQUEE') return;
// Ensure we only marquee when the static text overflows its container
const needsMarquee = (() => {
try {
// Force a layout read; the element must be in the DOM
const el = nameEl;
if (!el) return false;
// Small epsilon to avoid float rounding issues
return (el.scrollWidth - el.clientWidth) > 1;
} catch(_) { return false; }
})();
if (!needsMarquee) return; // keep static if it fits
const m = buildNameMarquee(e.name || '');
nameEl.replaceWith(m);
nameEl = m;
// best-effort nudge so it begins smoothly
requestAnimationFrame(() => { try { m.stop && m.stop(); m.start && m.start(); } catch(_){} });
};
// Optional UI cue: dots for toneable entries
if (isToneableByPolicy(e)) {
const dots = document.createElement('div');
dots.className = 'tone-ind';
dots.style.cssText = 'position:absolute;top:6px;right:6px;display:flex;gap:3px;';
const sw = (c)=>{ const d=document.createElement('span'); d.style.cssText=`width:6px;height:6px;border-radius:50%;background:${c};opacity:.9;`; return d; };
dots.append(sw('#f7d7c4')); dots.append(sw('#c68642')); dots.append(sw('#8d5524'));
card.appendChild(dots);
}
card.addEventListener('click', async () => {
if (!licenseValid || !e.supports_skin_tone) {
// For nonPro, or nontoneable, or families → perform action immediately
if (!licenseValid || !isToneableByPolicy(e) || isFamilyEntry(e)) {
performEmojiAction(baseGlyph).catch(console.error);
return;
}
const idx = await prefIdxPromise;
const base = e.emoji_base || stripSkinTone(baseGlyph);
if (toneLock && idx >= 0) {
const variant = withSkinTone(base, SKIN_TONES[idx].ch);
performEmojiAction(variant).catch(console.error);
return;
}
removePopover();
const pop = buildTonePopover(base, idx, async (newIdx, tone) => {
const variant = withSkinTone(base, tone.ch);
if (toneLock && idx < 0) {
await setPreferredToneIndex(newIdx);
showToast('Preferred tone set');
}
performEmojiAction(variant).catch(console.error);
removePopover();
});
showPopoverNear(card, pop);
// Open the rowspanning tone chooser right below this card's row
openToneRowFor(card, e, baseGlyph);
});
card.append(emo, name);
card.append(emo, nameEl);
list.appendChild(card);
}
@@ -1382,10 +1806,20 @@ if (window.twemoji) {
(async function verifyLicenseOnBoot(){
try {
const { licenseKey, lastLicenseCheck } = await chrome.storage.local.get(['licenseKey','lastLicenseCheck']);
const { licenseKey, lastLicenseCheck, licenseValid: storedValid } = await chrome.storage.local.get(['licenseKey','lastLicenseCheck','licenseValid']);
if (!licenseKey) return;
const day = 24*60*60*1000;
// If licenseValid is true and last check is within a day, skip checking and set UI immediately
if (storedValid && (Date.now() - (lastLicenseCheck||0) < day)) {
licenseValid = true;
refreshPageLimit();
applyLicenseUI();
setStatusTag();
licenseStatusEl && (licenseStatusEl.textContent = 'Pro active');
return;
}
// Otherwise, show "Checking license…" while verifying
licenseStatusEl && (licenseStatusEl.textContent = 'Checking license…');
const day = 24*60*60*1000; if (Date.now() - (lastLicenseCheck||0) < day) return;
const acct = await accountId();
const res = await fetch('https://api.dewemoji.com/v1/license/verify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -1394,8 +1828,17 @@ if (window.twemoji) {
const data = await res.json().catch(() => ({}));
const ok = !!data.ok;
await chrome.storage.local.set({ licenseValid: ok, lastLicenseCheck: Date.now() });
licenseValid = ok; refreshPageLimit(); applyLicenseUI(); setStatusTag();
licenseStatusEl && (licenseStatusEl.textContent = licenseValid ? 'Pro active' : 'Free mode — Pro features locked.');
licenseValid = ok;
refreshPageLimit();
applyLicenseUI();
setStatusTag();
if (licenseStatusEl) {
if (licenseValid) {
licenseStatusEl.textContent = 'Pro active';
} else {
licenseStatusEl.innerHTML = freeStatusHTML();
}
}
} catch {}
})();

View File

@@ -1,6 +1,6 @@
:root {
color-scheme: light dark;
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb;
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb; --active: #60a5fa2b;
}
@media (prefers-color-scheme: dark){
:root { --bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151; }
@@ -268,3 +268,30 @@ select#sub {
position: relative;
z-index: 6;
}
div.card.active {
background-color: var(--active)!important;
}
.tone-row {
grid-column: 1 / -1;
background-color: var(--active);
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
border-radius: 12px;
padding: 12px;
display: grid;
grid-template-columns: repeat(5, minmax(0px, 1fr));
gap: 6px;
align-items: center;
}
.tone-option {
height: 56px;
border-radius: 10px;
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
background: var(--c-bg, #f3f4f6);
display: flex;
align-items: center;
justify-content: center;
font-size: x-large;
}