feat: add community discussion floating pill widget

Floating pill badge (bottom-right) with pulsing dot, "Join the
Discussion" text, and CTA linking to GitHub Discussions #94.
Shows 15s after initial data load. "Don't show again" persists
dismissal to localStorage (wm-community-dismissed).
This commit is contained in:
Elie Habib
2026-02-18 01:05:24 +04:00
parent 5f5c135e07
commit 6750dbfd08
3 changed files with 144 additions and 24 deletions
+2
View File
@@ -37,6 +37,7 @@ import { buildMapUrl, debounce, loadFromStorage, parseMapUrlState, saveToStorage
import { reverseGeocode } from '@/utils/reverse-geocode';
import { CountryBriefPage } from '@/components/CountryBriefPage';
import { maybeShowDownloadBanner } from '@/components/DownloadBanner';
import { maybeShowCommunityWidget } from '@/components/CommunityWidget';
import { CountryTimeline, type TimelineEvent } from '@/components/CountryTimeline';
import { escapeHtml } from '@/utils/sanitize';
import type { ParsedMapUrlState } from '@/utils';
@@ -3200,6 +3201,7 @@ export class App {
this.allNews = collectedNews;
this.initialLoadComplete = true;
maybeShowDownloadBanner();
maybeShowCommunityWidget();
// Temporal baseline: report news volume
updateAndCheck([
{ type: 'news', region: 'global', count: collectedNews.length },
+49
View File
@@ -0,0 +1,49 @@
const STORAGE_KEY = 'wm-community-dismissed';
const SHOW_DELAY_MS = 15_000;
let scheduled = false;
export function maybeShowCommunityWidget(): void {
if (scheduled) return;
if (localStorage.getItem(STORAGE_KEY)) return;
scheduled = true;
setTimeout(() => {
if (localStorage.getItem(STORAGE_KEY)) return;
const el = build();
document.body.appendChild(el);
requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.add('cw-show'));
});
}, SHOW_DELAY_MS);
}
function dismiss(el: HTMLElement): void {
localStorage.setItem(STORAGE_KEY, '1');
el.classList.remove('cw-show');
el.addEventListener('transitionend', () => el.remove(), { once: true });
}
function close(el: HTMLElement): void {
el.classList.remove('cw-show');
el.addEventListener('transitionend', () => el.remove(), { once: true });
}
function build(): HTMLElement {
const wrap = document.createElement('div');
wrap.className = 'cw-pill-wrap';
wrap.innerHTML = `
<div class="cw-pill">
<div class="cw-dot"></div>
<span class="cw-text">Join the Discussion</span>
<a class="cw-cta" href="https://github.com/koala73/worldmonitor/discussions/94" target="_blank" rel="noopener">Open</a>
<button class="cw-close" aria-label="Close">&times;</button>
</div>
<button class="cw-dismiss">Don't show again</button>
`;
wrap.querySelector('.cw-close')!.addEventListener('click', () => close(wrap));
wrap.querySelector('.cw-dismiss')!.addEventListener('click', () => dismiss(wrap));
return wrap;
}
+93 -24
View File
@@ -1,3 +1,6 @@
@import './lang-switcher.css';
@import './rtl-overrides.css';
/* ============================================================
Theme Colors — overridden by [data-theme="light"] below
============================================================ */
@@ -49,8 +52,14 @@
--map-grid: #0a2a20;
--map-country: #0a2018;
--map-stroke: #0f5040;
/* Font stack */
--font-body: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
}
[dir="rtl"] { --font-body: 'Geeza Pro', 'SF Arabic', 'Tahoma', system-ui, sans-serif; }
:lang(zh-CN), :lang(zh) { --font-body: 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', system-ui, sans-serif; }
/* ============================================================
Semantic Colors — dark-mode defaults, light overrides below
============================================================ */
@@ -169,7 +178,7 @@ html {
}
body {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-family: var(--font-body);
font-size: 12px;
line-height: 1.5;
background: var(--bg);
@@ -9200,29 +9209,6 @@ a.prediction-link:hover {
color: var(--text-dim);
}
.intel-findings-context-menu {
position: fixed;
z-index: 10000;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
padding: 4px 0;
min-width: 200px;
}
.context-menu-item {
padding: 8px 14px;
font-size: 12px;
color: var(--text);
cursor: pointer;
white-space: nowrap;
}
.context-menu-item:hover {
background: var(--bg-hover);
}
/* ===== Tech Events Panel ===== */
.tech-events-panel {
display: flex;
@@ -12417,3 +12403,86 @@ body.has-critical-banner .panels-grid {
.cb-timeline-section { display: none; }
body > *:not(.country-brief-overlay) { display: none !important; }
}
/* Community Widget — Floating Pill */
.cw-pill-wrap {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9000;
opacity: 0;
transform: translateY(10px) scale(0.95);
transition: opacity 0.35s ease, transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
pointer-events: none;
}
.cw-pill-wrap.cw-show {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.cw-pill {
display: flex;
align-items: center;
gap: 10px;
background: var(--panel-bg, #141414);
border: 1px solid var(--border, #2a2a2a);
border-radius: 28px;
padding: 8px 8px 8px 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.cw-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: hsl(229, 48%, 55%);
flex-shrink: 0;
animation: cw-pulse 2s ease-in-out infinite;
}
@keyframes cw-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 hsla(229, 48%, 55%, 0.5); }
50% { opacity: 0.6; box-shadow: 0 0 0 4px hsla(229, 48%, 55%, 0); }
}
.cw-text {
font-size: 13px;
font-weight: 600;
color: var(--text, #e8e8e8);
white-space: nowrap;
}
.cw-cta {
padding: 6px 14px;
background: hsl(229, 48%, 55%);
border: none;
border-radius: 20px;
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
transition: background 0.2s;
}
.cw-cta:hover { background: hsl(229, 48%, 62%); }
.cw-close {
background: none;
border: none;
color: var(--text-ghost, #444);
font-size: 16px;
cursor: pointer;
padding: 4px 6px;
border-radius: 50%;
flex-shrink: 0;
line-height: 1;
transition: color 0.15s, background 0.15s;
}
.cw-close:hover { color: var(--text-dim, #888); background: var(--overlay-light, rgba(255,255,255,0.05)); }
.cw-dismiss {
display: block;
margin: 6px 8px 0 auto;
font-size: 10px;
color: var(--text-ghost, #444);
cursor: pointer;
background: none;
border: none;
transition: color 0.2s;
}
.cw-dismiss:hover { color: var(--text-dim, #888); }