diff --git a/manifest.json b/manifest.json index 9c6e1d1..2c64e97 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/panel.html b/panel.html index 7a8ecbf..f523b0c 100644 --- a/panel.html +++ b/panel.html @@ -5,6 +5,16 @@ Emoji Widget + @@ -86,7 +96,12 @@
- Free mode — Pro features locked. + + Free mode — Pro features locked. +
+ 🔓 Upgrade to Pro + +
@@ -107,7 +122,23 @@ + + + +
- + \ No newline at end of file diff --git a/panel.js b/panel.js index 7292669..113e51b 100644 --- a/panel.js +++ b/panel.js @@ -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 = ``; - 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 & non‑toneable 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.' + + '
' + + '🔓 Upgrade to Pro' + + ''; +} + 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 non‑toneable 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 non‑Pro, or non‑toneable, 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 row‑spanning 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 {} })(); diff --git a/styles.css b/styles.css index 3b61e68..cc803bc 100644 --- a/styles.css +++ b/styles.css @@ -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; } @@ -267,4 +267,31 @@ html, body { margin:0; padding:0; background:var(--bg); color:var(--fg); 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; } \ No newline at end of file