openvibers/openvibe.shared Commits
4.27.0: Notifications 2.0 (themed opaque toasts that never draw over an open menu, grouped and prioritized; System cards for operator alerts; a richer bell) (#158)
Changes
@@@ -6,6 +6,37 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.27.0 — 2026-10-28++++- **Notifications 2.0** (`notification-ui.js`). The owner's screenshot on 10-28: three red "Alert:++ OpenVibeBrowserCheckFailed" toasts drawn over the open menu, translucent and overlapping, a siren emoji, raw++ "run: sudo ovhost …" text, a badge of 67.++ - **Toasts:**++ - an opaque, themed card with a line glyph for the kind of notice (never an emoji), or the sender's avatar with++ the glyph as a badge;++ - a one-line title, up to two lines of text, chips, up to two actions and a timeout bar;++ - phones show one at a time under the bar, with "+N more" inside the card and swipe to dismiss;++ - desktops show up to three at the bottom right.++ - **Layering:**++ - a toast **never draws over an open menu, sheet, panel or dialog**: it waits, timers paused, and shows when the++ layer closes;++ - open layers are read from the page itself (the menu's `is-open`, any bar toggle's `aria-expanded`, a drawn++ `aria-modal`, `dialog[open]`), so a lazily loaded bell still sees a menu opened before it loaded.++ - **Grouping and priority:**++ - the same notice within 15 minutes joins the card that is showing ("Browser check failed ×3" with the sites as++ chips);++ - one notification id is one card, however often a poll or a push brings it;++ - critical first: a full stack lets a lower-priority card go, never a higher one.++ - **Operator alerts** read as System cards: "Browser check failed · Release 80b7955 · 1 failing check", the++ severity, the sites, "Open status", and the fix command behind **Copy fix command** in the bell, never in the++ body.++ - **The bell:**++ - day headers, category chips, unread dots and grouped repeats, plus search;++ - the row's read and dismiss buttons sit on its time line, never over its text, and are always shown on touch++ screens;++ - polling toasts only what is unread and newer than the last thing seen.++- **Budget:** `notification-ui.js` 13 → 20 KB brotli. It loads only after sign-in, when the bell mounts.++## 4.26.1 — 2026-10-28- **No site name twice:**@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.26.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.27.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.26.1",++ "version": "4.27.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -1,8 +1,8 @@// ═══════════════════════════════════════════════════════════════// OpenVibe — Notification UI Client (shared across every OpenVibe site)//--// One store (openvibe.network) → one bell everywhere. This file renders the bell +--// dropdown panel, toasts, a full-page inbox, and handles Web Push subscription.++// One store (openvibe.network) → one bell everywhere. This file renders the bell + its panel, toasts, a full-page++// inbox, and handles Web Push subscription.//// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })@@@ -15,6 +15,18 @@// person's network.notification.created over OpenVibe.Events with a ticket from POST /api/v1/realtime/ticket;// each event or gap re-reads the count. Polling stays: every 15 s without a stream, every 2 min with one.// Writes: POST /:id/read, /read-batch, /read-all, /:id/dismiss, /api/push/{subscribe,unsubscribe}++//++// Notifications 2.0 (2026-10-28, the owner's screenshot of three alert toasts drawn over the open menu):++// • a toast is an opaque, themed card: a line glyph for its category (never an emoji) or the sender's avatar with++// the glyph as a badge, a one-line title, at most two lines of text, chips, up to two actions, a timeout bar;++// • toasts never draw over an open menu, panel or dialog: they wait (timers paused) and show when it closes;++// • phones show one at a time under the bar (with "+N more" and swipe to dismiss), desktops up to three at the++// bottom right;++// • the same notice arriving again within 15 minutes joins the card that is showing (a count, site chips);++// • operator alerts (OPERATOR_ALERT, or rich_content.kind "ops_alert") read as System cards: "Browser check++// failed · events · release 80b7955", a status link, and the fix command behind a copy button in the bell —++// never raw shell text in the body;++// • the bell's list has day headers, category chips, unread dots and grouped repeats.// ═══════════════════════════════════════════════════════════════(function (root) {@@@ -24,14 +36,39 @@const REALTIME_POLL = 120_000; // with an open realtime stream the poll is only a safety net (reads made on other sites)const SELF_SRC = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';const PAGE_SIZE = 30;-- const TOAST_DURATION = { low: 0, normal: 4200, high: 6500, critical: 0 }; // 0 = sticky-- const MAX_TOASTS = 3;++ const TOAST_DURATION = { low: 0, normal: 5200, high: 8000, critical: 0 }; // 0 = sticky++ const MAX_TOASTS = 3; // desktop; phones show one and count the rest++ const GROUP_WINDOW = 15 * 60 * 1000;const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };const CATEGORY_LABELS = {-- social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams', economy: 'Economy',-- achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements',++ stream: 'Live', social: 'Social', chat: 'Chat', game: 'Games', economy: 'Money & deals',++ achievement: 'Achievements', service: 'Services', moderation: 'Moderation', system: 'System', admin: 'Announcements',++ };++ const SERVICE_LABELS = { live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', host: 'Host', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', chat: 'Chat' };++++ // Line glyphs (24×24, stroked like the open-loop mark): one per kind of notice.++ const GLYPH = {++ live: '<circle cx="12" cy="12" r="2.6"/><path d="M6.4 6.4a8 8 0 0 0 0 11.2M17.6 6.4a8 8 0 0 1 0 11.2M9.2 9.2a4 4 0 0 0 0 5.6M14.8 9.2a4 4 0 0 1 0 5.6"/>',++ people: '<circle cx="9" cy="8.5" r="3.2"/><path d="M3.5 19.5a5.5 5.5 0 0 1 11 0"/><circle cx="17.2" cy="9.4" r="2.4"/><path d="M15.6 14.4a4.5 4.5 0 0 1 5.4 4.6"/>',++ chat: '<path d="M4.5 5.5h15v10.5H10l-5.5 4z"/>',++ game: '<path d="M6.5 8.5h11a3.5 3.5 0 0 1 3.5 3.5v2.6a2.9 2.9 0 0 1-5.1 1.9L14.2 14.6H9.8L8.1 16.5A2.9 2.9 0 0 1 3 14.6V12a3.5 3.5 0 0 1 3.5-3.5z"/><path d="M8.3 10.6v3M6.8 12.1h3"/><path d="M15.6 11.4h.01M17.2 12.9h.01"/>',++ coin: '<circle cx="12" cy="12" r="8.2"/><path d="M14.6 9.6c-.6-.9-1.5-1.3-2.6-1.3-1.5 0-2.6.8-2.6 1.9s1.1 1.6 2.6 1.9 2.6.8 2.6 2-1.1 1.9-2.6 1.9c-1.1 0-2.1-.5-2.6-1.3M12 6.6v1.7M12 15.7v1.7"/>',++ tag: '<path d="M4 4.5h7.2l8.8 8.8-6.7 6.7-8.8-8.8z"/><circle cx="8.4" cy="8.9" r="1.3"/>',++ trophy: '<path d="M8 4h8v5a4 4 0 0 1-8 0zM8 6H5.2a3 3 0 0 0 3 3.6M16 6h2.8a3 3 0 0 1-3 3.6M12 13v3.6M8.6 20h6.8"/>',++ shield: '<path d="M12 3.2l7 3v5.2c0 4.3-2.9 7.8-7 9.6-4.1-1.8-7-5.3-7-9.6V6.2z"/>',++ gear: '<circle cx="12" cy="12" r="3"/><path d="M12 3.5v2.2M12 18.3v2.2M3.5 12h2.2M18.3 12h2.2M6 6l1.6 1.6M16.4 16.4L18 18M6 18l1.6-1.6M16.4 7.6L18 6"/>',++ grid: '<rect x="4" y="4" width="6.6" height="6.6" rx="1.6"/><rect x="13.4" y="4" width="6.6" height="6.6" rx="1.6"/><rect x="4" y="13.4" width="6.6" height="6.6" rx="1.6"/><rect x="13.4" y="13.4" width="6.6" height="6.6" rx="1.6"/>',++ pulse: '<path d="M3 12.5h4.2l2.3-5.5 4.2 10.5 2.3-5h4.9"/>',++ check: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',++ bell: '<path d="M6.2 16.2V11a5.8 5.8 0 0 1 11.6 0v5.2l1.6 2H4.6z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>',++ key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',++ };++ const CATEGORY_GLYPH = { stream: 'live', social: 'people', chat: 'chat', game: 'game', economy: 'coin', achievement: 'trophy', moderation: 'shield', system: 'gear', service: 'grid', admin: 'bell' };++ const TONE = {++ live: 'var(--live-red, #e5484d)', danger: 'var(--live-red, #e5484d)', warn: 'var(--warning, #f5a524)', ok: 'var(--success, #30a46c)',++ gold: 'var(--gold, #e3b341)', accent: 'var(--accent, #3b82f6)', muted: 'var(--text-secondary, #a0a0ab)',};-- const SERVICE_LABELS = { live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network' };++ const CATEGORY_TONE = { stream: 'live', social: 'accent', chat: 'accent', game: 'accent', economy: 'gold', achievement: 'gold', moderation: 'warn', system: 'muted', service: 'accent', admin: 'accent' };let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };let _pollTimer = null;@@@ -47,6 +84,8 @@let _lastPoll = 0;let _soon = null; let _soonAll = false;const _views = new Map(); // container → view state (panel + inboxes)++ const _queue = []; // toast cards waiting for a free screen (a menu open, or a phone already showing one)++ let _menuOpen = false; // the OpenVibe menu (search.js) says so through its 'openvibe-menu' event// ── Styles ──────────────────────────────────────────────function injectStyles() {@@@ -54,106 +93,151 @@const style = document.createElement('style');style.id = 'openvibe-notif-styles';style.textContent = `-- @keyframes openvibe-toast-in { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }-- @keyframes openvibe-toast-out { from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } }-- @keyframes openvibe-badge-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.25); } }-- @keyframes openvibe-bell-ring { 0%{ transform: rotate(0); } 10%{ transform: rotate(14deg); } 20%{ transform: rotate(-14deg); } 30%{ transform: rotate(10deg); } 40%{ transform: rotate(-6deg); } 50%{ transform: rotate(0); } }-- @keyframes openvibe-slide-down { from { transform: translateY(-10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }-- @keyframes openvibe-spin { to { transform: rotate(360deg); } }---- .openvibe-toast-container { position: fixed; top: 82px; right: 14px; z-index: 100000; display: flex; flex-direction: column; gap: 6px; pointer-events: none; max-width: 340px; width: 100%; }-- .openvibe-toast { pointer-events: all; background: color-mix(in srgb, var(--bg-card, #22222c) 94%, transparent); border: 1px solid color-mix(in srgb, var(--border, #333340) 82%, transparent); border-radius: 10px; padding: 11px 12px; display: flex; gap: 10px; align-items: flex-start; box-shadow: 0 10px 24px rgba(0,0,0,0.28); animation: openvibe-toast-in .24s cubic-bezier(.34,1.56,.64,1); cursor: pointer; position: relative; overflow: hidden; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); transition: border-color .2s; }-- .openvibe-toast:hover { border-color: var(--accent, #3b82f6); }-- .openvibe-toast.removing { animation: openvibe-toast-out .3s ease forwards; }-- .openvibe-toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1; margin-top: 1px; }-- .openvibe-toast-icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }-- .openvibe-toast-body { flex: 1; min-width: 0; }-- .openvibe-toast-title { font-weight: 600; font-size: 12px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; }-- .openvibe-toast-title .service-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, #60a5fa); font-weight: 500; }-- .openvibe-toast-msg { font-size: 11px; color: var(--text-secondary, #b0b0b8); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }-- .openvibe-toast-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; padding: 2px; line-height: 1; }-- .openvibe-toast-close:hover { color: var(--text-primary, #e0e0e0); }-- .openvibe-toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; background: var(--accent, #3b82f6); }-- .openvibe-toast.priority-high { border-left: 3px solid var(--warning, #f39c12); }-- .openvibe-toast.priority-critical { border-left: 3px solid var(--live-red, #e74c3c); background: rgba(231,76,60,0.06); }-- .openvibe-toast-actions { display: flex; gap: 6px; margin-top: 8px; }-- .openvibe-toast-actions button { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border, #333340); background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); font-size: 11px; font-weight: 600; cursor: pointer; }-- .openvibe-toast-actions button.primary { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); border-color: var(--accent-strong, var(--accent, #3472d8)); }---- .openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; }-- .openvibe-bell svg { width: 22px; height: 22px; fill: var(--text-secondary, #b0b0b8); transition: fill .2s; }-- .openvibe-bell:hover svg, .openvibe-bell.open svg { fill: var(--accent-light, #60a5fa); }-- .openvibe-bell .badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px; background: var(--live-red, #e74c3c); color: #fff; border-radius: 8px; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; line-height: 1; pointer-events: none; animation: openvibe-badge-pulse .4s ease; }++ @keyframes ovn-in-up { from { transform: translateY(14px) scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }++ @keyframes ovn-in-down { from { transform: translateY(-14px) scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }++ @keyframes ovn-out { to { transform: translateX(24px); opacity: 0; } }++ @keyframes ovn-badge-pop { 0%,100% { transform: scale(1); } 50% { transform: scale(1.22); } }++ @keyframes ovn-bell-ring { 0%{ transform: rotate(0); } 12%{ transform: rotate(13deg); } 24%{ transform: rotate(-12deg); } 36%{ transform: rotate(8deg); } 48%{ transform: rotate(-4deg); } 60%{ transform: rotate(0); } }++ @keyframes ovn-panel-in { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } }++ @keyframes ovn-spin { to { transform: rotate(360deg); } }++++ /* Toasts. Opaque cards on the theme's raised surface; held (hidden, timers paused) while a menu is open. */++ .openvibe-toast-container { position: fixed; z-index: 2147482600; display: flex; flex-direction: column; gap: 10px; pointer-events: none;++ right: max(16px, env(safe-area-inset-right, 0px)); bottom: max(16px, env(safe-area-inset-bottom, 0px)); width: min(380px, calc(100vw - 32px));++ font-family: inherit; transition: opacity .18s ease; }++ .openvibe-toast-container.ovn-held { opacity: 0; visibility: hidden; }++ .openvibe-toast-container.ovn-phone { top: var(--ovn-top, 64px); bottom: auto; left: max(8px, env(safe-area-inset-left, 0px)); right: max(8px, env(safe-area-inset-right, 0px)); width: auto; }++ .openvibe-toast { pointer-events: auto; position: relative; overflow: hidden; display: grid; grid-template-columns: 38px 1fr; gap: 12px; align-items: start;++ padding: 12px 34px 12px 12px; border-radius: 16px; cursor: pointer; touch-action: pan-y;++ background: var(--bg-elevated, var(--bg-card, #1f2129)); color: var(--text-primary, #ececf1);++ border: 1px solid var(--border, rgba(255,255,255,.1)); box-shadow: 0 18px 40px rgba(0,0,0,.34), 0 2px 6px rgba(0,0,0,.2);++ animation: ovn-in-up .26s cubic-bezier(.2,.8,.2,1); }++ .ovn-phone .openvibe-toast { animation-name: ovn-in-down; }++ .openvibe-toast::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--ovn-tone, var(--accent, #3b82f6)); }++ .openvibe-toast:hover, .openvibe-toast:focus-visible { border-color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 55%, var(--border, #333)); outline: none; }++ .openvibe-toast.removing { animation: ovn-out .24s ease forwards; }++ .openvibe-toast-body { min-width: 0; }++ .openvibe-toast-title { display: flex; align-items: baseline; gap: 6px; min-width: 0; font-size: 14px; font-weight: 650; line-height: 1.3; }++ .openvibe-toast-title .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .openvibe-toast-title .n { flex: none; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; color: var(--ovn-tone, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 16%, transparent); }++ .openvibe-toast-sub { margin-top: 1px; font-size: 11.5px; color: var(--text-muted, #8b8b96); display: flex; gap: 6px; align-items: center; }++ .openvibe-toast-msg { margin-top: 4px; font-size: 13px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }++ .openvibe-toast-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }++ .openvibe-toast-actions button, .openvibe-toast-actions a { font: inherit; font-size: 12.5px; font-weight: 650; padding: 7px 13px; border-radius: 999px; cursor: pointer; text-decoration: none; line-height: 1;++ border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); }++ .openvibe-toast-actions .primary { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }++ .openvibe-toast-close { position: absolute; top: 8px; right: 8px; width: 26px; height: 26px; border-radius: 50%; border: none; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; font-size: 16px; line-height: 1; }++ .openvibe-toast-close:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }++ .openvibe-toast-progress { position: absolute; left: 0; bottom: 0; height: 2px; width: 100%; transform-origin: left; background: var(--ovn-tone, var(--accent, #3b82f6)); opacity: .7; }++ .openvibe-toast-more { margin-left: auto; flex: none; font: inherit; font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 999px; cursor: pointer;++ border: 1px solid color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 40%, var(--border, #333)); background: transparent; color: var(--text-secondary, #b6b6c0); }++ .openvibe-toast.priority-critical { border-color: color-mix(in srgb, var(--ovn-tone) 60%, var(--border, #333)); }++++ /* The glyph (or avatar with a glyph badge), shared by toasts and the list. */++ .ovn-glyph { position: relative; width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; flex: none;++ color: var(--ovn-tone, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 15%, transparent); }++ .ovn-glyph svg { width: 21px; height: 21px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }++ .ovn-glyph.ovn-avatar { border-radius: 50%; background: var(--bg-tertiary, #2a2b33); }++ .ovn-glyph.ovn-avatar img { width: 38px; height: 38px; border-radius: 50%; object-fit: cover; display: block; }++ .ovn-glyph .ovn-badge { position: absolute; right: -3px; bottom: -3px; width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center;++ background: var(--ovn-tone, var(--accent, #3b82f6)); color: var(--bg-elevated, var(--bg-card, #1f2129)); box-shadow: 0 0 0 2px var(--bg-elevated, var(--bg-card, #1f2129)); }++ .ovn-glyph .ovn-badge svg { width: 11px; height: 11px; stroke-width: 2.4; }++ .ovn-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }++ .ovn-chip { font-size: 11px; font-weight: 600; line-height: 1; padding: 4px 8px; border-radius: 999px; color: var(--text-secondary, #b6b6c0); background: var(--bg-tertiary, rgba(255,255,255,.06)); border: 1px solid var(--border, rgba(255,255,255,.08)); }++ .ovn-chip.sev { color: var(--ovn-tone); background: color-mix(in srgb, var(--ovn-tone) 14%, transparent); border-color: transparent; text-transform: uppercase; letter-spacing: .03em; font-size: 10px; }++++ .openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; }++ .openvibe-bell svg { width: 22px; height: 22px; fill: none; stroke: var(--text-secondary, #b0b0b8); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: stroke .2s; }++ .openvibe-bell:hover svg, .openvibe-bell.open svg { stroke: var(--accent-light, var(--accent, #60a5fa)); }++ .openvibe-bell:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++ .openvibe-bell .badge { position: absolute; top: 1px; right: 0; min-width: 17px; height: 17px; padding: 0 5px; box-sizing: border-box; background: var(--live-red, #e5484d); color: #fff; border-radius: 999px;++ font-size: 10px; font-weight: 750; display: flex; align-items: center; justify-content: center; line-height: 1; pointer-events: none; box-shadow: 0 0 0 2px var(--bg-primary, #111);++ font-variant-numeric: tabular-nums; animation: ovn-badge-pop .36s ease; }.openvibe-bell .badge.hidden { display: none; }-- .openvibe-bell.ringing svg { animation: openvibe-bell-ring .6s ease; }++ .openvibe-bell.ringing svg { animation: ovn-bell-ring .7s ease; transform-origin: 50% 15%; }-- .openvibe-notif-panel { position: fixed; top: 62px; right: 16px; width: 400px; max-height: calc(100vh - 86px); background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5)); z-index: 99999; display: none; flex-direction: column; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); animation: openvibe-slide-down .2s ease; overflow: hidden; }++ /* The panel (desktop popover, phone sheet) and the full inbox share one list view. */++ .openvibe-notif-panel { position: fixed; z-index: 99999; display: none; flex-direction: column; overflow: hidden;++ top: var(--ovn-top, 62px); right: max(8px, env(safe-area-inset-right, 0px)); width: min(420px, calc(100vw - 16px));++ max-height: calc(100vh - var(--ovn-top, 62px) - 12px); max-height: calc(100dvh - var(--ovn-top, 62px) - 12px - env(safe-area-inset-bottom, 0px));++ background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));++ border-radius: 18px; box-shadow: 0 24px 60px rgba(0,0,0,.45); animation: ovn-panel-in .2s cubic-bezier(.2,.8,.2,1); font-family: inherit; }.openvibe-notif-panel.open { display: flex; }-- .openvibe-notif-inbox { display: flex; flex-direction: column; background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; overflow: hidden; color: var(--text-primary, #e0e0e0); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; min-height: 420px; max-height: 78vh; }++ .openvibe-notif-inbox { display: flex; flex-direction: column; overflow: hidden; min-height: 420px; max-height: 78vh; border-radius: 18px;++ background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1)); font-family: inherit; }-- .openvibe-nv-head { padding: 12px 14px; border-bottom: 1px solid var(--border, #333340); display: flex; align-items: center; justify-content: space-between; gap: 8px; }-- .openvibe-nv-head h3 { font-size: 15px; font-weight: 600; margin: 0; display: flex; align-items: center; gap: 8px; }-- .openvibe-nv-head h3 .count { font-size: 11px; font-weight: 600; background: var(--live-red, #e74c3c); color: #fff; border-radius: 9px; padding: 1px 7px; }++ .openvibe-nv-head { padding: 14px 14px 10px 16px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }++ .openvibe-nv-head h3 { font-size: 17px; font-weight: 750; letter-spacing: -.01em; margin: 0; display: flex; align-items: center; gap: 8px; }++ .openvibe-nv-head h3 .count { font-size: 11px; font-weight: 750; background: var(--live-red, #e5484d); color: #fff; border-radius: 999px; padding: 2px 7px; font-variant-numeric: tabular-nums; }.openvibe-nv-head h3 .count.zero { display: none; }-- .openvibe-nv-actions { display: flex; gap: 4px; align-items: center; }-- .openvibe-nv-actions button, .openvibe-nv-actions a { background: none; border: 1px solid transparent; color: var(--text-muted, #707080); cursor: pointer; font-size: 12px; padding: 5px 8px; border-radius: 6px; text-decoration: none; display: inline-flex; align-items: center; gap: 5px; line-height: 1; }-- .openvibe-nv-actions button:hover, .openvibe-nv-actions a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }-- .openvibe-nv-actions button.on { color: var(--accent-light, #60a5fa); border-color: color-mix(in srgb, var(--accent, #3b82f6) 40%, transparent); }-- .openvibe-nv-actions button:disabled { opacity: .45; cursor: default; }-- .openvibe-nv-actions svg { width: 14px; height: 14px; fill: currentColor; }---- .openvibe-nv-tools { padding: 8px 12px; border-bottom: 1px solid var(--border, #333340); display: flex; flex-direction: column; gap: 8px; }++ .openvibe-nv-actions { display: flex; gap: 2px; align-items: center; }++ .openvibe-nv-actions button, .openvibe-nv-actions a { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; border: none; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; text-decoration: none; }++ .openvibe-nv-actions button > span, .openvibe-nv-actions a > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }++ .openvibe-nv-actions button:hover, .openvibe-nv-actions a:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }++ .openvibe-nv-actions button.on { color: var(--accent-light, var(--accent, #60a5fa)); }++ .openvibe-nv-actions button:disabled { opacity: .4; cursor: default; }++ .openvibe-nv-actions svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }++++ .openvibe-nv-tools { padding: 0 12px 10px; display: flex; flex-direction: column; gap: 10px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }.openvibe-nv-search { position: relative; }-- .openvibe-nv-search input { width: 100%; box-sizing: border-box; padding: 7px 28px 7px 30px; border-radius: 8px; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-primary, #e0e0e0); font-size: 12px; outline: none; }++ .openvibe-nv-search input { width: 100%; box-sizing: border-box; padding: 9px 30px 9px 34px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: var(--bg-input, var(--bg-tertiary, rgba(255,255,255,.04))); color: var(--text-primary, #ececf1); font: inherit; font-size: 13px; outline: none; }.openvibe-nv-search input:focus { border-color: var(--accent, #3b82f6); }-- .openvibe-nv-search svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; fill: var(--text-muted, #707080); pointer-events: none; }-- .openvibe-nv-search .clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; display: none; }++ .openvibe-nv-search > svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; fill: none; stroke: var(--text-muted, #8b8b96); stroke-width: 1.8; stroke-linecap: round; pointer-events: none; }++ .openvibe-nv-search .clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted, #8b8b96); cursor: pointer; font-size: 16px; display: none; }.openvibe-nv-search.has-q .clear { display: block; }-- .openvibe-nv-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }-- .openvibe-nv-chips button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 10px; border-radius: 999px; cursor: pointer; line-height: 1.2; }-- .openvibe-nv-chips button:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e0e0e0); }-- .openvibe-nv-chips button.active { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); border-color: var(--accent, #3b82f6); color: var(--accent-light, #60a5fa); }-- .openvibe-nv-chips select { margin-left: auto; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 8px; border-radius: 8px; cursor: pointer; }---- .openvibe-nv-list { flex: 1; overflow-y: auto; padding: 4px 0; overscroll-behavior: contain; }-- .openvibe-notif-item { padding: 10px 12px 10px 14px; display: flex; gap: 10px; align-items: flex-start; cursor: pointer; transition: background .15s; border-left: 3px solid transparent; position: relative; }-- .openvibe-notif-item:hover { background: var(--bg-hover, #2f2f3d); }-- .openvibe-notif-item.unread { background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); border-left-color: var(--accent, #3b82f6); }-- .openvibe-notif-item .icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; width: 32px; text-align: center; }-- .openvibe-notif-item .icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }-- .openvibe-notif-item .content { flex: 1; min-width: 0; }-- .openvibe-notif-item .title { font-size: 13px; font-weight: 500; margin-bottom: 2px; padding-right: 18px; }-- .openvibe-notif-item.unread .title { font-weight: 650; }-- .openvibe-notif-item .msg { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }-- .openvibe-notif-item .meta { font-size: 10px; color: var(--text-muted, #707080); margin-top: 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }-- .openvibe-notif-item .meta .tag { padding: 1px 5px; border-radius: 3px; background: var(--bg-tertiary, #2a2a38); font-weight: 500; }-- .openvibe-notif-item .dismiss { position: absolute; top: 8px; right: 8px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 15px; line-height: 1; padding: 2px 4px; border-radius: 4px; opacity: 0; transition: opacity .15s; }-- .openvibe-notif-item:hover .dismiss { opacity: 1; }-- .openvibe-notif-item .dismiss:hover { color: var(--live-red-text, var(--live-red, #e74c3c)); background: var(--bg-tertiary, #2a2a38); }-- .openvibe-nv-empty { text-align: center; padding: 44px 24px; color: var(--text-muted, #707080); font-size: 13px; }-- .openvibe-nv-empty .icon { font-size: 34px; margin-bottom: 8px; display: block; opacity: .5; }-- .openvibe-nv-more { display: flex; justify-content: center; padding: 10px; }-- .openvibe-nv-more button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 12px; padding: 6px 14px; border-radius: 8px; cursor: pointer; }-- .openvibe-nv-more .spinner { width: 16px; height: 16px; border: 2px solid var(--border, #333340); border-top-color: var(--accent, #3b82f6); border-radius: 50%; animation: openvibe-spin .8s linear infinite; }-- .openvibe-nv-foot { padding: 8px 12px; border-top: 1px solid var(--border, #333340); font-size: 11px; color: var(--text-muted, #707080); display: flex; justify-content: space-between; align-items: center; gap: 8px; }-- .openvibe-nv-foot a { color: var(--accent-light, #60a5fa); text-decoration: none; }-- .openvibe-nv-note { font-size: 11px; color: var(--text-muted, #707080); padding: 6px 12px; background: color-mix(in srgb, var(--warning, #f39c12) 10%, transparent); border-bottom: 1px solid var(--border, #333340); }---- /* The panel hangs under whatever navbar the site has (its top is measured when it opens) and never-- leaves the visible viewport: dvh instead of vh, room for the home indicator, list scrolls inside. */-- .openvibe-notif-panel { top: var(--ovn-top, 62px); right: max(8px, env(safe-area-inset-right, 0px)); width: min(420px, calc(100vw - 16px));-- max-height: calc(100vh - var(--ovn-top, 62px) - 12px); max-height: calc(100dvh - var(--ovn-top, 62px) - 12px - env(safe-area-inset-bottom, 0px)); border-radius: 16px; }-- .openvibe-notif-item .title .count { display: inline-block; margin-left: 4px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); vertical-align: 1px; }-- /* Phones and narrow windows: a sheet from edge to edge under the bar, down to the bottom inset. */++ .openvibe-nv-chips { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; margin: 0 -12px; padding: 0 12px; }++ .openvibe-nv-chips::-webkit-scrollbar { display: none; }++ .openvibe-nv-chips button { flex: none; border: 1px solid var(--border, rgba(255,255,255,.1)); background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12.5px; font-weight: 600; padding: 6px 12px; border-radius: 999px; cursor: pointer; line-height: 1.1; }++ .openvibe-nv-chips button:hover { color: var(--text-primary, #ececf1); border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }++ .openvibe-nv-chips button.active { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }++++ .openvibe-nv-list { flex: 1; overflow-y: auto; padding: 2px 0 6px; overscroll-behavior: contain; }++ .openvibe-nv-day { position: sticky; top: 0; z-index: 1; padding: 10px 16px 6px; font-size: 11.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #8b8b96); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }++ .openvibe-notif-item { display: grid; grid-template-columns: 38px 1fr; gap: 12px; padding: 10px 40px 10px 14px; margin: 0 6px; border-radius: 14px; cursor: pointer; position: relative; transition: background .15s; }++ .openvibe-notif-item:hover { background: var(--bg-hover, rgba(255,255,255,.05)); }++ .openvibe-notif-item.unread { background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); }++ .openvibe-notif-item .content { min-width: 0; }++ .openvibe-notif-item .title { font-size: 13.5px; font-weight: 550; line-height: 1.32; display: flex; align-items: baseline; gap: 6px; }++ .openvibe-notif-item .title .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }++ .openvibe-notif-item.unread .title { font-weight: 700; }++ .openvibe-notif-item .title .count { flex: none; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; color: var(--ovn-tone, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 15%, transparent); }++ .openvibe-notif-item .msg { margin-top: 2px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); line-height: 1.42; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }++ .openvibe-notif-item .meta { font-size: 11.5px; color: var(--text-muted, #8b8b96); margin-top: 5px; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }++ .openvibe-notif-item .meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .6; }++ .openvibe-notif-item .unread-dot { position: absolute; right: 16px; top: 16px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent, #3b82f6); }++ .openvibe-notif-item .row-actions { margin-left: auto; display: flex; gap: 2px; opacity: 0; transition: opacity .15s; }++ .openvibe-notif-item:hover .row-actions, .openvibe-notif-item:focus-within .row-actions { opacity: 1; }++ /* The row's buttons sit on its time line, never over its text; touch screens always show them. */++ @media (hover: none), (pointer: coarse) { .openvibe-notif-item .row-actions { opacity: 1; } }++ .openvibe-notif-item:hover .unread-dot { opacity: 0; }++ .openvibe-notif-item .row-actions button { width: 28px; height: 28px; border-radius: 8px; border: none; background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }++ .openvibe-notif-item .row-actions button:hover { color: var(--text-primary, #ececf1); background: var(--bg-hover, rgba(255,255,255,.08)); }++ .openvibe-notif-item .row-actions svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }++ .openvibe-notif-item .copy { font: inherit; font-size: 11.5px; font-weight: 650; margin-top: 7px; padding: 5px 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; }++ .openvibe-notif-item .copy:hover { color: var(--text-primary, #ececf1); }++ .openvibe-nv-empty { text-align: center; padding: 48px 24px 56px; color: var(--text-muted, #8b8b96); font-size: 13.5px; }++ .openvibe-nv-empty svg { width: 64px; height: 64px; display: block; margin: 0 auto 12px; fill: none; stroke: var(--accent, #3b82f6); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; opacity: .75; }++ .openvibe-nv-empty b { display: block; font-size: 15px; color: var(--text-primary, #ececf1); margin-bottom: 4px; }++ .openvibe-nv-more { display: flex; justify-content: center; padding: 12px; }++ .openvibe-nv-more button { border: 1px solid var(--border, rgba(255,255,255,.1)); background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12.5px; padding: 7px 16px; border-radius: 999px; cursor: pointer; }++ .openvibe-nv-more .spinner { width: 18px; height: 18px; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); border-radius: 50%; animation: ovn-spin .8s linear infinite; }++ .openvibe-nv-foot { padding: 10px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #8b8b96); display: flex; justify-content: space-between; align-items: center; gap: 8px; }++ .openvibe-nv-foot a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; font-weight: 650; }++ .openvibe-nv-note { font-size: 12px; color: var(--text-secondary, #b6b6c0); padding: 8px 16px; background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }++++ /* Phones: the panel is a sheet from under the bar to the bottom inset, edge to edge. */@media (max-width: 640px) {-- .openvibe-notif-panel { left: 8px; right: 8px; width: auto; bottom: calc(8px + env(safe-area-inset-bottom, 0px)); max-height: none; }-- .openvibe-notif-panel [data-act] > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } /* icon-only actions, labels stay for screen readers */-- .openvibe-notif-panel [data-act] { min-width: 36px; min-height: 36px; justify-content: center; }-- .openvibe-notif-item { padding-top: 10px; padding-bottom: 10px; }++ .openvibe-notif-panel { left: 6px; right: 6px; width: auto; bottom: calc(6px + env(safe-area-inset-bottom, 0px)); max-height: none; border-radius: 20px; }++ .openvibe-notif-item .row-actions { opacity: 1; }++ .openvibe-notif-item .unread-dot { display: none; }++ .openvibe-notif-item.unread .title .t::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); margin-right: 6px; vertical-align: 1px; }}-- @media (max-height: 520px) { .openvibe-notif-item .msg { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; } }-- @media (max-width: 460px) {-- .openvibe-toast-container { right: 8px; left: 8px; max-width: none; }++ @media (max-height: 520px) { .openvibe-notif-item .msg { -webkit-line-clamp: 1; } }++ @media (prefers-reduced-motion: reduce) {++ .openvibe-toast, .ovn-phone .openvibe-toast, .openvibe-notif-panel { animation: none; }++ .openvibe-toast.removing { animation: none; opacity: 0; }++ .openvibe-bell.ringing svg, .openvibe-bell .badge { animation: none; }++ .openvibe-toast-progress { display: none; }}`;document.head.appendChild(style);@@@ -183,21 +267,26 @@const d = parseDate(v); if (!d) return '';const diff = (Date.now() - d.getTime()) / 1000;if (diff < 45) return 'just now';-- if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))}m ago`;-- if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;-- if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;++ if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))} min ago`;++ if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;++ if (diff < 604800) return `${Math.floor(diff / 86400)} d ago`;return d.toLocaleDateString();}++ /** "Today", "Yesterday", a weekday this week, else the date: the list's day headers. */++ function dayLabel(v) {++ const d = parseDate(v); if (!d) return 'Earlier';++ const start = new Date(); start.setHours(0, 0, 0, 0);++ const day = new Date(d.getTime()); day.setHours(0, 0, 0, 0);++ const days = Math.round((start - day) / 86400000);++ if (days <= 0) return 'Today';++ if (days === 1) return 'Yesterday';++ if (days < 7) { try { return d.toLocaleDateString(undefined, { weekday: 'long' }); } catch { return 'This week'; } }++ try { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { return 'Earlier'; }++ }function inboxUrl() {if (_config.inboxUrl) return _config.inboxUrl;try { return new URL('/notifications', _config.apiBase).toString(); } catch { return 'https://openvibe.network/notifications'; }}-- function iconHtml(n) {-- const rich = richOf(n);-- const img = n.sender_avatar || rich.thumbnail || (rich.user && rich.user.avatar_url);-- if (img && /^https?:\/\//.test(String(img))) return `<img src="${esc(img)}" alt="" loading="lazy" onerror="this.replaceWith(document.createTextNode('${esc(n.icon || '🔔')}'))">`;-- return esc(n.icon || '🔔');-- }function navigateTo(url) {if (!url) return;try {@@@ -205,6 +294,79 @@window.location.href = u.toString();} catch { window.location.href = url; }}++ const svg = (paths) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;++ function isPhone() {++ try { return !!(root.matchMedia && root.matchMedia('(max-width: 640px)').matches); } catch { return false; }++ }++++ // ── What a notification says (one presentation for toasts and the list) ─────++ /** OpenVibeBrowserCheckFailed → "Browser check failed". */++ function humanize(name) {++ const s = String(name || '').replace(/^OpenVibe(?=[A-Z])/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim();++ return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Alert';++ }++ function isOpsAlert(n, rich) { return !!n && (n.type === 'OPERATOR_ALERT' || rich.kind === 'ops_alert'); }++ /**++ * An operator alert, from its structured fields (rich_content.kind "ops_alert": name, state, severity, sites,++ * release, summary, command) or, for older notices, read out of its title and message.++ */++ function presentOps(n, rich) {++ const title = String(n.title || '');++ const m = /^(Alert|Still firing|Resolved):\s*(.+)$/i.exec(title);++ const state = rich.state || (m ? (m[1].toLowerCase() === 'resolved' ? 'resolved' : (m[1].toLowerCase() === 'still firing' ? 'reminded' : 'firing')) : 'firing');++ const name = rich.name || (m ? m[2] : title);++ const raw = String(n.message || '');++ const command = rich.command || ((/run:\s*(sudo [^;]+?)(?:\.\s|;|\.$|$)/.exec(raw) || [])[1]) || null;++ const sites = Array.isArray(rich.sites) && rich.sites.length ? rich.sites.slice(0, 6)++ : [...new Set(((raw.match(/(?:^|\.\s+)([a-z0-9][a-z0-9.-]{1,30}):\s/g) || []).map(s => s.replace(/^\.\s+/, '').replace(/:\s$/, ''))))].slice(0, 6);++ const release = rich.release || ((/release ([0-9a-f]{7,40})/i.exec(raw) || [])[1] || null);++ const failing = (/\((\d+) failing check/i.exec(raw) || [])[1];++ let summary = rich.summary || raw++ .replace(/run:\s*sudo [^;]+?(?:\.\s|;|\.$|$)/g, '')++ .replace(/Firing since [^.]+\.?/g, '')++ .replace(/(?:^|\.\s+)[a-z0-9][a-z0-9.-]{1,30}:\s/g, ' ')++ .replace(/\s+/g, ' ').trim();++ if (release || failing) summary = [release ? `Release ${String(release).slice(0, 7)}` : null, failing ? `${failing} failing check${failing === '1' ? '' : 's'}` : null].filter(Boolean).join(' · ');++ const severity = rich.severity || (n.priority === 'critical' ? 'critical' : n.priority === 'high' ? 'warning' : 'info');++ const tone = state === 'resolved' ? 'ok' : severity === 'critical' ? 'danger' : 'warn';++ return {++ kind: 'ops', key: `ops:${name}:${state === 'resolved' ? 'resolved' : 'open'}`, tone, glyph: state === 'resolved' ? 'check' : 'pulse',++ title: (state === 'resolved' ? 'Resolved: ' : state === 'reminded' ? 'Still failing: ' : '') + humanize(name),++ message: summary.slice(0, 220), chips: sites, severity: state === 'resolved' ? 'resolved' : severity, command,++ label: 'System', url: rich.runbook_url || urlOf(n), actionLabel: 'Open status',++ };++ }++ function present(n) {++ const rich = richOf(n);++ if (isOpsAlert(n, rich)) return presentOps(n, rich);++ const cat = n.category || 'system';++ const t = String(n.type || '');++ const glyph = /DEAL|COUPON|PRICE|TRADE/.test(t) ? 'tag' : /CONFIRM|APPROV|SECUR|SIGN/.test(t) ? 'key' : (CATEGORY_GLYPH[cat] || 'bell');++ const tone = n.priority === 'critical' ? 'danger' : (CATEGORY_TONE[cat] || 'accent');++ const chips = Array.isArray(rich.chips) ? rich.chips.filter(c => typeof c === 'string').slice(0, 4) : [];++ return {++ kind: 'plain', key: [t, n.title || '', n.service || '', cat].join('\u0001'), tone, glyph,++ title: String(n.title || ''), message: String(n.message || ''), chips, severity: null, command: null,++ label: SERVICE_LABELS[n.service] || (n.service && n.service !== 'network' ? n.service : '') || CATEGORY_LABELS[cat] || '',++ url: urlOf(n), actionLabel: null,++ };++ }++ function avatarOf(n) {++ const rich = richOf(n);++ const img = n.sender_avatar || rich.thumbnail || (rich.user && rich.user.avatar_url);++ return img && /^https?:\/\//.test(String(img)) ? String(img) : null;++ }++ function glyphHtml(n, p) {++ const tone = TONE[p.tone] || TONE.accent;++ const img = p.kind === 'ops' ? null : avatarOf(n);++ if (img) return `<span class="ovn-glyph ovn-avatar" style="--ovn-tone:${tone}"><img src="${esc(img)}" alt="" loading="lazy" onerror="this.remove()"><span class="ovn-badge">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span></span>`;++ return `<span class="ovn-glyph" style="--ovn-tone:${tone}">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span>`;++ }++ function chipsHtml(p, extra = []) {++ const sev = p.severity ? [`<span class="ovn-chip sev" style="--ovn-tone:${TONE[p.tone] || TONE.accent}">${esc(p.severity)}</span>`] : [];++ const all = sev.concat((p.chips || []).concat(extra).map(c => `<span class="ovn-chip">${esc(c)}</span>`));++ return all.length ? `<div class="ovn-chips">${all.join('')}</div>` : '';++ }// ── API ─────────────────────────────────────────────────function isCrossOrigin() {@@@ -251,59 +413,233 @@} catch { /* */ }}++ // ── Layers: toasts never draw over an open menu, panel or dialog ─────────++ // What counts as open, read from the page itself: this file loads late (after the bell mounts), so an++ // 'openvibe-menu' event fired before it is missed; the menu's own class and every bar toggle's aria-expanded are not.++ const OPEN_LAYERS = '.ovs.is-open, .openvibe-navbar [aria-expanded="true"], .openvibe-notif-panel.open, dialog[open], [aria-modal="true"]:not([hidden])';++ function layerBusy() {++ if (_menuOpen) return true;++ try { if (root.OpenVibePanels && typeof root.OpenVibePanels.anyOpen === 'function' && root.OpenVibePanels.anyOpen()) return true; } catch { /* */ }++ if (document.body && document.body.classList && document.body.classList.contains('ov-panel-open')) return true;++ // Only what is drawn counts: a closed menu may keep aria-modal while it sits at display: none.++ try { for (const el of document.querySelectorAll(OPEN_LAYERS)) if (el.getClientRects().length) return true; } catch { /* */ }++ return false;++ }++ let _layerWatch = false;++ function watchLayers() {++ if (_layerWatch || typeof document === 'undefined') return;++ _layerWatch = true;++ document.addEventListener('openvibe-menu', (e) => { _menuOpen = !!(e && e.detail && e.detail.open); syncLayer(); });++ try {++ // aria-expanded and open change only when something opens or closes, so watching them page-wide is cheap.++ if (typeof root.MutationObserver === 'function' && document.body) new root.MutationObserver(() => syncLayer()).observe(document.body, { attributes: true, subtree: true, attributeFilter: ['aria-expanded', 'open', 'aria-modal'] });++ if (typeof root.MutationObserver === 'function' && document.body) new root.MutationObserver(() => syncLayer()).observe(document.body, { attributes: true, attributeFilter: ['class'] });++ } catch { /* */ }++ // Without an observer, any click may have opened or closed something: check right after it.++ document.addEventListener('click', () => Promise.resolve().then(syncLayer), true);++ }++ function syncLayer() {++ if (!_toastContainer) return;++ const busy = layerBusy();++ const was = _toastContainer.classList.contains('ovn-held');++ if (busy === was) return;++ _toastContainer.classList.toggle('ovn-held', busy);++ for (const el of _toastContainer.querySelectorAll('.openvibe-toast')) busy ? pauseToast(el) : resumeToast(el);++ if (!busy) flushQueue();++ }++// ── Toasts ──────────────────────────────────────────────function ensureToastContainer() {if (_toastContainer) return;_toastContainer = document.createElement('div');_toastContainer.className = 'openvibe-toast-container';++ _toastContainer.setAttribute('role', 'region');++ _toastContainer.setAttribute('aria-label', 'Notifications');++ _toastContainer.setAttribute('aria-live', 'polite');document.body.appendChild(_toastContainer);++ watchLayers();}++ function placeContainer() {++ const phone = isPhone();++ _toastContainer.classList.toggle('ovn-phone', phone);++ if (phone) {++ // Under whatever bar the site has; above it when the bar is hidden on scroll.++ const bar = document.querySelector('nav.openvibe-navbar, nav.navbar, header.navbar, .navbar');++ const r = bar && bar.getBoundingClientRect ? bar.getBoundingClientRect() : null;++ const top = r && r.bottom > 0 ? Math.round(r.bottom) + 8 : 10;++ _toastContainer.style.setProperty('--ovn-top', top + 'px');++ }++ }++ function visibleToasts() { return _toastContainer ? [..._toastContainer.querySelectorAll('.openvibe-toast:not(.removing)')] : []; }++ function maxVisible() { return isPhone() ? 1 : MAX_TOASTS; }++++ // Urgent first: a full stack makes room by letting a lower-priority card go, never a higher one.++ const RANK = { critical: 3, high: 2, normal: 1, low: 0 };++ const rank = (n) => RANK[(n && n.priority) || 'normal'] ?? 1;++ const _shownIds = new Set(); // one notification id is one card, however many times a poll or a push brings itfunction showToast(n) {if (!_preferences.toasts) return;if (_preferences.muted_categories.includes(n.category)) return;++ const id = n && n.id != null ? String(n.id) : null;++ if (id) {++ if (_shownIds.has(id)) return;++ _shownIds.add(id);++ if (_shownIds.size > 500) _shownIds.delete(_shownIds.values().next().value);++ }ensureToastContainer();-- while (_toastContainer.children.length >= MAX_TOASTS) _toastContainer.firstChild.remove();-- const rich = richOf(n);++ const p = present(n);++ // The same notice again within the window joins the card already showing (or waiting).++ const now = Date.now();++ const live = visibleToasts().find(el => el.__ovn && el.__ovn.key === p.key && now - el.__ovn.at < GROUP_WINDOW);++ if (live) { mergeToast(live, n, p); return; }++ const queued = _queue.find(q => q.p.key === p.key);++ if (queued) { queued.count += 1; queued.n = rank(n) >= rank(queued.n) ? n : queued.n; queued.chips = [...new Set(queued.chips.concat(p.chips))]; return; }++ _queue.push({ n, p, count: 1, chips: p.chips.slice() });++ _queue.sort((a, b) => rank(b.n) - rank(a.n)); // stable: arrival order within a priority++ flushQueue();++ }++ function flushQueue() {++ if (!_toastContainer || layerBusy()) { if (_toastContainer) _toastContainer.classList.toggle('ovn-held', layerBusy()); updateMore(); return; }++ placeContainer();++ while (_queue.length) {++ const vis = visibleToasts();++ if (vis.length < maxVisible()) { renderToast(_queue.shift()); continue; }++ const lowest = vis.reduce((a, el) => (rank(el.__ovn.n) < rank(a.__ovn.n) ? el : a), vis[0]);++ if (rank(_queue[0].n) > rank(lowest.__ovn.n)) { dismissToast(lowest); continue; }++ break;++ }++ updateMore();++ }++ /** "+N more" lives inside the card that is showing (a phone shows one at a time), never floating over the page. */++ function updateMore() {++ if (!_toastContainer) return;++ const waiting = _queue.length;++ const first = visibleToasts()[0];++ _toastContainer.querySelectorAll('.openvibe-toast-more').forEach((m) => { if (!waiting || !isPhone() || !first || !first.contains(m)) m.remove(); });++ if (!waiting || !isPhone() || !first) return;++ let more = first.querySelector('.openvibe-toast-more');++ if (!more) {++ more = document.createElement('button');++ more.type = 'button'; more.className = 'openvibe-toast-more';++ more.addEventListener('click', (e) => { e.stopPropagation(); const f = visibleToasts()[0]; if (f) dismissToast(f); else flushQueue(); });++ const sub = first.querySelector('.openvibe-toast-sub');++ (sub || first.querySelector('.openvibe-toast-body') || first).appendChild(more);++ }++ more.textContent = `+${waiting} more`;++ more.setAttribute('aria-label', `${waiting} more notification${waiting === 1 ? '' : 's'}: show the next`);++ }++ function renderToast(q) {++ const { n, p } = q;const toast = document.createElement('div');toast.className = `openvibe-toast priority-${esc(n.priority || 'normal')}`;toast.dataset.notifId = n.id || '';-- const serviceLabel = n.service && n.service !== 'network' ? `<span class="service-badge">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';-- const actions = Array.isArray(rich.actions) ? rich.actions.filter(a => a && a.type !== 'input') : [];++ toast.tabIndex = 0;++ toast.style.setProperty('--ovn-tone', TONE[p.tone] || TONE.accent);++ if (n.priority === 'critical') toast.setAttribute('role', 'alert');++ toast.__ovn = { key: p.key, at: Date.now(), count: q.count, chips: q.chips.slice(), ids: n.id ? [n.id] : [], n };++ const rich = richOf(n);++ const custom = Array.isArray(rich.actions) ? rich.actions.filter(a => a && a.type !== 'input' && a.label).slice(0, 2) : [];++ const actions = custom.length ? custom : (p.url ? [{ id: 'open', label: p.actionLabel || 'View', url: p.url, style: 'primary' }] : []);toast.innerHTML = `-- <div class="openvibe-toast-icon">${iconHtml(n)}</div>++ ${glyphHtml(n, p)}<div class="openvibe-toast-body">-- <div class="openvibe-toast-title">${esc(n.title)}${serviceLabel}</div>-- <div class="openvibe-toast-msg">${esc(n.message || '')}</div>-- ${actions.length ? `<div class="openvibe-toast-actions">${actions.map(a => `<button data-action="${esc(a.id)}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</button>`).join('')}</div>` : ''}++ <div class="openvibe-toast-title"><span class="t">${esc(p.title)}</span><span class="n" data-role="n"${q.count > 1 ? '' : ' hidden'}>×${q.count}</span></div>++ <div class="openvibe-toast-sub">${esc(p.label || '')}${p.label ? '<span aria-hidden="true">·</span>' : ''}<span>now</span></div>++ ${p.message ? `<div class="openvibe-toast-msg" data-role="msg">${esc(p.message)}</div>` : ''}++ <div data-role="chips">${chipsHtml({ ...p, chips: q.chips })}</div>++ ${actions.length ? `<div class="openvibe-toast-actions">${actions.map(a => a.url++ ? `<a href="${esc(a.url)}" data-action="${esc(a.id || 'open')}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</a>`++ : `<button type="button" data-action="${esc(a.id)}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</button>`).join('')}</div>` : ''}</div>-- <button class="openvibe-toast-close" aria-label="Dismiss">×</button>`;-- const duration = TOAST_DURATION[n.priority] ?? 5000;++ <button type="button" class="openvibe-toast-close" aria-label="Dismiss">×</button>`;++ const duration = TOAST_DURATION[n.priority] ?? 5200;++ toast.__ovnDuration = duration;if (duration > 0) {const bar = document.createElement('div');-- bar.className = 'openvibe-toast-progress'; bar.style.width = '100%'; bar.style.transition = `width ${duration}ms linear`;++ bar.className = 'openvibe-toast-progress';toast.appendChild(bar);-- requestAnimationFrame(() => { bar.style.width = '0%'; });-- setTimeout(() => dismissToast(toast), duration);++ startTimer(toast, duration);}toast.querySelector('.openvibe-toast-close').addEventListener('click', (e) => { e.stopPropagation(); dismissToast(toast); });-- toast.addEventListener('click', () => {-- if (n.id) markRead([n.id]);++ toast.addEventListener('click', (e) => {++ if (e.target.closest && e.target.closest('[data-action]')) return;++ const ids = toast.__ovn.ids; if (ids.length) markRead(ids);dismissToast(toast);-- const u = urlOf(n); if (u) navigateTo(u);++ if (p.url) navigateTo(p.url);});++ toast.addEventListener('keydown', (e) => { if (e.key === 'Escape') dismissToast(toast); });toast.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', (e) => {e.stopPropagation();-- const a = actions.find(x => String(x.id) === btn.dataset.action);-- if (a && a.url) { navigateTo(a.url); return; }++ const ids = toast.__ovn.ids; if (ids.length) markRead(ids);++ const a = actions.find(x => String(x.id || 'open') === btn.dataset.action);++ if (a && a.url) { e.preventDefault(); dismissToast(toast); navigateTo(a.url); return; }if (_config.onAction) _config.onAction(n.id, btn.dataset.action, n);dismissToast(toast);}));++ // Hover or focus holds the timer; a swipe sideways (or up, on a phone) dismisses.++ toast.addEventListener('mouseenter', () => pauseToast(toast));++ toast.addEventListener('mouseleave', () => resumeToast(toast));++ toast.addEventListener('focusin', () => pauseToast(toast));++ toast.addEventListener('focusout', () => resumeToast(toast));++ attachSwipe(toast);_toastContainer.appendChild(toast);}++ function mergeToast(el, n, p) {++ const st = el.__ovn;++ if (n.id && st.ids.includes(n.id)) return;++ st.count += 1; st.at = Date.now(); st.n = rank(n) >= rank(st.n) ? n : st.n;++ if (n.id) st.ids.push(n.id);++ st.chips = [...new Set(st.chips.concat(p.chips))].slice(0, 8);++ const c = el.querySelector('[data-role="n"]'); if (c) { c.textContent = `×${st.count}`; c.hidden = false; }++ const m = el.querySelector('[data-role="msg"]'); if (m && p.message) m.textContent = p.message;++ const ch = el.querySelector('[data-role="chips"]'); if (ch) ch.innerHTML = chipsHtml({ ...p, chips: st.chips });++ if (el.__ovnDuration > 0) startTimer(el, el.__ovnDuration);++ }++ function startTimer(el, ms) {++ clearTimeout(el.__ovnTimer);++ el.__ovnRemaining = ms; el.__ovnStarted = Date.now();++ const bar = el.querySelector('.openvibe-toast-progress');++ if (bar) { bar.style.transition = 'none'; bar.style.transform = 'scaleX(1)'; const go = () => { bar.style.transition = `transform ${ms}ms linear`; bar.style.transform = 'scaleX(0)'; }; (root.requestAnimationFrame || ((f) => f()))(go); }++ if (_toastContainer && _toastContainer.classList.contains('ovn-held')) { pauseToast(el); return; }++ el.__ovnTimer = setTimeout(() => dismissToast(el), ms);++ }++ function pauseToast(el) {++ if (!el || !el.__ovnTimer) return;++ clearTimeout(el.__ovnTimer); el.__ovnTimer = null;++ el.__ovnRemaining = Math.max(800, (el.__ovnRemaining || 0) - (Date.now() - (el.__ovnStarted || Date.now())));++ const bar = el.querySelector('.openvibe-toast-progress');++ if (bar && root.getComputedStyle) { try { const t = root.getComputedStyle(bar).transform; bar.style.transition = 'none'; bar.style.transform = t && t !== 'none' ? t : 'scaleX(1)'; } catch { /* */ } }++ }++ function resumeToast(el) {++ if (!el || el.__ovnTimer || !(el.__ovnDuration > 0) || el.classList.contains('removing')) return;++ if (_toastContainer && _toastContainer.classList.contains('ovn-held')) return;++ startTimer(el, el.__ovnRemaining || el.__ovnDuration);++ }++ function attachSwipe(el) {++ if (typeof root.PointerEvent !== 'function') return;++ let x0 = null, y0 = null, dx = 0, dy = 0;++ el.addEventListener('pointerdown', (e) => { x0 = e.clientX; y0 = e.clientY; dx = dy = 0; });++ el.addEventListener('pointermove', (e) => {++ if (x0 == null) return;++ dx = e.clientX - x0; dy = e.clientY - y0;++ if (Math.abs(dx) > 8 || dy < -8) { el.style.transition = 'none'; el.style.transform = Math.abs(dx) >= Math.abs(dy) ? `translateX(${dx}px)` : `translateY(${Math.min(0, dy)}px)`; el.style.opacity = String(Math.max(0.2, 1 - Math.max(Math.abs(dx), -dy) / 220)); }++ });++ const end = () => {++ if (x0 == null) return;++ const gone = Math.abs(dx) > 90 || (isPhone() && dy < -40);++ x0 = y0 = null;++ if (gone) { el.__ovnSwiped = true; dismissToast(el); return; }++ el.style.transition = 'transform .2s ease, opacity .2s ease'; el.style.transform = ''; el.style.opacity = '';++ };++ el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);++ // A swipe is not a click on the card.++ el.addEventListener('click', (e) => { if (el.__ovnSwiped) { e.stopImmediatePropagation(); e.preventDefault(); } }, true);++ }function dismissToast(el) {-- if (!el || !el.parentNode) return;++ if (!el || !el.parentNode || el.classList.contains('removing')) return;++ clearTimeout(el.__ovnTimer);el.classList.add('removing');-- setTimeout(() => el.remove(), 300);++ setTimeout(() => { el.remove(); flushQueue(); }, 240);}// ── Bell / badge ────────────────────────────────────────@@@ -311,7 +647,7 @@const bell = document.createElement('div');bell.className = 'openvibe-bell';bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;-- bell.innerHTML = `<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg><span class="badge hidden">0</span>`;++ bell.innerHTML = `${svg(GLYPH.bell)}<span class="badge hidden">0</span>`;bell.addEventListener('click', () => togglePanel());bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });_bellEl = bell;@@@ -325,8 +661,9 @@if (_unreadCount > 0) {badge.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount);badge.classList.remove('hidden');-- if (!quiet && _unreadCount > prev) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 600); }++ if (!quiet && _unreadCount > prev) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 700); }} else badge.classList.add('hidden');++ _bellEl.setAttribute('aria-label', _unreadCount ? `Notifications, ${_unreadCount} unread` : 'Notifications');}for (const v of _views.values()) {const c = v.root.querySelector('.openvibe-nv-head .count');@@@ -336,37 +673,42 @@}// ── List views (panel + inbox share this) ───────────────-- const SVG = {-- search: '<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>',-- check: '<svg viewBox="0 0 24 24"><path d="M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z"/></svg>',-- bellOn: '<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>',-- bellOff: '<svg viewBox="0 0 24 24"><path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.42v5l-2 2v1h13.73l2 2L21 19.72l-1-1.03zM12 22c1.11 0 2-.89 2-2h-4c0 1.11.89 2 2 2zm6-7.32V11c0-3.08-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.08-.42.12-.1.03-.2.07-.3.11h-.01c-.01 0-.01 0-.02.01-.23.09-.46.2-.68.31 0 0-.01 0-.01.01L18 14.68z"/></svg>',-- gear: '<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>',-- open: '<svg viewBox="0 0 24 24"><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>',++ const ICON = {++ search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',++ checkAll: '<path d="M2.5 12.5l4.5 4.5L17 7"/><path d="M11.5 16.5l.5.5L22 7"/>',++ bellOn: GLYPH.bell,++ bellOff: '<path d="M6.2 16.2V11a5.8 5.8 0 0 1 9.6-4.4M17.8 10.4v5.8l1.6 2H6"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0M3.5 3.5l17 17"/>',++ gear: GLYPH.gear,++ open: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10"/>',++ read: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',++ close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',++ empty: '<path d="M18 37V26a14 14 0 0 1 28 0v11l4 5H14z"/><path d="M27 49a5 5 0 0 0 10 0"/><path d="M50 14l4-4M53 22h5M44 8l1-5"/>',};function buildView(root, { mode }) {const v = { root, mode, tab: 'all', category: '', q: '', items: [], offset: 0, hasMore: false, loading: false, seq: 0, ids: new Set() };++ const cats = ['stream', 'social', 'chat', 'economy', 'game', 'achievement', 'system', 'admin'];root.innerHTML = `<div class="openvibe-nv-head"><h3>Notifications <span class="count zero">0</span></h3><div class="openvibe-nv-actions">-- <button type="button" data-act="read-all" title="Mark all as read">${SVG.check}<span>Mark all read</span></button>-- <button type="button" data-act="push" title="Desktop / mobile push alerts on this device">${SVG.bellOff}</button>-- ${mode === 'panel' ? `<a href="${esc(inboxUrl())}" data-act="inbox" title="Open inbox">${SVG.open}</a>` : `<a href="${esc(inboxUrl().replace(/\/notifications$/, '/notifications#prefs'))}" data-act="prefs" title="Notification preferences">${SVG.gear}</a>`}++ <button type="button" data-act="read-all" title="Mark all as read">${svg(ICON.checkAll)}<span>Mark all read</span></button>++ <button type="button" data-act="push" title="Push alerts on this device">${svg(ICON.bellOff)}<span>Push alerts</span></button>++ <a href="${esc(inboxUrl().replace(/\/notifications$/, '/notifications#prefs'))}" data-act="prefs" title="Notification settings">${svg(ICON.gear)}<span>Settings</span></a>++ ${mode === 'panel' ? `<a href="${esc(inboxUrl())}" data-act="inbox" title="Open the full inbox">${svg(ICON.open)}<span>Open inbox</span></a>` : ''}</div></div><div class="openvibe-nv-note" data-role="note" style="display:none"></div><div class="openvibe-nv-tools">-- <div class="openvibe-nv-search">${SVG.search}<input type="search" placeholder="Search notifications…" autocomplete="off" spellcheck="false"><button type="button" class="clear" aria-label="Clear search">×</button></div>-- <div class="openvibe-nv-chips">-- <button type="button" data-tab="all" class="active">All</button>-- <button type="button" data-tab="unread">Unread</button>-- <select data-role="category"><option value="">All categories</option>${Object.entries(CATEGORY_LABELS).map(([k, l]) => `<option value="${k}">${l}</option>`).join('')}</select>++ <div class="openvibe-nv-search">${svg(ICON.search)}<input type="search" placeholder="Search notifications" aria-label="Search notifications" autocomplete="off" spellcheck="false"><button type="button" class="clear" aria-label="Clear search">×</button></div>++ <div class="openvibe-nv-chips" role="tablist" aria-label="Show">++ <button type="button" role="tab" data-tab="all" class="active" aria-selected="true">All</button>++ <button type="button" role="tab" data-tab="unread" aria-selected="false">Unread</button>++ ${cats.map(k => `<button type="button" role="tab" data-cat="${k}" aria-selected="false">${esc(CATEGORY_LABELS[k])}</button>`).join('')}</div></div><div class="openvibe-nv-list" data-role="list"></div>-- <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'panel' ? `<a href="${esc(inboxUrl())}">Open full inbox →</a>` : ''}</div>`;++ <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'panel' ? `<a href="${esc(inboxUrl())}">Full inbox →</a>` : ''}</div>`;const list = root.querySelector('[data-role="list"]');const input = root.querySelector('input[type="search"]');let qTimer = null;@@@ -375,11 +717,13 @@clearTimeout(qTimer); qTimer = setTimeout(() => { v.q = input.value.trim(); reload(v); }, 260);});root.querySelector('.openvibe-nv-search .clear').addEventListener('click', () => { input.value = ''; v.q = ''; root.querySelector('.openvibe-nv-search').classList.remove('has-q'); reload(v); });-- root.querySelectorAll('[data-tab]').forEach(btn => btn.addEventListener('click', () => {-- root.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active')); btn.classList.add('active');-- v.tab = btn.dataset.tab; reload(v);++ const chips = root.querySelectorAll('.openvibe-nv-chips button');++ const select = (btn) => { chips.forEach(b => { b.classList.toggle('active', b === btn); b.setAttribute('aria-selected', String(b === btn)); }); };++ chips.forEach(btn => btn.addEventListener('click', () => {++ select(btn);++ if (btn.dataset.cat) { v.tab = 'all'; v.category = btn.dataset.cat; } else { v.tab = btn.dataset.tab; v.category = ''; }++ reload(v);}));-- root.querySelector('[data-role="category"]').addEventListener('change', (e) => { v.category = e.target.value; reload(v); });root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));// Infinite scroll: load the next page when the user nears the bottom.@@@ -388,50 +732,75 @@if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);});list.addEventListener('click', (e) => {-- const dismissBtn = e.target.closest('.dismiss');const item = e.target.closest('.openvibe-notif-item');if (!item) return;const id = item.dataset.id;const ids = (item.dataset.ids || id).split(',').filter(Boolean);-- if (dismissBtn) { e.stopPropagation(); ids.forEach(x => dismiss(v, x)); return; }++ const act = e.target.closest('[data-row]');++ if (act) {++ e.stopPropagation();++ if (act.dataset.row === 'dismiss') ids.forEach(x => dismiss(v, x));++ else if (act.dataset.row === 'read') markRead(ids);++ else if (act.dataset.row === 'copy') copyText(act.dataset.cmd || '', act);++ return;++ }const n = v.items.find(x => String(x.id) === id);const unreadIds = ids.filter(x => { const it = v.items.find(y => String(y.id) === x); return it && !isRead(it); });if (unreadIds.length) markRead(unreadIds);-- const u = urlOf(n);++ const u = n && present(n).url;if (u) navigateTo(u);});refreshPushButton(v);_views.set(root, v);return v;}++ function copyText(text, btn) {++ const done = () => { if (btn) { const was = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = was; }, 1400); } };++ try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, () => {}); return; } } catch { /* */ }++ done();++ }function renderItems(v, { append = false } = {}) {const list = v.root.querySelector('[data-role="list"]');-- // The same event repeated back to back ("X is live!" six times) reads as one row with a count.++ // The same notice repeated back to back ("X is live!" six times; three browser checks failing) reads as one++ // row with a count and the sites it covers.const groups = [];for (const n of v.items) {-- const key = [n.title, n.message || '', n.service || '', n.category || ''].join('\u0001');++ const p = present(n);const g = groups[groups.length - 1];-- if (g && g.key === key) { g.ids.push(String(n.id)); if (!isRead(n)) g.unread = true; } else groups.push({ key, n, ids: [String(n.id)], unread: !isRead(n) });++ if (g && g.p.key === p.key) {++ g.ids.push(String(n.id)); if (!isRead(n)) g.unread = true;++ g.chips = [...new Set(g.chips.concat(p.chips))].slice(0, 8);++ if (!g.cmd && p.command) g.cmd = p.command;++ } else groups.push({ n, p, ids: [String(n.id)], unread: !isRead(n), chips: p.chips.slice(), cmd: p.command });}-- const html = groups.map(({ n, ids, unread }) => {++ let lastDay = append ? v.lastDay : null;++ const html = groups.map(({ n, p, ids, unread, chips, cmd }) => {try {-- const cat = CATEGORY_LABELS[n.category] || n.category || '';-- const service = n.service && n.service !== 'network' ? `<span class="tag">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';-- return `<div class="openvibe-notif-item ${unread ? 'unread' : ''}" data-id="${esc(n.id)}" data-ids="${esc(ids.join(','))}" title="${esc(urlOf(n) ? 'Open' : '')}">-- <span class="icon">${iconHtml(n)}</span>++ const day = dayLabel(n.created_at);++ const head = day !== lastDay ? `<div class="openvibe-nv-day">${esc(day)}</div>` : '';++ lastDay = day;++ const when = esc(timeAgo(n.created_at));++ const label = p.label ? `<span class="dot"></span><span>${esc(p.label)}</span>` : '';++ return `${head}<div class="openvibe-notif-item ${unread ? 'unread' : ''}" data-id="${esc(n.id)}" data-ids="${esc(ids.join(','))}" style="--ovn-tone:${TONE[p.tone] || TONE.accent}" tabindex="0">++ ${glyphHtml(n, p)}<div class="content">-- <div class="title">${esc(n.title)}${ids.length > 1 ? ` <span class="count" title="${ids.length} times">×${ids.length}</span>` : ''}</div>-- ${n.message ? `<div class="msg">${esc(n.message)}</div>` : ''}-- <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${esc(timeAgo(n.created_at))}</span>${service}${cat ? `<span class="tag">${esc(cat)}</span>` : ''}</div>++ <div class="title"><span class="t">${esc(p.title)}</span>${ids.length > 1 ? `<span class="count" title="${ids.length} times">×${ids.length}</span>` : ''}</div>++ ${p.message ? `<div class="msg">${esc(p.message)}</div>` : ''}++ ${chipsHtml({ ...p, chips })}++ ${cmd ? `<button type="button" class="copy" data-row="copy" data-cmd="${esc(cmd)}">Copy fix command</button>` : ''}++ <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${when}</span>${label}<div class="row-actions">${unread ? `<button type="button" data-row="read" title="Mark as read" aria-label="Mark as read">${svg(ICON.read)}</button>` : ''}<button type="button" data-row="dismiss" title="Dismiss" aria-label="Dismiss">${svg(ICON.close)}</button></div></div></div>-- <button type="button" class="dismiss" title="Dismiss" aria-label="Dismiss">×</button>++ ${unread ? '<span class="unread-dot" aria-label="Unread"></span>' : ''}</div>`;} catch { return ''; }}).join('');++ v.lastDay = lastDay;if (!append) list.innerHTML = '';if (!v.items.length) {-- list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">${v.q ? '🔎' : '🔔'}</span>${v.q ? 'Nothing matches that search' : (v.tab === 'unread' ? 'You\'re all caught up' : 'No notifications yet')}</div>`;++ const title = v.q ? 'Nothing matches that search' : (v.tab === 'unread' ? 'You\'re all caught up' : (v.category ? 'Nothing here yet' : 'No notifications yet'));++ const sub = v.q ? 'Try other words.' : 'When something happens across OpenVibe, it lands here.';++ list.innerHTML = `<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${esc(title)}</b>${esc(sub)}</div>`;} else {const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();list.insertAdjacentHTML('beforeend', html);@@@ -451,7 +820,7 @@}async function reload(v) {const seq = ++v.seq;-- v.loading = true; v.offset = 0; v.items = []; v.ids = new Set(); v.hasMore = false; v.total = null;++ v.loading = true; v.offset = 0; v.items = []; v.ids = new Set(); v.hasMore = false; v.total = null; v.lastDay = null;const list = v.root.querySelector('[data-role="list"]');list.innerHTML = '<div class="openvibe-nv-more"><span class="spinner"></span></div>';try {@@@ -462,7 +831,7 @@renderItems(v);} catch (e) {if (seq !== v.seq) return;-- list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">⚠️</span>${e.status === 401 ? 'Sign in to see your notifications' : 'Could not load notifications'}</div>`;++ list.innerHTML = `<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${e.status === 401 ? 'Sign in to see your notifications' : 'Could not load notifications'}</b>${e.status === 401 ? '' : 'Check your connection and try again.'}</div>`;} finally { if (seq === v.seq) v.loading = false; }}async function loadMore(v) {@@@ -477,7 +846,7 @@v.items = v.items.concat(fresh); v.hasMore = !!data.has_more; v.offset += (data.notifications || []).length; v.total = data.total ?? v.total;// Re-render only the tail to keep the scroll position.const old = v.root.querySelector('.openvibe-nv-more'); if (old) old.remove();-- if (fresh.length) renderItems({ ...v, items: fresh }, { append: true });++ if (fresh.length) { const tail = { ...v, items: fresh }; renderItems(tail, { append: true }); v.lastDay = tail.lastDay; }else if (v.hasMore) { const list = v.root.querySelector('[data-role="list"]'); list.insertAdjacentHTML('beforeend', '<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>'); list.querySelector('[data-act="more"]').addEventListener('click', () => loadMore(v)); }const st = v.root.querySelector('[data-role="status"]');if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.tab === 'unread' ? ' unread' : ''}` : '';@@@ -487,7 +856,10 @@function applyReadLocally(v, ids, all) {const set = new Set((ids || []).map(String));for (const n of v.items) if (all || set.has(String(n.id))) n.is_read = 1;-- v.root.querySelectorAll('.openvibe-notif-item.unread').forEach(el => { if (all || set.has(el.dataset.id)) el.classList.remove('unread'); });++ v.root.querySelectorAll('.openvibe-notif-item.unread').forEach(el => {++ const elIds = (el.dataset.ids || el.dataset.id || '').split(',');++ if (all || elIds.some(x => set.has(x))) { el.classList.remove('unread'); el.querySelector('.unread-dot')?.remove(); el.querySelector('[data-row="read"]')?.remove(); }++ });}// ── Mutations ───────────────────────────────────────────@@@ -513,7 +885,12 @@async function dismiss(v, id) {const n = v.items.find(x => String(x.id) === String(id));v.items = v.items.filter(x => String(x.id) !== String(id));-- v.root.querySelector(`.openvibe-notif-item[data-id="${CSS.escape(String(id))}"]`)?.remove();++ const row = v.root.querySelector(`.openvibe-notif-item[data-id="${CSS.escape(String(id))}"]`);++ if (row) {++ const rest = (row.dataset.ids || '').split(',').filter(x => x && x !== String(id));++ if (rest.length) { row.dataset.ids = rest.join(','); row.dataset.id = rest[0]; const c = row.querySelector('.title .count'); if (c) { if (rest.length > 1) c.textContent = `×${rest.length}`; else c.remove(); } }++ else row.remove();++ }if (v.total != null) v.total = Math.max(0, v.total - 1);if (!v.items.length) renderItems(v);try {@@@ -537,7 +914,9 @@// Something new arrived: fetch what's newer than the last thing we saw.const params = new URLSearchParams({ unread_only: '1', limit: '5', since: _lastSeenAt });const nd = await apiJson(`/api/notifications?${params}`);-- const fresh = (nd.notifications || []).slice().reverse();++ // Only what is unread and newer than the last thing seen, whatever the server answered.++ const since = _lastSeenAt;++ const fresh = (nd.notifications || []).filter(n => n && !n.is_read && (!since || String(n.created_at || '') > since)).slice().reverse();for (const n of fresh) { showToast(n); playSound(n.priority); }// Any open list should reflect the new items too.for (const v of _views.values()) if (v.mode === 'inbox' || (_panelEl && _panelEl.classList.contains('open'))) reload(v);@@@ -610,6 +989,7 @@function createPanel() {const panel = document.createElement('div');panel.className = 'openvibe-notif-panel';++ panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Notifications');document.body.appendChild(panel);buildView(panel, { mode: 'panel' });document.addEventListener('click', (e) => {@@@ -619,7 +999,7 @@_panelEl = panel;return panel;}-- function closePanel() { _panelEl?.classList.remove('open'); _bellEl?.classList.remove('open'); }++ function closePanel() { _panelEl?.classList.remove('open'); _bellEl?.classList.remove('open'); syncLayer(); }function togglePanel() {if (!_panelEl) createPanel();const opening = !_panelEl.classList.contains('open');@@@ -633,6 +1013,8 @@}_panelEl.classList.toggle('open', opening);_bellEl?.classList.toggle('open', opening);++ _bellEl?.setAttribute('aria-expanded', String(opening));++ syncLayer();if (opening) { reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread(); }}@@@ -685,8 +1067,8 @@if (!st.supported) { btn.style.display = 'none'; return; }btn.style.display = '';btn.classList.toggle('on', st.subscribed);-- btn.innerHTML = st.subscribed ? `${SVG.bellOn}<span>Push on</span>` : `${SVG.bellOff}<span>Enable push</span>`;-- btn.title = st.subscribed ? 'Push alerts are on for this device — click to turn off' : (st.permission === 'denied' ? 'Notifications are blocked in your browser settings' : 'Get desktop / mobile alerts on this device even when the tab is closed');++ btn.innerHTML = st.subscribed ? `${svg(ICON.bellOn)}<span>Push on</span>` : `${svg(ICON.bellOff)}<span>Enable push</span>`;++ btn.title = st.subscribed ? 'Push alerts are on for this device: select to turn off' : (st.permission === 'denied' ? 'Notifications are blocked in your browser settings' : 'Get alerts on this device even when the site is closed');btn.disabled = st.permission === 'denied' && !st.subscribed;}async function togglePush(v) {@@@ -694,8 +1076,8 @@const say = (msg, ms = 4000) => { if (!note) return; note.textContent = msg; note.style.display = ''; clearTimeout(note._t); note._t = setTimeout(() => { note.style.display = 'none'; }, ms); };try {const st = await pushStatus();-- if (st.subscribed) { await disablePush(); say('Push alerts turned off for this device.'); }-- else { await enablePush(); say('Push alerts enabled — you\'ll get alerts here even when this site is closed.'); }++ if (st.subscribed) { await disablePush(); say('Push alerts are off for this device.'); }++ else { await enablePush(); say('Push alerts are on: you\'ll get them here even when this site is closed.'); }} catch (e) { say(e.message || 'Could not change push setting', 6000); }for (const view of _views.values()) refreshPushButton(view);}@@@ -716,7 +1098,7 @@stopPolling();stopRealtime();_toastContainer?.remove(); _panelEl?.remove();-- _toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null;++ _toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null; _queue.length = 0;},/** Create and inject the bell icon. Returns the DOM element. */createBell(container) {@@@ -732,7 +1114,7 @@if (!container) return null;container.classList.add('openvibe-notif-inbox');const v = buildView(container, { mode: 'inbox' });-- if (opts.tab) { v.tab = opts.tab; container.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('active', b.dataset.tab === opts.tab)); }++ if (opts.tab) { v.tab = opts.tab; container.querySelectorAll('[data-tab]').forEach(b => { b.classList.toggle('active', b.dataset.tab === opts.tab); b.setAttribute('aria-selected', String(b.dataset.tab === opts.tab)); }); }reload(v);return v;},@@@ -754,6 +1136,8 @@},/** The realtime feed's state ({ state: 'off' | 'ticket' | 'connecting' | 'open' | 'backoff' | 'hidden' | 'blocked' | 'failed' | 'signed-out' | 'unavailable', … }), or null when off. */realtimeState() { return _live ? _live.state() : null; },++ /** How a notification reads (title, message, chips, tone, glyph, group key): the same for toasts and the list. */++ present,togglePanel, closePanel,enablePush, disablePush, pushStatus,get unreadCount() { return _unreadCount; },@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.26.1",++ "version": "4.27.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -361,7 +361,9 @@ const created = (subject, over = {}) => ({const { brotli } = require('../scripts/size-report');const size = (f) => brotli(fs.readFileSync(path.join(ROOT, f)));assert.ok(size('notification-live.js') <= 3 * 1024, `notification-live.js is ${(size('notification-live.js') / 1024).toFixed(2)} KB brotli, budget 3 KB`);-- assert.ok(size('notification-ui.js') <= 13 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 13 KB`);++ // 13 → 20 KB on 2026-10-28 (4.27.0, Notifications 2.0: line glyphs, System cards, grouping, layering, priority, the++ // bell's rich list). It loads only after sign-in, when the bell mounts, never on first paint; measured 19.16 KB.++ assert.ok(size('notification-ui.js') <= 20 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 20 KB`);console.log('notification live: all checks passed');})().catch((err) => { console.error(err); process.exit(1); });
@@ -6,6 +6,37 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,37 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.27.0 — 2026-10-28
+
+- **Notifications 2.0** (`notification-ui.js`). The owner's screenshot on 10-28: three red "Alert:
+ OpenVibeBrowserCheckFailed" toasts drawn over the open menu, translucent and overlapping, a siren emoji, raw
+ "run: sudo ovhost …" text, a badge of 67.
+ - **Toasts:**
+ - an opaque, themed card with a line glyph for the kind of notice (never an emoji), or the sender's avatar with
+ the glyph as a badge;
+ - a one-line title, up to two lines of text, chips, up to two actions and a timeout bar;
+ - phones show one at a time under the bar, with "+N more" inside the card and swipe to dismiss;
+ - desktops show up to three at the bottom right.
+ - **Layering:**
+ - a toast **never draws over an open menu, sheet, panel or dialog**: it waits, timers paused, and shows when the
+ layer closes;
+ - open layers are read from the page itself (the menu's `is-open`, any bar toggle's `aria-expanded`, a drawn
+ `aria-modal`, `dialog[open]`), so a lazily loaded bell still sees a menu opened before it loaded.
+ - **Grouping and priority:**
+ - the same notice within 15 minutes joins the card that is showing ("Browser check failed ×3" with the sites as
+ chips);
+ - one notification id is one card, however often a poll or a push brings it;
+ - critical first: a full stack lets a lower-priority card go, never a higher one.
+ - **Operator alerts** read as System cards: "Browser check failed · Release 80b7955 · 1 failing check", the
+ severity, the sites, "Open status", and the fix command behind **Copy fix command** in the bell, never in the
+ body.
+ - **The bell:**
+ - day headers, category chips, unread dots and grouped repeats, plus search;
+ - the row's read and dismiss buttons sit on its time line, never over its text, and are always shown on touch
+ screens;
+ - polling toasts only what is unread and newer than the last thing seen.
+- **Budget:** `notification-ui.js` 13 → 20 KB brotli. It loads only after sign-in, when the bell mounts.
+
## 4.26.1 — 2026-10-28
## 4.26.1 — 2026-10-28
- **No site name twice:**
- **No site name twice:**
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.26.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.27.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.26.1",
+ "version": "4.27.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// OpenVibe — Notification UI Client (shared across every OpenVibe site)
// OpenVibe — Notification UI Client (shared across every OpenVibe site)
//
//
-// One store (openvibe.network) → one bell everywhere. This file renders the bell +
-// dropdown panel, toasts, a full-page inbox, and handles Web Push subscription.
+// One store (openvibe.network) → one bell everywhere. This file renders the bell + its panel, toasts, a full-page
+// inbox, and handles Web Push subscription.
//
//
// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>
// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>
// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })
// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })
@@ -15,6 +15,18 @@
@@ -15,6 +15,18 @@
// person's network.notification.created over OpenVibe.Events with a ticket from POST /api/v1/realtime/ticket;
// person's network.notification.created over OpenVibe.Events with a ticket from POST /api/v1/realtime/ticket;
// each event or gap re-reads the count. Polling stays: every 15 s without a stream, every 2 min with one.
// each event or gap re-reads the count. Polling stays: every 15 s without a stream, every 2 min with one.
// Writes: POST /:id/read, /read-batch, /read-all, /:id/dismiss, /api/push/{subscribe,unsubscribe}
// Writes: POST /:id/read, /read-batch, /read-all, /:id/dismiss, /api/push/{subscribe,unsubscribe}
+//
+// Notifications 2.0 (2026-10-28, the owner's screenshot of three alert toasts drawn over the open menu):
+// • a toast is an opaque, themed card: a line glyph for its category (never an emoji) or the sender's avatar with
+// the glyph as a badge, a one-line title, at most two lines of text, chips, up to two actions, a timeout bar;
+// • toasts never draw over an open menu, panel or dialog: they wait (timers paused) and show when it closes;
+// • phones show one at a time under the bar (with "+N more" and swipe to dismiss), desktops up to three at the
+// bottom right;
+// • the same notice arriving again within 15 minutes joins the card that is showing (a count, site chips);
+// • operator alerts (OPERATOR_ALERT, or rich_content.kind "ops_alert") read as System cards: "Browser check
+// failed · events · release 80b7955", a status link, and the fix command behind a copy button in the bell —
+// never raw shell text in the body;
+// • the bell's list has day headers, category chips, unread dots and grouped repeats.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
(function (root) {
(function (root) {
@@ -24,14 +36,39 @@
@@ -24,14 +36,39 @@
const REALTIME_POLL = 120_000; // with an open realtime stream the poll is only a safety net (reads made on other sites)
const REALTIME_POLL = 120_000; // with an open realtime stream the poll is only a safety net (reads made on other sites)
const SELF_SRC = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const SELF_SRC = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const PAGE_SIZE = 30;
const PAGE_SIZE = 30;
- const TOAST_DURATION = { low: 0, normal: 4200, high: 6500, critical: 0 }; // 0 = sticky
- const MAX_TOASTS = 3;
+ const TOAST_DURATION = { low: 0, normal: 5200, high: 8000, critical: 0 }; // 0 = sticky
+ const MAX_TOASTS = 3; // desktop; phones show one and count the rest
+ const GROUP_WINDOW = 15 * 60 * 1000;
const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };
const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };
const CATEGORY_LABELS = {
const CATEGORY_LABELS = {
- social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams', economy: 'Economy',
- achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements',
+ stream: 'Live', social: 'Social', chat: 'Chat', game: 'Games', economy: 'Money & deals',
+ achievement: 'Achievements', service: 'Services', moderation: 'Moderation', system: 'System', admin: 'Announcements',
+ };
+ const SERVICE_LABELS = { live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', host: 'Host', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', chat: 'Chat' };
+
+ // Line glyphs (24×24, stroked like the open-loop mark): one per kind of notice.
+ const GLYPH = {
+ live: '<circle cx="12" cy="12" r="2.6"/><path d="M6.4 6.4a8 8 0 0 0 0 11.2M17.6 6.4a8 8 0 0 1 0 11.2M9.2 9.2a4 4 0 0 0 0 5.6M14.8 9.2a4 4 0 0 1 0 5.6"/>',
+ people: '<circle cx="9" cy="8.5" r="3.2"/><path d="M3.5 19.5a5.5 5.5 0 0 1 11 0"/><circle cx="17.2" cy="9.4" r="2.4"/><path d="M15.6 14.4a4.5 4.5 0 0 1 5.4 4.6"/>',
+ chat: '<path d="M4.5 5.5h15v10.5H10l-5.5 4z"/>',
+ game: '<path d="M6.5 8.5h11a3.5 3.5 0 0 1 3.5 3.5v2.6a2.9 2.9 0 0 1-5.1 1.9L14.2 14.6H9.8L8.1 16.5A2.9 2.9 0 0 1 3 14.6V12a3.5 3.5 0 0 1 3.5-3.5z"/><path d="M8.3 10.6v3M6.8 12.1h3"/><path d="M15.6 11.4h.01M17.2 12.9h.01"/>',
+ coin: '<circle cx="12" cy="12" r="8.2"/><path d="M14.6 9.6c-.6-.9-1.5-1.3-2.6-1.3-1.5 0-2.6.8-2.6 1.9s1.1 1.6 2.6 1.9 2.6.8 2.6 2-1.1 1.9-2.6 1.9c-1.1 0-2.1-.5-2.6-1.3M12 6.6v1.7M12 15.7v1.7"/>',
+ tag: '<path d="M4 4.5h7.2l8.8 8.8-6.7 6.7-8.8-8.8z"/><circle cx="8.4" cy="8.9" r="1.3"/>',
+ trophy: '<path d="M8 4h8v5a4 4 0 0 1-8 0zM8 6H5.2a3 3 0 0 0 3 3.6M16 6h2.8a3 3 0 0 1-3 3.6M12 13v3.6M8.6 20h6.8"/>',
+ shield: '<path d="M12 3.2l7 3v5.2c0 4.3-2.9 7.8-7 9.6-4.1-1.8-7-5.3-7-9.6V6.2z"/>',
+ gear: '<circle cx="12" cy="12" r="3"/><path d="M12 3.5v2.2M12 18.3v2.2M3.5 12h2.2M18.3 12h2.2M6 6l1.6 1.6M16.4 16.4L18 18M6 18l1.6-1.6M16.4 7.6L18 6"/>',
+ grid: '<rect x="4" y="4" width="6.6" height="6.6" rx="1.6"/><rect x="13.4" y="4" width="6.6" height="6.6" rx="1.6"/><rect x="4" y="13.4" width="6.6" height="6.6" rx="1.6"/><rect x="13.4" y="13.4" width="6.6" height="6.6" rx="1.6"/>',
+ pulse: '<path d="M3 12.5h4.2l2.3-5.5 4.2 10.5 2.3-5h4.9"/>',
+ check: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',
+ bell: '<path d="M6.2 16.2V11a5.8 5.8 0 0 1 11.6 0v5.2l1.6 2H4.6z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>',
+ key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',
+ };
+ const CATEGORY_GLYPH = { stream: 'live', social: 'people', chat: 'chat', game: 'game', economy: 'coin', achievement: 'trophy', moderation: 'shield', system: 'gear', service: 'grid', admin: 'bell' };
+ const TONE = {
+ live: 'var(--live-red, #e5484d)', danger: 'var(--live-red, #e5484d)', warn: 'var(--warning, #f5a524)', ok: 'var(--success, #30a46c)',
+ gold: 'var(--gold, #e3b341)', accent: 'var(--accent, #3b82f6)', muted: 'var(--text-secondary, #a0a0ab)',
};
};
- const SERVICE_LABELS = { live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network' };
+ const CATEGORY_TONE = { stream: 'live', social: 'accent', chat: 'accent', game: 'accent', economy: 'gold', achievement: 'gold', moderation: 'warn', system: 'muted', service: 'accent', admin: 'accent' };
let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };
let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };
let _pollTimer = null;
let _pollTimer = null;
@@ -47,6 +84,8 @@
@@ -47,6 +84,8 @@
let _lastPoll = 0;
let _lastPoll = 0;
let _soon = null; let _soonAll = false;
let _soon = null; let _soonAll = false;
const _views = new Map(); // container → view state (panel + inboxes)
const _views = new Map(); // container → view state (panel + inboxes)
+ const _queue = []; // toast cards waiting for a free screen (a menu open, or a phone already showing one)
+ let _menuOpen = false; // the OpenVibe menu (search.js) says so through its 'openvibe-menu' event
// ── Styles ──────────────────────────────────────────────
// ── Styles ──────────────────────────────────────────────
function injectStyles() {
function injectStyles() {
@@ -54,106 +93,151 @@
@@ -54,106 +93,151 @@
const style = document.createElement('style');
const style = document.createElement('style');
style.id = 'openvibe-notif-styles';
style.id = 'openvibe-notif-styles';
style.textContent = `
style.textContent = `
- @keyframes openvibe-toast-in { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
- @keyframes openvibe-toast-out { from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } }
- @keyframes openvibe-badge-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.25); } }
- @keyframes openvibe-bell-ring { 0%{ transform: rotate(0); } 10%{ transform: rotate(14deg); } 20%{ transform: rotate(-14deg); } 30%{ transform: rotate(10deg); } 40%{ transform: rotate(-6deg); } 50%{ transform: rotate(0); } }
- @keyframes openvibe-slide-down { from { transform: translateY(-10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
- @keyframes openvibe-spin { to { transform: rotate(360deg); } }
-
- .openvibe-toast-container { position: fixed; top: 82px; right: 14px; z-index: 100000; display: flex; flex-direction: column; gap: 6px; pointer-events: none; max-width: 340px; width: 100%; }
- .openvibe-toast { pointer-events: all; background: color-mix(in srgb, var(--bg-card, #22222c) 94%, transparent); border: 1px solid color-mix(in srgb, var(--border, #333340) 82%, transparent); border-radius: 10px; padding: 11px 12px; display: flex; gap: 10px; align-items: flex-start; box-shadow: 0 10px 24px rgba(0,0,0,0.28); animation: openvibe-toast-in .24s cubic-bezier(.34,1.56,.64,1); cursor: pointer; position: relative; overflow: hidden; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); transition: border-color .2s; }
- .openvibe-toast:hover { border-color: var(--accent, #3b82f6); }
- .openvibe-toast.removing { animation: openvibe-toast-out .3s ease forwards; }
- .openvibe-toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1; margin-top: 1px; }
- .openvibe-toast-icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }
- .openvibe-toast-body { flex: 1; min-width: 0; }
- .openvibe-toast-title { font-weight: 600; font-size: 12px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; }
- .openvibe-toast-title .service-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, #60a5fa); font-weight: 500; }
- .openvibe-toast-msg { font-size: 11px; color: var(--text-secondary, #b0b0b8); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
- .openvibe-toast-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; padding: 2px; line-height: 1; }
- .openvibe-toast-close:hover { color: var(--text-primary, #e0e0e0); }
- .openvibe-toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; background: var(--accent, #3b82f6); }
- .openvibe-toast.priority-high { border-left: 3px solid var(--warning, #f39c12); }
- .openvibe-toast.priority-critical { border-left: 3px solid var(--live-red, #e74c3c); background: rgba(231,76,60,0.06); }
- .openvibe-toast-actions { display: flex; gap: 6px; margin-top: 8px; }
- .openvibe-toast-actions button { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border, #333340); background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); font-size: 11px; font-weight: 600; cursor: pointer; }
- .openvibe-toast-actions button.primary { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); border-color: var(--accent-strong, var(--accent, #3472d8)); }
-
- .openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; }
- .openvibe-bell svg { width: 22px; height: 22px; fill: var(--text-secondary, #b0b0b8); transition: fill .2s; }
- .openvibe-bell:hover svg, .openvibe-bell.open svg { fill: var(--accent-light, #60a5fa); }
- .openvibe-bell .badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px; background: var(--live-red, #e74c3c); color: #fff; border-radius: 8px; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; line-height: 1; pointer-events: none; animation: openvibe-badge-pulse .4s ease; }
+ @keyframes ovn-in-up { from { transform: translateY(14px) scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }
+ @keyframes ovn-in-down { from { transform: translateY(-14px) scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }
+ @keyframes ovn-out { to { transform: translateX(24px); opacity: 0; } }
+ @keyframes ovn-badge-pop { 0%,100% { transform: scale(1); } 50% { transform: scale(1.22); } }
+ @keyframes ovn-bell-ring { 0%{ transform: rotate(0); } 12%{ transform: rotate(13deg); } 24%{ transform: rotate(-12deg); } 36%{ transform: rotate(8deg); } 48%{ transform: rotate(-4deg); } 60%{ transform: rotate(0); } }
+ @keyframes ovn-panel-in { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } }
+ @keyframes ovn-spin { to { transform: rotate(360deg); } }
+
+ /* Toasts. Opaque cards on the theme's raised surface; held (hidden, timers paused) while a menu is open. */
+ .openvibe-toast-container { position: fixed; z-index: 2147482600; display: flex; flex-direction: column; gap: 10px; pointer-events: none;
+ right: max(16px, env(safe-area-inset-right, 0px)); bottom: max(16px, env(safe-area-inset-bottom, 0px)); width: min(380px, calc(100vw - 32px));
+ font-family: inherit; transition: opacity .18s ease; }
+ .openvibe-toast-container.ovn-held { opacity: 0; visibility: hidden; }
+ .openvibe-toast-container.ovn-phone { top: var(--ovn-top, 64px); bottom: auto; left: max(8px, env(safe-area-inset-left, 0px)); right: max(8px, env(safe-area-inset-right, 0px)); width: auto; }
+ .openvibe-toast { pointer-events: auto; position: relative; overflow: hidden; display: grid; grid-template-columns: 38px 1fr; gap: 12px; align-items: start;
+ padding: 12px 34px 12px 12px; border-radius: 16px; cursor: pointer; touch-action: pan-y;
+ background: var(--bg-elevated, var(--bg-card, #1f2129)); color: var(--text-primary, #ececf1);
+ border: 1px solid var(--border, rgba(255,255,255,.1)); box-shadow: 0 18px 40px rgba(0,0,0,.34), 0 2px 6px rgba(0,0,0,.2);
+ animation: ovn-in-up .26s cubic-bezier(.2,.8,.2,1); }
+ .ovn-phone .openvibe-toast { animation-name: ovn-in-down; }
+ .openvibe-toast::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--ovn-tone, var(--accent, #3b82f6)); }
+ .openvibe-toast:hover, .openvibe-toast:focus-visible { border-color: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 55%, var(--border, #333)); outline: none; }
+ .openvibe-toast.removing { animation: ovn-out .24s ease forwards; }
+ .openvibe-toast-body { min-width: 0; }
+ .openvibe-toast-title { display: flex; align-items: baseline; gap: 6px; min-width: 0; font-size: 14px; font-weight: 650; line-height: 1.3; }
+ .openvibe-toast-title .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .openvibe-toast-title .n { flex: none; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; color: var(--ovn-tone, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 16%, transparent); }
+ .openvibe-toast-sub { margin-top: 1px; font-size: 11.5px; color: var(--text-muted, #8b8b96); display: flex; gap: 6px; align-items: center; }
+ .openvibe-toast-msg { margin-top: 4px; font-size: 13px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
+ .openvibe-toast-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
+ .openvibe-toast-actions button, .openvibe-toast-actions a { font: inherit; font-size: 12.5px; font-weight: 650; padding: 7px 13px; border-radius: 999px; cursor: pointer; text-decoration: none; line-height: 1;
+ border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); }
+ .openvibe-toast-actions .primary { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }
+ .openvibe-toast-close { position: absolute; top: 8px; right: 8px; width: 26px; height: 26px; border-radius: 50%; border: none; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; font-size: 16px; line-height: 1; }
+ .openvibe-toast-close:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }
+ .openvibe-toast-progress { position: absolute; left: 0; bottom: 0; height: 2px; width: 100%; transform-origin: left; background: var(--ovn-tone, var(--accent, #3b82f6)); opacity: .7; }
+ .openvibe-toast-more { margin-left: auto; flex: none; font: inherit; font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 999px; cursor: pointer;
+ border: 1px solid color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 40%, var(--border, #333)); background: transparent; color: var(--text-secondary, #b6b6c0); }
+ .openvibe-toast.priority-critical { border-color: color-mix(in srgb, var(--ovn-tone) 60%, var(--border, #333)); }
+
+ /* The glyph (or avatar with a glyph badge), shared by toasts and the list. */
+ .ovn-glyph { position: relative; width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; flex: none;
+ color: var(--ovn-tone, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 15%, transparent); }
+ .ovn-glyph svg { width: 21px; height: 21px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+ .ovn-glyph.ovn-avatar { border-radius: 50%; background: var(--bg-tertiary, #2a2b33); }
+ .ovn-glyph.ovn-avatar img { width: 38px; height: 38px; border-radius: 50%; object-fit: cover; display: block; }
+ .ovn-glyph .ovn-badge { position: absolute; right: -3px; bottom: -3px; width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center;
+ background: var(--ovn-tone, var(--accent, #3b82f6)); color: var(--bg-elevated, var(--bg-card, #1f2129)); box-shadow: 0 0 0 2px var(--bg-elevated, var(--bg-card, #1f2129)); }
+ .ovn-glyph .ovn-badge svg { width: 11px; height: 11px; stroke-width: 2.4; }
+ .ovn-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
+ .ovn-chip { font-size: 11px; font-weight: 600; line-height: 1; padding: 4px 8px; border-radius: 999px; color: var(--text-secondary, #b6b6c0); background: var(--bg-tertiary, rgba(255,255,255,.06)); border: 1px solid var(--border, rgba(255,255,255,.08)); }
+ .ovn-chip.sev { color: var(--ovn-tone); background: color-mix(in srgb, var(--ovn-tone) 14%, transparent); border-color: transparent; text-transform: uppercase; letter-spacing: .03em; font-size: 10px; }
+
+ .openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; }
+ .openvibe-bell svg { width: 22px; height: 22px; fill: none; stroke: var(--text-secondary, #b0b0b8); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: stroke .2s; }
+ .openvibe-bell:hover svg, .openvibe-bell.open svg { stroke: var(--accent-light, var(--accent, #60a5fa)); }
+ .openvibe-bell:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+ .openvibe-bell .badge { position: absolute; top: 1px; right: 0; min-width: 17px; height: 17px; padding: 0 5px; box-sizing: border-box; background: var(--live-red, #e5484d); color: #fff; border-radius: 999px;
+ font-size: 10px; font-weight: 750; display: flex; align-items: center; justify-content: center; line-height: 1; pointer-events: none; box-shadow: 0 0 0 2px var(--bg-primary, #111);
+ font-variant-numeric: tabular-nums; animation: ovn-badge-pop .36s ease; }
.openvibe-bell .badge.hidden { display: none; }
.openvibe-bell .badge.hidden { display: none; }
- .openvibe-bell.ringing svg { animation: openvibe-bell-ring .6s ease; }
+ .openvibe-bell.ringing svg { animation: ovn-bell-ring .7s ease; transform-origin: 50% 15%; }
- .openvibe-notif-panel { position: fixed; top: 62px; right: 16px; width: 400px; max-height: calc(100vh - 86px); background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5)); z-index: 99999; display: none; flex-direction: column; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); animation: openvibe-slide-down .2s ease; overflow: hidden; }
+ /* The panel (desktop popover, phone sheet) and the full inbox share one list view. */
+ .openvibe-notif-panel { position: fixed; z-index: 99999; display: none; flex-direction: column; overflow: hidden;
+ top: var(--ovn-top, 62px); right: max(8px, env(safe-area-inset-right, 0px)); width: min(420px, calc(100vw - 16px));
+ max-height: calc(100vh - var(--ovn-top, 62px) - 12px); max-height: calc(100dvh - var(--ovn-top, 62px) - 12px - env(safe-area-inset-bottom, 0px));
+ background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));
+ border-radius: 18px; box-shadow: 0 24px 60px rgba(0,0,0,.45); animation: ovn-panel-in .2s cubic-bezier(.2,.8,.2,1); font-family: inherit; }
.openvibe-notif-panel.open { display: flex; }
.openvibe-notif-panel.open { display: flex; }
- .openvibe-notif-inbox { display: flex; flex-direction: column; background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; overflow: hidden; color: var(--text-primary, #e0e0e0); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; min-height: 420px; max-height: 78vh; }
+ .openvibe-notif-inbox { display: flex; flex-direction: column; overflow: hidden; min-height: 420px; max-height: 78vh; border-radius: 18px;
+ background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1)); font-family: inherit; }
- .openvibe-nv-head { padding: 12px 14px; border-bottom: 1px solid var(--border, #333340); display: flex; align-items: center; justify-content: space-between; gap: 8px; }
- .openvibe-nv-head h3 { font-size: 15px; font-weight: 600; margin: 0; display: flex; align-items: center; gap: 8px; }
- .openvibe-nv-head h3 .count { font-size: 11px; font-weight: 600; background: var(--live-red, #e74c3c); color: #fff; border-radius: 9px; padding: 1px 7px; }
+ .openvibe-nv-head { padding: 14px 14px 10px 16px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+ .openvibe-nv-head h3 { font-size: 17px; font-weight: 750; letter-spacing: -.01em; margin: 0; display: flex; align-items: center; gap: 8px; }
+ .openvibe-nv-head h3 .count { font-size: 11px; font-weight: 750; background: var(--live-red, #e5484d); color: #fff; border-radius: 999px; padding: 2px 7px; font-variant-numeric: tabular-nums; }
.openvibe-nv-head h3 .count.zero { display: none; }
.openvibe-nv-head h3 .count.zero { display: none; }
- .openvibe-nv-actions { display: flex; gap: 4px; align-items: center; }
- .openvibe-nv-actions button, .openvibe-nv-actions a { background: none; border: 1px solid transparent; color: var(--text-muted, #707080); cursor: pointer; font-size: 12px; padding: 5px 8px; border-radius: 6px; text-decoration: none; display: inline-flex; align-items: center; gap: 5px; line-height: 1; }
- .openvibe-nv-actions button:hover, .openvibe-nv-actions a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
- .openvibe-nv-actions button.on { color: var(--accent-light, #60a5fa); border-color: color-mix(in srgb, var(--accent, #3b82f6) 40%, transparent); }
- .openvibe-nv-actions button:disabled { opacity: .45; cursor: default; }
- .openvibe-nv-actions svg { width: 14px; height: 14px; fill: currentColor; }
-
- .openvibe-nv-tools { padding: 8px 12px; border-bottom: 1px solid var(--border, #333340); display: flex; flex-direction: column; gap: 8px; }
+ .openvibe-nv-actions { display: flex; gap: 2px; align-items: center; }
+ .openvibe-nv-actions button, .openvibe-nv-actions a { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; border: none; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; text-decoration: none; }
+ .openvibe-nv-actions button > span, .openvibe-nv-actions a > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+ .openvibe-nv-actions button:hover, .openvibe-nv-actions a:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }
+ .openvibe-nv-actions button.on { color: var(--accent-light, var(--accent, #60a5fa)); }
+ .openvibe-nv-actions button:disabled { opacity: .4; cursor: default; }
+ .openvibe-nv-actions svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+
+ .openvibe-nv-tools { padding: 0 12px 10px; display: flex; flex-direction: column; gap: 10px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
.openvibe-nv-search { position: relative; }
.openvibe-nv-search { position: relative; }
- .openvibe-nv-search input { width: 100%; box-sizing: border-box; padding: 7px 28px 7px 30px; border-radius: 8px; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-primary, #e0e0e0); font-size: 12px; outline: none; }
+ .openvibe-nv-search input { width: 100%; box-sizing: border-box; padding: 9px 30px 9px 34px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: var(--bg-input, var(--bg-tertiary, rgba(255,255,255,.04))); color: var(--text-primary, #ececf1); font: inherit; font-size: 13px; outline: none; }
.openvibe-nv-search input:focus { border-color: var(--accent, #3b82f6); }
.openvibe-nv-search input:focus { border-color: var(--accent, #3b82f6); }
- .openvibe-nv-search svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; fill: var(--text-muted, #707080); pointer-events: none; }
- .openvibe-nv-search .clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; display: none; }
+ .openvibe-nv-search > svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; fill: none; stroke: var(--text-muted, #8b8b96); stroke-width: 1.8; stroke-linecap: round; pointer-events: none; }
+ .openvibe-nv-search .clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted, #8b8b96); cursor: pointer; font-size: 16px; display: none; }
.openvibe-nv-search.has-q .clear { display: block; }
.openvibe-nv-search.has-q .clear { display: block; }
- .openvibe-nv-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
- .openvibe-nv-chips button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 10px; border-radius: 999px; cursor: pointer; line-height: 1.2; }
- .openvibe-nv-chips button:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e0e0e0); }
- .openvibe-nv-chips button.active { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); border-color: var(--accent, #3b82f6); color: var(--accent-light, #60a5fa); }
- .openvibe-nv-chips select { margin-left: auto; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 8px; border-radius: 8px; cursor: pointer; }
-
- .openvibe-nv-list { flex: 1; overflow-y: auto; padding: 4px 0; overscroll-behavior: contain; }
- .openvibe-notif-item { padding: 10px 12px 10px 14px; display: flex; gap: 10px; align-items: flex-start; cursor: pointer; transition: background .15s; border-left: 3px solid transparent; position: relative; }
- .openvibe-notif-item:hover { background: var(--bg-hover, #2f2f3d); }
- .openvibe-notif-item.unread { background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); border-left-color: var(--accent, #3b82f6); }
- .openvibe-notif-item .icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; width: 32px; text-align: center; }
- .openvibe-notif-item .icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }
- .openvibe-notif-item .content { flex: 1; min-width: 0; }
- .openvibe-notif-item .title { font-size: 13px; font-weight: 500; margin-bottom: 2px; padding-right: 18px; }
- .openvibe-notif-item.unread .title { font-weight: 650; }
- .openvibe-notif-item .msg { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
- .openvibe-notif-item .meta { font-size: 10px; color: var(--text-muted, #707080); margin-top: 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
- .openvibe-notif-item .meta .tag { padding: 1px 5px; border-radius: 3px; background: var(--bg-tertiary, #2a2a38); font-weight: 500; }
- .openvibe-notif-item .dismiss { position: absolute; top: 8px; right: 8px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 15px; line-height: 1; padding: 2px 4px; border-radius: 4px; opacity: 0; transition: opacity .15s; }
- .openvibe-notif-item:hover .dismiss { opacity: 1; }
- .openvibe-notif-item .dismiss:hover { color: var(--live-red-text, var(--live-red, #e74c3c)); background: var(--bg-tertiary, #2a2a38); }
- .openvibe-nv-empty { text-align: center; padding: 44px 24px; color: var(--text-muted, #707080); font-size: 13px; }
- .openvibe-nv-empty .icon { font-size: 34px; margin-bottom: 8px; display: block; opacity: .5; }
- .openvibe-nv-more { display: flex; justify-content: center; padding: 10px; }
- .openvibe-nv-more button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 12px; padding: 6px 14px; border-radius: 8px; cursor: pointer; }
- .openvibe-nv-more .spinner { width: 16px; height: 16px; border: 2px solid var(--border, #333340); border-top-color: var(--accent, #3b82f6); border-radius: 50%; animation: openvibe-spin .8s linear infinite; }
- .openvibe-nv-foot { padding: 8px 12px; border-top: 1px solid var(--border, #333340); font-size: 11px; color: var(--text-muted, #707080); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
- .openvibe-nv-foot a { color: var(--accent-light, #60a5fa); text-decoration: none; }
- .openvibe-nv-note { font-size: 11px; color: var(--text-muted, #707080); padding: 6px 12px; background: color-mix(in srgb, var(--warning, #f39c12) 10%, transparent); border-bottom: 1px solid var(--border, #333340); }
-
- /* The panel hangs under whatever navbar the site has (its top is measured when it opens) and never
- leaves the visible viewport: dvh instead of vh, room for the home indicator, list scrolls inside. */
- .openvibe-notif-panel { top: var(--ovn-top, 62px); right: max(8px, env(safe-area-inset-right, 0px)); width: min(420px, calc(100vw - 16px));
- max-height: calc(100vh - var(--ovn-top, 62px) - 12px); max-height: calc(100dvh - var(--ovn-top, 62px) - 12px - env(safe-area-inset-bottom, 0px)); border-radius: 16px; }
- .openvibe-notif-item .title .count { display: inline-block; margin-left: 4px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); vertical-align: 1px; }
- /* Phones and narrow windows: a sheet from edge to edge under the bar, down to the bottom inset. */
+ .openvibe-nv-chips { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; margin: 0 -12px; padding: 0 12px; }
+ .openvibe-nv-chips::-webkit-scrollbar { display: none; }
+ .openvibe-nv-chips button { flex: none; border: 1px solid var(--border, rgba(255,255,255,.1)); background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12.5px; font-weight: 600; padding: 6px 12px; border-radius: 999px; cursor: pointer; line-height: 1.1; }
+ .openvibe-nv-chips button:hover { color: var(--text-primary, #ececf1); border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }
+ .openvibe-nv-chips button.active { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }
+
+ .openvibe-nv-list { flex: 1; overflow-y: auto; padding: 2px 0 6px; overscroll-behavior: contain; }
+ .openvibe-nv-day { position: sticky; top: 0; z-index: 1; padding: 10px 16px 6px; font-size: 11.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #8b8b96); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }
+ .openvibe-notif-item { display: grid; grid-template-columns: 38px 1fr; gap: 12px; padding: 10px 40px 10px 14px; margin: 0 6px; border-radius: 14px; cursor: pointer; position: relative; transition: background .15s; }
+ .openvibe-notif-item:hover { background: var(--bg-hover, rgba(255,255,255,.05)); }
+ .openvibe-notif-item.unread { background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); }
+ .openvibe-notif-item .content { min-width: 0; }
+ .openvibe-notif-item .title { font-size: 13.5px; font-weight: 550; line-height: 1.32; display: flex; align-items: baseline; gap: 6px; }
+ .openvibe-notif-item .title .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+ .openvibe-notif-item.unread .title { font-weight: 700; }
+ .openvibe-notif-item .title .count { flex: none; font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 999px; color: var(--ovn-tone, var(--accent, #3b82f6)); background: color-mix(in srgb, var(--ovn-tone, var(--accent, #3b82f6)) 15%, transparent); }
+ .openvibe-notif-item .msg { margin-top: 2px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); line-height: 1.42; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
+ .openvibe-notif-item .meta { font-size: 11.5px; color: var(--text-muted, #8b8b96); margin-top: 5px; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
+ .openvibe-notif-item .meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .6; }
+ .openvibe-notif-item .unread-dot { position: absolute; right: 16px; top: 16px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent, #3b82f6); }
+ .openvibe-notif-item .row-actions { margin-left: auto; display: flex; gap: 2px; opacity: 0; transition: opacity .15s; }
+ .openvibe-notif-item:hover .row-actions, .openvibe-notif-item:focus-within .row-actions { opacity: 1; }
+ /* The row's buttons sit on its time line, never over its text; touch screens always show them. */
+ @media (hover: none), (pointer: coarse) { .openvibe-notif-item .row-actions { opacity: 1; } }
+ .openvibe-notif-item:hover .unread-dot { opacity: 0; }
+ .openvibe-notif-item .row-actions button { width: 28px; height: 28px; border-radius: 8px; border: none; background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }
+ .openvibe-notif-item .row-actions button:hover { color: var(--text-primary, #ececf1); background: var(--bg-hover, rgba(255,255,255,.08)); }
+ .openvibe-notif-item .row-actions svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+ .openvibe-notif-item .copy { font: inherit; font-size: 11.5px; font-weight: 650; margin-top: 7px; padding: 5px 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; }
+ .openvibe-notif-item .copy:hover { color: var(--text-primary, #ececf1); }
+ .openvibe-nv-empty { text-align: center; padding: 48px 24px 56px; color: var(--text-muted, #8b8b96); font-size: 13.5px; }
+ .openvibe-nv-empty svg { width: 64px; height: 64px; display: block; margin: 0 auto 12px; fill: none; stroke: var(--accent, #3b82f6); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; opacity: .75; }
+ .openvibe-nv-empty b { display: block; font-size: 15px; color: var(--text-primary, #ececf1); margin-bottom: 4px; }
+ .openvibe-nv-more { display: flex; justify-content: center; padding: 12px; }
+ .openvibe-nv-more button { border: 1px solid var(--border, rgba(255,255,255,.1)); background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12.5px; padding: 7px 16px; border-radius: 999px; cursor: pointer; }
+ .openvibe-nv-more .spinner { width: 18px; height: 18px; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); border-radius: 50%; animation: ovn-spin .8s linear infinite; }
+ .openvibe-nv-foot { padding: 10px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #8b8b96); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
+ .openvibe-nv-foot a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; font-weight: 650; }
+ .openvibe-nv-note { font-size: 12px; color: var(--text-secondary, #b6b6c0); padding: 8px 16px; background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }
+
+ /* Phones: the panel is a sheet from under the bar to the bottom inset, edge to edge. */
@media (max-width: 640px) {
@media (max-width: 640px) {
- .openvibe-notif-panel { left: 8px; right: 8px; width: auto; bottom: calc(8px + env(safe-area-inset-bottom, 0px)); max-height: none; }
- .openvibe-notif-panel [data-act] > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } /* icon-only actions, labels stay for screen readers */
- .openvibe-notif-panel [data-act] { min-width: 36px; min-height: 36px; justify-content: center; }
- .openvibe-notif-item { padding-top: 10px; padding-bottom: 10px; }
+ .openvibe-notif-panel { left: 6px; right: 6px; width: auto; bottom: calc(6px + env(safe-area-inset-bottom, 0px)); max-height: none; border-radius: 20px; }
+ .openvibe-notif-item .row-actions { opacity: 1; }
+ .openvibe-notif-item .unread-dot { display: none; }
+ .openvibe-notif-item.unread .title .t::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); margin-right: 6px; vertical-align: 1px; }
}
}
- @media (max-height: 520px) { .openvibe-notif-item .msg { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; } }
- @media (max-width: 460px) {
- .openvibe-toast-container { right: 8px; left: 8px; max-width: none; }
+ @media (max-height: 520px) { .openvibe-notif-item .msg { -webkit-line-clamp: 1; } }
+ @media (prefers-reduced-motion: reduce) {
+ .openvibe-toast, .ovn-phone .openvibe-toast, .openvibe-notif-panel { animation: none; }
+ .openvibe-toast.removing { animation: none; opacity: 0; }
+ .openvibe-bell.ringing svg, .openvibe-bell .badge { animation: none; }
+ .openvibe-toast-progress { display: none; }
}
}
`;
`;
document.head.appendChild(style);
document.head.appendChild(style);
@@ -183,21 +267,26 @@
@@ -183,21 +267,26 @@
const d = parseDate(v); if (!d) return '';
const d = parseDate(v); if (!d) return '';
const diff = (Date.now() - d.getTime()) / 1000;
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 45) return 'just now';
if (diff < 45) return 'just now';
- if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))}m ago`;
- if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
- if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
+ if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))} min ago`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
+ if (diff < 604800) return `${Math.floor(diff / 86400)} d ago`;
return d.toLocaleDateString();
return d.toLocaleDateString();
}
}
+ /** "Today", "Yesterday", a weekday this week, else the date: the list's day headers. */
+ function dayLabel(v) {
+ const d = parseDate(v); if (!d) return 'Earlier';
+ const start = new Date(); start.setHours(0, 0, 0, 0);
+ const day = new Date(d.getTime()); day.setHours(0, 0, 0, 0);
+ const days = Math.round((start - day) / 86400000);
+ if (days <= 0) return 'Today';
+ if (days === 1) return 'Yesterday';
+ if (days < 7) { try { return d.toLocaleDateString(undefined, { weekday: 'long' }); } catch { return 'This week'; } }
+ try { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { return 'Earlier'; }
+ }
function inboxUrl() {
function inboxUrl() {
if (_config.inboxUrl) return _config.inboxUrl;
if (_config.inboxUrl) return _config.inboxUrl;
try { return new URL('/notifications', _config.apiBase).toString(); } catch { return 'https://openvibe.network/notifications'; }
try { return new URL('/notifications', _config.apiBase).toString(); } catch { return 'https://openvibe.network/notifications'; }
}
}
- function iconHtml(n) {
- const rich = richOf(n);
- const img = n.sender_avatar || rich.thumbnail || (rich.user && rich.user.avatar_url);
- if (img && /^https?:\/\//.test(String(img))) return `<img src="${esc(img)}" alt="" loading="lazy" onerror="this.replaceWith(document.createTextNode('${esc(n.icon || '🔔')}'))">`;
- return esc(n.icon || '🔔');
- }
function navigateTo(url) {
function navigateTo(url) {
if (!url) return;
if (!url) return;
try {
try {
@@ -205,6 +294,79 @@
@@ -205,6 +294,79 @@
window.location.href = u.toString();
window.location.href = u.toString();
} catch { window.location.href = url; }
} catch { window.location.href = url; }
}
}
+ const svg = (paths) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
+ function isPhone() {
+ try { return !!(root.matchMedia && root.matchMedia('(max-width: 640px)').matches); } catch { return false; }
+ }
+
+ // ── What a notification says (one presentation for toasts and the list) ─────
+ /** OpenVibeBrowserCheckFailed → "Browser check failed". */
+ function humanize(name) {
+ const s = String(name || '').replace(/^OpenVibe(?=[A-Z])/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim();
+ return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Alert';
+ }
+ function isOpsAlert(n, rich) { return !!n && (n.type === 'OPERATOR_ALERT' || rich.kind === 'ops_alert'); }
+ /**
+ * An operator alert, from its structured fields (rich_content.kind "ops_alert": name, state, severity, sites,
+ * release, summary, command) or, for older notices, read out of its title and message.
+ */
+ function presentOps(n, rich) {
+ const title = String(n.title || '');
+ const m = /^(Alert|Still firing|Resolved):\s*(.+)$/i.exec(title);
+ const state = rich.state || (m ? (m[1].toLowerCase() === 'resolved' ? 'resolved' : (m[1].toLowerCase() === 'still firing' ? 'reminded' : 'firing')) : 'firing');
+ const name = rich.name || (m ? m[2] : title);
+ const raw = String(n.message || '');
+ const command = rich.command || ((/run:\s*(sudo [^;]+?)(?:\.\s|;|\.$|$)/.exec(raw) || [])[1]) || null;
+ const sites = Array.isArray(rich.sites) && rich.sites.length ? rich.sites.slice(0, 6)
+ : [...new Set(((raw.match(/(?:^|\.\s+)([a-z0-9][a-z0-9.-]{1,30}):\s/g) || []).map(s => s.replace(/^\.\s+/, '').replace(/:\s$/, ''))))].slice(0, 6);
+ const release = rich.release || ((/release ([0-9a-f]{7,40})/i.exec(raw) || [])[1] || null);
+ const failing = (/\((\d+) failing check/i.exec(raw) || [])[1];
+ let summary = rich.summary || raw
+ .replace(/run:\s*sudo [^;]+?(?:\.\s|;|\.$|$)/g, '')
+ .replace(/Firing since [^.]+\.?/g, '')
+ .replace(/(?:^|\.\s+)[a-z0-9][a-z0-9.-]{1,30}:\s/g, ' ')
+ .replace(/\s+/g, ' ').trim();
+ if (release || failing) summary = [release ? `Release ${String(release).slice(0, 7)}` : null, failing ? `${failing} failing check${failing === '1' ? '' : 's'}` : null].filter(Boolean).join(' · ');
+ const severity = rich.severity || (n.priority === 'critical' ? 'critical' : n.priority === 'high' ? 'warning' : 'info');
+ const tone = state === 'resolved' ? 'ok' : severity === 'critical' ? 'danger' : 'warn';
+ return {
+ kind: 'ops', key: `ops:${name}:${state === 'resolved' ? 'resolved' : 'open'}`, tone, glyph: state === 'resolved' ? 'check' : 'pulse',
+ title: (state === 'resolved' ? 'Resolved: ' : state === 'reminded' ? 'Still failing: ' : '') + humanize(name),
+ message: summary.slice(0, 220), chips: sites, severity: state === 'resolved' ? 'resolved' : severity, command,
+ label: 'System', url: rich.runbook_url || urlOf(n), actionLabel: 'Open status',
+ };
+ }
+ function present(n) {
+ const rich = richOf(n);
+ if (isOpsAlert(n, rich)) return presentOps(n, rich);
+ const cat = n.category || 'system';
+ const t = String(n.type || '');
+ const glyph = /DEAL|COUPON|PRICE|TRADE/.test(t) ? 'tag' : /CONFIRM|APPROV|SECUR|SIGN/.test(t) ? 'key' : (CATEGORY_GLYPH[cat] || 'bell');
+ const tone = n.priority === 'critical' ? 'danger' : (CATEGORY_TONE[cat] || 'accent');
+ const chips = Array.isArray(rich.chips) ? rich.chips.filter(c => typeof c === 'string').slice(0, 4) : [];
+ return {
+ kind: 'plain', key: [t, n.title || '', n.service || '', cat].join('\u0001'), tone, glyph,
+ title: String(n.title || ''), message: String(n.message || ''), chips, severity: null, command: null,
+ label: SERVICE_LABELS[n.service] || (n.service && n.service !== 'network' ? n.service : '') || CATEGORY_LABELS[cat] || '',
+ url: urlOf(n), actionLabel: null,
+ };
+ }
+ function avatarOf(n) {
+ const rich = richOf(n);
+ const img = n.sender_avatar || rich.thumbnail || (rich.user && rich.user.avatar_url);
+ return img && /^https?:\/\//.test(String(img)) ? String(img) : null;
+ }
+ function glyphHtml(n, p) {
+ const tone = TONE[p.tone] || TONE.accent;
+ const img = p.kind === 'ops' ? null : avatarOf(n);
+ if (img) return `<span class="ovn-glyph ovn-avatar" style="--ovn-tone:${tone}"><img src="${esc(img)}" alt="" loading="lazy" onerror="this.remove()"><span class="ovn-badge">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span></span>`;
+ return `<span class="ovn-glyph" style="--ovn-tone:${tone}">${svg(GLYPH[p.glyph] || GLYPH.bell)}</span>`;
+ }
+ function chipsHtml(p, extra = []) {
+ const sev = p.severity ? [`<span class="ovn-chip sev" style="--ovn-tone:${TONE[p.tone] || TONE.accent}">${esc(p.severity)}</span>`] : [];
+ const all = sev.concat((p.chips || []).concat(extra).map(c => `<span class="ovn-chip">${esc(c)}</span>`));
+ return all.length ? `<div class="ovn-chips">${all.join('')}</div>` : '';
+ }
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
function isCrossOrigin() {
function isCrossOrigin() {
@@ -251,59 +413,233 @@
@@ -251,59 +413,233 @@
} catch { /* */ }
} catch { /* */ }
}
}
+ // ── Layers: toasts never draw over an open menu, panel or dialog ─────────
+ // What counts as open, read from the page itself: this file loads late (after the bell mounts), so an
+ // 'openvibe-menu' event fired before it is missed; the menu's own class and every bar toggle's aria-expanded are not.
+ const OPEN_LAYERS = '.ovs.is-open, .openvibe-navbar [aria-expanded="true"], .openvibe-notif-panel.open, dialog[open], [aria-modal="true"]:not([hidden])';
+ function layerBusy() {
+ if (_menuOpen) return true;
+ try { if (root.OpenVibePanels && typeof root.OpenVibePanels.anyOpen === 'function' && root.OpenVibePanels.anyOpen()) return true; } catch { /* */ }
+ if (document.body && document.body.classList && document.body.classList.contains('ov-panel-open')) return true;
+ // Only what is drawn counts: a closed menu may keep aria-modal while it sits at display: none.
+ try { for (const el of document.querySelectorAll(OPEN_LAYERS)) if (el.getClientRects().length) return true; } catch { /* */ }
+ return false;
+ }
+ let _layerWatch = false;
+ function watchLayers() {
+ if (_layerWatch || typeof document === 'undefined') return;
+ _layerWatch = true;
+ document.addEventListener('openvibe-menu', (e) => { _menuOpen = !!(e && e.detail && e.detail.open); syncLayer(); });
+ try {
+ // aria-expanded and open change only when something opens or closes, so watching them page-wide is cheap.
+ if (typeof root.MutationObserver === 'function' && document.body) new root.MutationObserver(() => syncLayer()).observe(document.body, { attributes: true, subtree: true, attributeFilter: ['aria-expanded', 'open', 'aria-modal'] });
+ if (typeof root.MutationObserver === 'function' && document.body) new root.MutationObserver(() => syncLayer()).observe(document.body, { attributes: true, attributeFilter: ['class'] });
+ } catch { /* */ }
+ // Without an observer, any click may have opened or closed something: check right after it.
+ document.addEventListener('click', () => Promise.resolve().then(syncLayer), true);
+ }
+ function syncLayer() {
+ if (!_toastContainer) return;
+ const busy = layerBusy();
+ const was = _toastContainer.classList.contains('ovn-held');
+ if (busy === was) return;
+ _toastContainer.classList.toggle('ovn-held', busy);
+ for (const el of _toastContainer.querySelectorAll('.openvibe-toast')) busy ? pauseToast(el) : resumeToast(el);
+ if (!busy) flushQueue();
+ }
+
// ── Toasts ──────────────────────────────────────────────
// ── Toasts ──────────────────────────────────────────────
function ensureToastContainer() {
function ensureToastContainer() {
if (_toastContainer) return;
if (_toastContainer) return;
_toastContainer = document.createElement('div');
_toastContainer = document.createElement('div');
_toastContainer.className = 'openvibe-toast-container';
_toastContainer.className = 'openvibe-toast-container';
+ _toastContainer.setAttribute('role', 'region');
+ _toastContainer.setAttribute('aria-label', 'Notifications');
+ _toastContainer.setAttribute('aria-live', 'polite');
document.body.appendChild(_toastContainer);
document.body.appendChild(_toastContainer);
+ watchLayers();
}
}
+ function placeContainer() {
+ const phone = isPhone();
+ _toastContainer.classList.toggle('ovn-phone', phone);
+ if (phone) {
+ // Under whatever bar the site has; above it when the bar is hidden on scroll.
+ const bar = document.querySelector('nav.openvibe-navbar, nav.navbar, header.navbar, .navbar');
+ const r = bar && bar.getBoundingClientRect ? bar.getBoundingClientRect() : null;
+ const top = r && r.bottom > 0 ? Math.round(r.bottom) + 8 : 10;
+ _toastContainer.style.setProperty('--ovn-top', top + 'px');
+ }
+ }
+ function visibleToasts() { return _toastContainer ? [..._toastContainer.querySelectorAll('.openvibe-toast:not(.removing)')] : []; }
+ function maxVisible() { return isPhone() ? 1 : MAX_TOASTS; }
+
+ // Urgent first: a full stack makes room by letting a lower-priority card go, never a higher one.
+ const RANK = { critical: 3, high: 2, normal: 1, low: 0 };
+ const rank = (n) => RANK[(n && n.priority) || 'normal'] ?? 1;
+ const _shownIds = new Set(); // one notification id is one card, however many times a poll or a push brings it
function showToast(n) {
function showToast(n) {
if (!_preferences.toasts) return;
if (!_preferences.toasts) return;
if (_preferences.muted_categories.includes(n.category)) return;
if (_preferences.muted_categories.includes(n.category)) return;
+ const id = n && n.id != null ? String(n.id) : null;
+ if (id) {
+ if (_shownIds.has(id)) return;
+ _shownIds.add(id);
+ if (_shownIds.size > 500) _shownIds.delete(_shownIds.values().next().value);
+ }
ensureToastContainer();
ensureToastContainer();
- while (_toastContainer.children.length >= MAX_TOASTS) _toastContainer.firstChild.remove();
- const rich = richOf(n);
+ const p = present(n);
+ // The same notice again within the window joins the card already showing (or waiting).
+ const now = Date.now();
+ const live = visibleToasts().find(el => el.__ovn && el.__ovn.key === p.key && now - el.__ovn.at < GROUP_WINDOW);
+ if (live) { mergeToast(live, n, p); return; }
+ const queued = _queue.find(q => q.p.key === p.key);
+ if (queued) { queued.count += 1; queued.n = rank(n) >= rank(queued.n) ? n : queued.n; queued.chips = [...new Set(queued.chips.concat(p.chips))]; return; }
+ _queue.push({ n, p, count: 1, chips: p.chips.slice() });
+ _queue.sort((a, b) => rank(b.n) - rank(a.n)); // stable: arrival order within a priority
+ flushQueue();
+ }
+ function flushQueue() {
+ if (!_toastContainer || layerBusy()) { if (_toastContainer) _toastContainer.classList.toggle('ovn-held', layerBusy()); updateMore(); return; }
+ placeContainer();
+ while (_queue.length) {
+ const vis = visibleToasts();
+ if (vis.length < maxVisible()) { renderToast(_queue.shift()); continue; }
+ const lowest = vis.reduce((a, el) => (rank(el.__ovn.n) < rank(a.__ovn.n) ? el : a), vis[0]);
+ if (rank(_queue[0].n) > rank(lowest.__ovn.n)) { dismissToast(lowest); continue; }
+ break;
+ }
+ updateMore();
+ }
+ /** "+N more" lives inside the card that is showing (a phone shows one at a time), never floating over the page. */
+ function updateMore() {
+ if (!_toastContainer) return;
+ const waiting = _queue.length;
+ const first = visibleToasts()[0];
+ _toastContainer.querySelectorAll('.openvibe-toast-more').forEach((m) => { if (!waiting || !isPhone() || !first || !first.contains(m)) m.remove(); });
+ if (!waiting || !isPhone() || !first) return;
+ let more = first.querySelector('.openvibe-toast-more');
+ if (!more) {
+ more = document.createElement('button');
+ more.type = 'button'; more.className = 'openvibe-toast-more';
+ more.addEventListener('click', (e) => { e.stopPropagation(); const f = visibleToasts()[0]; if (f) dismissToast(f); else flushQueue(); });
+ const sub = first.querySelector('.openvibe-toast-sub');
+ (sub || first.querySelector('.openvibe-toast-body') || first).appendChild(more);
+ }
+ more.textContent = `+${waiting} more`;
+ more.setAttribute('aria-label', `${waiting} more notification${waiting === 1 ? '' : 's'}: show the next`);
+ }
+ function renderToast(q) {
+ const { n, p } = q;
const toast = document.createElement('div');
const toast = document.createElement('div');
toast.className = `openvibe-toast priority-${esc(n.priority || 'normal')}`;
toast.className = `openvibe-toast priority-${esc(n.priority || 'normal')}`;
toast.dataset.notifId = n.id || '';
toast.dataset.notifId = n.id || '';
- const serviceLabel = n.service && n.service !== 'network' ? `<span class="service-badge">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';
- const actions = Array.isArray(rich.actions) ? rich.actions.filter(a => a && a.type !== 'input') : [];
+ toast.tabIndex = 0;
+ toast.style.setProperty('--ovn-tone', TONE[p.tone] || TONE.accent);
+ if (n.priority === 'critical') toast.setAttribute('role', 'alert');
+ toast.__ovn = { key: p.key, at: Date.now(), count: q.count, chips: q.chips.slice(), ids: n.id ? [n.id] : [], n };
+ const rich = richOf(n);
+ const custom = Array.isArray(rich.actions) ? rich.actions.filter(a => a && a.type !== 'input' && a.label).slice(0, 2) : [];
+ const actions = custom.length ? custom : (p.url ? [{ id: 'open', label: p.actionLabel || 'View', url: p.url, style: 'primary' }] : []);
toast.innerHTML = `
toast.innerHTML = `
- <div class="openvibe-toast-icon">${iconHtml(n)}</div>
+ ${glyphHtml(n, p)}
<div class="openvibe-toast-body">
<div class="openvibe-toast-body">
- <div class="openvibe-toast-title">${esc(n.title)}${serviceLabel}</div>
- <div class="openvibe-toast-msg">${esc(n.message || '')}</div>
- ${actions.length ? `<div class="openvibe-toast-actions">${actions.map(a => `<button data-action="${esc(a.id)}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</button>`).join('')}</div>` : ''}
+ <div class="openvibe-toast-title"><span class="t">${esc(p.title)}</span><span class="n" data-role="n"${q.count > 1 ? '' : ' hidden'}>×${q.count}</span></div>
+ <div class="openvibe-toast-sub">${esc(p.label || '')}${p.label ? '<span aria-hidden="true">·</span>' : ''}<span>now</span></div>
+ ${p.message ? `<div class="openvibe-toast-msg" data-role="msg">${esc(p.message)}</div>` : ''}
+ <div data-role="chips">${chipsHtml({ ...p, chips: q.chips })}</div>
+ ${actions.length ? `<div class="openvibe-toast-actions">${actions.map(a => a.url
+ ? `<a href="${esc(a.url)}" data-action="${esc(a.id || 'open')}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</a>`
+ : `<button type="button" data-action="${esc(a.id)}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</button>`).join('')}</div>` : ''}
</div>
</div>
- <button class="openvibe-toast-close" aria-label="Dismiss">×</button>`;
- const duration = TOAST_DURATION[n.priority] ?? 5000;
+ <button type="button" class="openvibe-toast-close" aria-label="Dismiss">×</button>`;
+ const duration = TOAST_DURATION[n.priority] ?? 5200;
+ toast.__ovnDuration = duration;
if (duration > 0) {
if (duration > 0) {
const bar = document.createElement('div');
const bar = document.createElement('div');
- bar.className = 'openvibe-toast-progress'; bar.style.width = '100%'; bar.style.transition = `width ${duration}ms linear`;
+ bar.className = 'openvibe-toast-progress';
toast.appendChild(bar);
toast.appendChild(bar);
- requestAnimationFrame(() => { bar.style.width = '0%'; });
- setTimeout(() => dismissToast(toast), duration);
+ startTimer(toast, duration);
}
}
toast.querySelector('.openvibe-toast-close').addEventListener('click', (e) => { e.stopPropagation(); dismissToast(toast); });
toast.querySelector('.openvibe-toast-close').addEventListener('click', (e) => { e.stopPropagation(); dismissToast(toast); });
- toast.addEventListener('click', () => {
- if (n.id) markRead([n.id]);
+ toast.addEventListener('click', (e) => {
+ if (e.target.closest && e.target.closest('[data-action]')) return;
+ const ids = toast.__ovn.ids; if (ids.length) markRead(ids);
dismissToast(toast);
dismissToast(toast);
- const u = urlOf(n); if (u) navigateTo(u);
+ if (p.url) navigateTo(p.url);
});
});
+ toast.addEventListener('keydown', (e) => { if (e.key === 'Escape') dismissToast(toast); });
toast.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', (e) => {
toast.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', (e) => {
e.stopPropagation();
e.stopPropagation();
- const a = actions.find(x => String(x.id) === btn.dataset.action);
- if (a && a.url) { navigateTo(a.url); return; }
+ const ids = toast.__ovn.ids; if (ids.length) markRead(ids);
+ const a = actions.find(x => String(x.id || 'open') === btn.dataset.action);
+ if (a && a.url) { e.preventDefault(); dismissToast(toast); navigateTo(a.url); return; }
if (_config.onAction) _config.onAction(n.id, btn.dataset.action, n);
if (_config.onAction) _config.onAction(n.id, btn.dataset.action, n);
dismissToast(toast);
dismissToast(toast);
}));
}));
+ // Hover or focus holds the timer; a swipe sideways (or up, on a phone) dismisses.
+ toast.addEventListener('mouseenter', () => pauseToast(toast));
+ toast.addEventListener('mouseleave', () => resumeToast(toast));
+ toast.addEventListener('focusin', () => pauseToast(toast));
+ toast.addEventListener('focusout', () => resumeToast(toast));
+ attachSwipe(toast);
_toastContainer.appendChild(toast);
_toastContainer.appendChild(toast);
}
}
+ function mergeToast(el, n, p) {
+ const st = el.__ovn;
+ if (n.id && st.ids.includes(n.id)) return;
+ st.count += 1; st.at = Date.now(); st.n = rank(n) >= rank(st.n) ? n : st.n;
+ if (n.id) st.ids.push(n.id);
+ st.chips = [...new Set(st.chips.concat(p.chips))].slice(0, 8);
+ const c = el.querySelector('[data-role="n"]'); if (c) { c.textContent = `×${st.count}`; c.hidden = false; }
+ const m = el.querySelector('[data-role="msg"]'); if (m && p.message) m.textContent = p.message;
+ const ch = el.querySelector('[data-role="chips"]'); if (ch) ch.innerHTML = chipsHtml({ ...p, chips: st.chips });
+ if (el.__ovnDuration > 0) startTimer(el, el.__ovnDuration);
+ }
+ function startTimer(el, ms) {
+ clearTimeout(el.__ovnTimer);
+ el.__ovnRemaining = ms; el.__ovnStarted = Date.now();
+ const bar = el.querySelector('.openvibe-toast-progress');
+ if (bar) { bar.style.transition = 'none'; bar.style.transform = 'scaleX(1)'; const go = () => { bar.style.transition = `transform ${ms}ms linear`; bar.style.transform = 'scaleX(0)'; }; (root.requestAnimationFrame || ((f) => f()))(go); }
+ if (_toastContainer && _toastContainer.classList.contains('ovn-held')) { pauseToast(el); return; }
+ el.__ovnTimer = setTimeout(() => dismissToast(el), ms);
+ }
+ function pauseToast(el) {
+ if (!el || !el.__ovnTimer) return;
+ clearTimeout(el.__ovnTimer); el.__ovnTimer = null;
+ el.__ovnRemaining = Math.max(800, (el.__ovnRemaining || 0) - (Date.now() - (el.__ovnStarted || Date.now())));
+ const bar = el.querySelector('.openvibe-toast-progress');
+ if (bar && root.getComputedStyle) { try { const t = root.getComputedStyle(bar).transform; bar.style.transition = 'none'; bar.style.transform = t && t !== 'none' ? t : 'scaleX(1)'; } catch { /* */ } }
+ }
+ function resumeToast(el) {
+ if (!el || el.__ovnTimer || !(el.__ovnDuration > 0) || el.classList.contains('removing')) return;
+ if (_toastContainer && _toastContainer.classList.contains('ovn-held')) return;
+ startTimer(el, el.__ovnRemaining || el.__ovnDuration);
+ }
+ function attachSwipe(el) {
+ if (typeof root.PointerEvent !== 'function') return;
+ let x0 = null, y0 = null, dx = 0, dy = 0;
+ el.addEventListener('pointerdown', (e) => { x0 = e.clientX; y0 = e.clientY; dx = dy = 0; });
+ el.addEventListener('pointermove', (e) => {
+ if (x0 == null) return;
+ dx = e.clientX - x0; dy = e.clientY - y0;
+ if (Math.abs(dx) > 8 || dy < -8) { el.style.transition = 'none'; el.style.transform = Math.abs(dx) >= Math.abs(dy) ? `translateX(${dx}px)` : `translateY(${Math.min(0, dy)}px)`; el.style.opacity = String(Math.max(0.2, 1 - Math.max(Math.abs(dx), -dy) / 220)); }
+ });
+ const end = () => {
+ if (x0 == null) return;
+ const gone = Math.abs(dx) > 90 || (isPhone() && dy < -40);
+ x0 = y0 = null;
+ if (gone) { el.__ovnSwiped = true; dismissToast(el); return; }
+ el.style.transition = 'transform .2s ease, opacity .2s ease'; el.style.transform = ''; el.style.opacity = '';
+ };
+ el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);
+ // A swipe is not a click on the card.
+ el.addEventListener('click', (e) => { if (el.__ovnSwiped) { e.stopImmediatePropagation(); e.preventDefault(); } }, true);
+ }
function dismissToast(el) {
function dismissToast(el) {
- if (!el || !el.parentNode) return;
+ if (!el || !el.parentNode || el.classList.contains('removing')) return;
+ clearTimeout(el.__ovnTimer);
el.classList.add('removing');
el.classList.add('removing');
- setTimeout(() => el.remove(), 300);
+ setTimeout(() => { el.remove(); flushQueue(); }, 240);
}
}
// ── Bell / badge ────────────────────────────────────────
// ── Bell / badge ────────────────────────────────────────
@@ -311,7 +647,7 @@
@@ -311,7 +647,7 @@
const bell = document.createElement('div');
const bell = document.createElement('div');
bell.className = 'openvibe-bell';
bell.className = 'openvibe-bell';
bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;
bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;
- bell.innerHTML = `<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg><span class="badge hidden">0</span>`;
+ bell.innerHTML = `${svg(GLYPH.bell)}<span class="badge hidden">0</span>`;
bell.addEventListener('click', () => togglePanel());
bell.addEventListener('click', () => togglePanel());
bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });
bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });
_bellEl = bell;
_bellEl = bell;
@@ -325,8 +661,9 @@
@@ -325,8 +661,9 @@
if (_unreadCount > 0) {
if (_unreadCount > 0) {
badge.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount);
badge.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount);
badge.classList.remove('hidden');
badge.classList.remove('hidden');
- if (!quiet && _unreadCount > prev) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 600); }
+ if (!quiet && _unreadCount > prev) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 700); }
} else badge.classList.add('hidden');
} else badge.classList.add('hidden');
+ _bellEl.setAttribute('aria-label', _unreadCount ? `Notifications, ${_unreadCount} unread` : 'Notifications');
}
}
for (const v of _views.values()) {
for (const v of _views.values()) {
const c = v.root.querySelector('.openvibe-nv-head .count');
const c = v.root.querySelector('.openvibe-nv-head .count');
@@ -336,37 +673,42 @@
@@ -336,37 +673,42 @@
}
}
// ── List views (panel + inbox share this) ───────────────
// ── List views (panel + inbox share this) ───────────────
- const SVG = {
- search: '<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>',
- check: '<svg viewBox="0 0 24 24"><path d="M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z"/></svg>',
- bellOn: '<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>',
- bellOff: '<svg viewBox="0 0 24 24"><path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.42v5l-2 2v1h13.73l2 2L21 19.72l-1-1.03zM12 22c1.11 0 2-.89 2-2h-4c0 1.11.89 2 2 2zm6-7.32V11c0-3.08-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.08-.42.12-.1.03-.2.07-.3.11h-.01c-.01 0-.01 0-.02.01-.23.09-.46.2-.68.31 0 0-.01 0-.01.01L18 14.68z"/></svg>',
- gear: '<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>',
- open: '<svg viewBox="0 0 24 24"><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>',
+ const ICON = {
+ search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
+ checkAll: '<path d="M2.5 12.5l4.5 4.5L17 7"/><path d="M11.5 16.5l.5.5L22 7"/>',
+ bellOn: GLYPH.bell,
+ bellOff: '<path d="M6.2 16.2V11a5.8 5.8 0 0 1 9.6-4.4M17.8 10.4v5.8l1.6 2H6"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0M3.5 3.5l17 17"/>',
+ gear: GLYPH.gear,
+ open: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10"/>',
+ read: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',
+ close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
+ empty: '<path d="M18 37V26a14 14 0 0 1 28 0v11l4 5H14z"/><path d="M27 49a5 5 0 0 0 10 0"/><path d="M50 14l4-4M53 22h5M44 8l1-5"/>',
};
};
function buildView(root, { mode }) {
function buildView(root, { mode }) {
const v = { root, mode, tab: 'all', category: '', q: '', items: [], offset: 0, hasMore: false, loading: false, seq: 0, ids: new Set() };
const v = { root, mode, tab: 'all', category: '', q: '', items: [], offset: 0, hasMore: false, loading: false, seq: 0, ids: new Set() };
+ const cats = ['stream', 'social', 'chat', 'economy', 'game', 'achievement', 'system', 'admin'];
root.innerHTML = `
root.innerHTML = `
<div class="openvibe-nv-head">
<div class="openvibe-nv-head">
<h3>Notifications <span class="count zero">0</span></h3>
<h3>Notifications <span class="count zero">0</span></h3>
<div class="openvibe-nv-actions">
<div class="openvibe-nv-actions">
- <button type="button" data-act="read-all" title="Mark all as read">${SVG.check}<span>Mark all read</span></button>
- <button type="button" data-act="push" title="Desktop / mobile push alerts on this device">${SVG.bellOff}</button>
- ${mode === 'panel' ? `<a href="${esc(inboxUrl())}" data-act="inbox" title="Open inbox">${SVG.open}</a>` : `<a href="${esc(inboxUrl().replace(/\/notifications$/, '/notifications#prefs'))}" data-act="prefs" title="Notification preferences">${SVG.gear}</a>`}
+ <button type="button" data-act="read-all" title="Mark all as read">${svg(ICON.checkAll)}<span>Mark all read</span></button>
+ <button type="button" data-act="push" title="Push alerts on this device">${svg(ICON.bellOff)}<span>Push alerts</span></button>
+ <a href="${esc(inboxUrl().replace(/\/notifications$/, '/notifications#prefs'))}" data-act="prefs" title="Notification settings">${svg(ICON.gear)}<span>Settings</span></a>
+ ${mode === 'panel' ? `<a href="${esc(inboxUrl())}" data-act="inbox" title="Open the full inbox">${svg(ICON.open)}<span>Open inbox</span></a>` : ''}
</div>
</div>
</div>
</div>
<div class="openvibe-nv-note" data-role="note" style="display:none"></div>
<div class="openvibe-nv-note" data-role="note" style="display:none"></div>
<div class="openvibe-nv-tools">
<div class="openvibe-nv-tools">
- <div class="openvibe-nv-search">${SVG.search}<input type="search" placeholder="Search notifications…" autocomplete="off" spellcheck="false"><button type="button" class="clear" aria-label="Clear search">×</button></div>
- <div class="openvibe-nv-chips">
- <button type="button" data-tab="all" class="active">All</button>
- <button type="button" data-tab="unread">Unread</button>
- <select data-role="category"><option value="">All categories</option>${Object.entries(CATEGORY_LABELS).map(([k, l]) => `<option value="${k}">${l}</option>`).join('')}</select>
+ <div class="openvibe-nv-search">${svg(ICON.search)}<input type="search" placeholder="Search notifications" aria-label="Search notifications" autocomplete="off" spellcheck="false"><button type="button" class="clear" aria-label="Clear search">×</button></div>
+ <div class="openvibe-nv-chips" role="tablist" aria-label="Show">
+ <button type="button" role="tab" data-tab="all" class="active" aria-selected="true">All</button>
+ <button type="button" role="tab" data-tab="unread" aria-selected="false">Unread</button>
+ ${cats.map(k => `<button type="button" role="tab" data-cat="${k}" aria-selected="false">${esc(CATEGORY_LABELS[k])}</button>`).join('')}
</div>
</div>
</div>
</div>
<div class="openvibe-nv-list" data-role="list"></div>
<div class="openvibe-nv-list" data-role="list"></div>
- <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'panel' ? `<a href="${esc(inboxUrl())}">Open full inbox →</a>` : ''}</div>`;
+ <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'panel' ? `<a href="${esc(inboxUrl())}">Full inbox →</a>` : ''}</div>`;
const list = root.querySelector('[data-role="list"]');
const list = root.querySelector('[data-role="list"]');
const input = root.querySelector('input[type="search"]');
const input = root.querySelector('input[type="search"]');
let qTimer = null;
let qTimer = null;
@@ -375,11 +717,13 @@
@@ -375,11 +717,13 @@
clearTimeout(qTimer); qTimer = setTimeout(() => { v.q = input.value.trim(); reload(v); }, 260);
clearTimeout(qTimer); qTimer = setTimeout(() => { v.q = input.value.trim(); reload(v); }, 260);
});
});
root.querySelector('.openvibe-nv-search .clear').addEventListener('click', () => { input.value = ''; v.q = ''; root.querySelector('.openvibe-nv-search').classList.remove('has-q'); reload(v); });
root.querySelector('.openvibe-nv-search .clear').addEventListener('click', () => { input.value = ''; v.q = ''; root.querySelector('.openvibe-nv-search').classList.remove('has-q'); reload(v); });
- root.querySelectorAll('[data-tab]').forEach(btn => btn.addEventListener('click', () => {
- root.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active')); btn.classList.add('active');
- v.tab = btn.dataset.tab; reload(v);
+ const chips = root.querySelectorAll('.openvibe-nv-chips button');
+ const select = (btn) => { chips.forEach(b => { b.classList.toggle('active', b === btn); b.setAttribute('aria-selected', String(b === btn)); }); };
+ chips.forEach(btn => btn.addEventListener('click', () => {
+ select(btn);
+ if (btn.dataset.cat) { v.tab = 'all'; v.category = btn.dataset.cat; } else { v.tab = btn.dataset.tab; v.category = ''; }
+ reload(v);
}));
}));
- root.querySelector('[data-role="category"]').addEventListener('change', (e) => { v.category = e.target.value; reload(v); });
root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());
root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());
root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));
root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));
// Infinite scroll: load the next page when the user nears the bottom.
// Infinite scroll: load the next page when the user nears the bottom.
@@ -388,50 +732,75 @@
@@ -388,50 +732,75 @@
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);
});
});
list.addEventListener('click', (e) => {
list.addEventListener('click', (e) => {
- const dismissBtn = e.target.closest('.dismiss');
const item = e.target.closest('.openvibe-notif-item');
const item = e.target.closest('.openvibe-notif-item');
if (!item) return;
if (!item) return;
const id = item.dataset.id;
const id = item.dataset.id;
const ids = (item.dataset.ids || id).split(',').filter(Boolean);
const ids = (item.dataset.ids || id).split(',').filter(Boolean);
- if (dismissBtn) { e.stopPropagation(); ids.forEach(x => dismiss(v, x)); return; }
+ const act = e.target.closest('[data-row]');
+ if (act) {
+ e.stopPropagation();
+ if (act.dataset.row === 'dismiss') ids.forEach(x => dismiss(v, x));
+ else if (act.dataset.row === 'read') markRead(ids);
+ else if (act.dataset.row === 'copy') copyText(act.dataset.cmd || '', act);
+ return;
+ }
const n = v.items.find(x => String(x.id) === id);
const n = v.items.find(x => String(x.id) === id);
const unreadIds = ids.filter(x => { const it = v.items.find(y => String(y.id) === x); return it && !isRead(it); });
const unreadIds = ids.filter(x => { const it = v.items.find(y => String(y.id) === x); return it && !isRead(it); });
if (unreadIds.length) markRead(unreadIds);
if (unreadIds.length) markRead(unreadIds);
- const u = urlOf(n);
+ const u = n && present(n).url;
if (u) navigateTo(u);
if (u) navigateTo(u);
});
});
refreshPushButton(v);
refreshPushButton(v);
_views.set(root, v);
_views.set(root, v);
return v;
return v;
}
}
+ function copyText(text, btn) {
+ const done = () => { if (btn) { const was = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = was; }, 1400); } };
+ try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, () => {}); return; } } catch { /* */ }
+ done();
+ }
function renderItems(v, { append = false } = {}) {
function renderItems(v, { append = false } = {}) {
const list = v.root.querySelector('[data-role="list"]');
const list = v.root.querySelector('[data-role="list"]');
- // The same event repeated back to back ("X is live!" six times) reads as one row with a count.
+ // The same notice repeated back to back ("X is live!" six times; three browser checks failing) reads as one
+ // row with a count and the sites it covers.
const groups = [];
const groups = [];
for (const n of v.items) {
for (const n of v.items) {
- const key = [n.title, n.message || '', n.service || '', n.category || ''].join('\u0001');
+ const p = present(n);
const g = groups[groups.length - 1];
const g = groups[groups.length - 1];
- if (g && g.key === key) { g.ids.push(String(n.id)); if (!isRead(n)) g.unread = true; } else groups.push({ key, n, ids: [String(n.id)], unread: !isRead(n) });
+ if (g && g.p.key === p.key) {
+ g.ids.push(String(n.id)); if (!isRead(n)) g.unread = true;
+ g.chips = [...new Set(g.chips.concat(p.chips))].slice(0, 8);
+ if (!g.cmd && p.command) g.cmd = p.command;
+ } else groups.push({ n, p, ids: [String(n.id)], unread: !isRead(n), chips: p.chips.slice(), cmd: p.command });
}
}
- const html = groups.map(({ n, ids, unread }) => {
+ let lastDay = append ? v.lastDay : null;
+ const html = groups.map(({ n, p, ids, unread, chips, cmd }) => {
try {
try {
- const cat = CATEGORY_LABELS[n.category] || n.category || '';
- const service = n.service && n.service !== 'network' ? `<span class="tag">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';
- return `<div class="openvibe-notif-item ${unread ? 'unread' : ''}" data-id="${esc(n.id)}" data-ids="${esc(ids.join(','))}" title="${esc(urlOf(n) ? 'Open' : '')}">
- <span class="icon">${iconHtml(n)}</span>
+ const day = dayLabel(n.created_at);
+ const head = day !== lastDay ? `<div class="openvibe-nv-day">${esc(day)}</div>` : '';
+ lastDay = day;
+ const when = esc(timeAgo(n.created_at));
+ const label = p.label ? `<span class="dot"></span><span>${esc(p.label)}</span>` : '';
+ return `${head}<div class="openvibe-notif-item ${unread ? 'unread' : ''}" data-id="${esc(n.id)}" data-ids="${esc(ids.join(','))}" style="--ovn-tone:${TONE[p.tone] || TONE.accent}" tabindex="0">
+ ${glyphHtml(n, p)}
<div class="content">
<div class="content">
- <div class="title">${esc(n.title)}${ids.length > 1 ? ` <span class="count" title="${ids.length} times">×${ids.length}</span>` : ''}</div>
- ${n.message ? `<div class="msg">${esc(n.message)}</div>` : ''}
- <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${esc(timeAgo(n.created_at))}</span>${service}${cat ? `<span class="tag">${esc(cat)}</span>` : ''}</div>
+ <div class="title"><span class="t">${esc(p.title)}</span>${ids.length > 1 ? `<span class="count" title="${ids.length} times">×${ids.length}</span>` : ''}</div>
+ ${p.message ? `<div class="msg">${esc(p.message)}</div>` : ''}
+ ${chipsHtml({ ...p, chips })}
+ ${cmd ? `<button type="button" class="copy" data-row="copy" data-cmd="${esc(cmd)}">Copy fix command</button>` : ''}
+ <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${when}</span>${label}<div class="row-actions">${unread ? `<button type="button" data-row="read" title="Mark as read" aria-label="Mark as read">${svg(ICON.read)}</button>` : ''}<button type="button" data-row="dismiss" title="Dismiss" aria-label="Dismiss">${svg(ICON.close)}</button></div></div>
</div>
</div>
- <button type="button" class="dismiss" title="Dismiss" aria-label="Dismiss">×</button>
+ ${unread ? '<span class="unread-dot" aria-label="Unread"></span>' : ''}
</div>`;
</div>`;
} catch { return ''; }
} catch { return ''; }
}).join('');
}).join('');
+ v.lastDay = lastDay;
if (!append) list.innerHTML = '';
if (!append) list.innerHTML = '';
if (!v.items.length) {
if (!v.items.length) {
- list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">${v.q ? '🔎' : '🔔'}</span>${v.q ? 'Nothing matches that search' : (v.tab === 'unread' ? 'You\'re all caught up' : 'No notifications yet')}</div>`;
+ const title = v.q ? 'Nothing matches that search' : (v.tab === 'unread' ? 'You\'re all caught up' : (v.category ? 'Nothing here yet' : 'No notifications yet'));
+ const sub = v.q ? 'Try other words.' : 'When something happens across OpenVibe, it lands here.';
+ list.innerHTML = `<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${esc(title)}</b>${esc(sub)}</div>`;
} else {
} else {
const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();
const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();
list.insertAdjacentHTML('beforeend', html);
list.insertAdjacentHTML('beforeend', html);
@@ -451,7 +820,7 @@
@@ -451,7 +820,7 @@
}
}
async function reload(v) {
async function reload(v) {
const seq = ++v.seq;
const seq = ++v.seq;
- v.loading = true; v.offset = 0; v.items = []; v.ids = new Set(); v.hasMore = false; v.total = null;
+ v.loading = true; v.offset = 0; v.items = []; v.ids = new Set(); v.hasMore = false; v.total = null; v.lastDay = null;
const list = v.root.querySelector('[data-role="list"]');
const list = v.root.querySelector('[data-role="list"]');
list.innerHTML = '<div class="openvibe-nv-more"><span class="spinner"></span></div>';
list.innerHTML = '<div class="openvibe-nv-more"><span class="spinner"></span></div>';
try {
try {
@@ -462,7 +831,7 @@
@@ -462,7 +831,7 @@
renderItems(v);
renderItems(v);
} catch (e) {
} catch (e) {
if (seq !== v.seq) return;
if (seq !== v.seq) return;
- list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">⚠️</span>${e.status === 401 ? 'Sign in to see your notifications' : 'Could not load notifications'}</div>`;
+ list.innerHTML = `<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${e.status === 401 ? 'Sign in to see your notifications' : 'Could not load notifications'}</b>${e.status === 401 ? '' : 'Check your connection and try again.'}</div>`;
} finally { if (seq === v.seq) v.loading = false; }
} finally { if (seq === v.seq) v.loading = false; }
}
}
async function loadMore(v) {
async function loadMore(v) {
@@ -477,7 +846,7 @@
@@ -477,7 +846,7 @@
v.items = v.items.concat(fresh); v.hasMore = !!data.has_more; v.offset += (data.notifications || []).length; v.total = data.total ?? v.total;
v.items = v.items.concat(fresh); v.hasMore = !!data.has_more; v.offset += (data.notifications || []).length; v.total = data.total ?? v.total;
// Re-render only the tail to keep the scroll position.
// Re-render only the tail to keep the scroll position.
const old = v.root.querySelector('.openvibe-nv-more'); if (old) old.remove();
const old = v.root.querySelector('.openvibe-nv-more'); if (old) old.remove();
- if (fresh.length) renderItems({ ...v, items: fresh }, { append: true });
+ if (fresh.length) { const tail = { ...v, items: fresh }; renderItems(tail, { append: true }); v.lastDay = tail.lastDay; }
else if (v.hasMore) { const list = v.root.querySelector('[data-role="list"]'); list.insertAdjacentHTML('beforeend', '<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>'); list.querySelector('[data-act="more"]').addEventListener('click', () => loadMore(v)); }
else if (v.hasMore) { const list = v.root.querySelector('[data-role="list"]'); list.insertAdjacentHTML('beforeend', '<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>'); list.querySelector('[data-act="more"]').addEventListener('click', () => loadMore(v)); }
const st = v.root.querySelector('[data-role="status"]');
const st = v.root.querySelector('[data-role="status"]');
if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.tab === 'unread' ? ' unread' : ''}` : '';
if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.tab === 'unread' ? ' unread' : ''}` : '';
@@ -487,7 +856,10 @@
@@ -487,7 +856,10 @@
function applyReadLocally(v, ids, all) {
function applyReadLocally(v, ids, all) {
const set = new Set((ids || []).map(String));
const set = new Set((ids || []).map(String));
for (const n of v.items) if (all || set.has(String(n.id))) n.is_read = 1;
for (const n of v.items) if (all || set.has(String(n.id))) n.is_read = 1;
- v.root.querySelectorAll('.openvibe-notif-item.unread').forEach(el => { if (all || set.has(el.dataset.id)) el.classList.remove('unread'); });
+ v.root.querySelectorAll('.openvibe-notif-item.unread').forEach(el => {
+ const elIds = (el.dataset.ids || el.dataset.id || '').split(',');
+ if (all || elIds.some(x => set.has(x))) { el.classList.remove('unread'); el.querySelector('.unread-dot')?.remove(); el.querySelector('[data-row="read"]')?.remove(); }
+ });
}
}
// ── Mutations ───────────────────────────────────────────
// ── Mutations ───────────────────────────────────────────
@@ -513,7 +885,12 @@
@@ -513,7 +885,12 @@
async function dismiss(v, id) {
async function dismiss(v, id) {
const n = v.items.find(x => String(x.id) === String(id));
const n = v.items.find(x => String(x.id) === String(id));
v.items = v.items.filter(x => String(x.id) !== String(id));
v.items = v.items.filter(x => String(x.id) !== String(id));
- v.root.querySelector(`.openvibe-notif-item[data-id="${CSS.escape(String(id))}"]`)?.remove();
+ const row = v.root.querySelector(`.openvibe-notif-item[data-id="${CSS.escape(String(id))}"]`);
+ if (row) {
+ const rest = (row.dataset.ids || '').split(',').filter(x => x && x !== String(id));
+ if (rest.length) { row.dataset.ids = rest.join(','); row.dataset.id = rest[0]; const c = row.querySelector('.title .count'); if (c) { if (rest.length > 1) c.textContent = `×${rest.length}`; else c.remove(); } }
+ else row.remove();
+ }
if (v.total != null) v.total = Math.max(0, v.total - 1);
if (v.total != null) v.total = Math.max(0, v.total - 1);
if (!v.items.length) renderItems(v);
if (!v.items.length) renderItems(v);
try {
try {
@@ -537,7 +914,9 @@
@@ -537,7 +914,9 @@
// Something new arrived: fetch what's newer than the last thing we saw.
// Something new arrived: fetch what's newer than the last thing we saw.
const params = new URLSearchParams({ unread_only: '1', limit: '5', since: _lastSeenAt });
const params = new URLSearchParams({ unread_only: '1', limit: '5', since: _lastSeenAt });
const nd = await apiJson(`/api/notifications?${params}`);
const nd = await apiJson(`/api/notifications?${params}`);
- const fresh = (nd.notifications || []).slice().reverse();
+ // Only what is unread and newer than the last thing seen, whatever the server answered.
+ const since = _lastSeenAt;
+ const fresh = (nd.notifications || []).filter(n => n && !n.is_read && (!since || String(n.created_at || '') > since)).slice().reverse();
for (const n of fresh) { showToast(n); playSound(n.priority); }
for (const n of fresh) { showToast(n); playSound(n.priority); }
// Any open list should reflect the new items too.
// Any open list should reflect the new items too.
for (const v of _views.values()) if (v.mode === 'inbox' || (_panelEl && _panelEl.classList.contains('open'))) reload(v);
for (const v of _views.values()) if (v.mode === 'inbox' || (_panelEl && _panelEl.classList.contains('open'))) reload(v);
@@ -610,6 +989,7 @@
@@ -610,6 +989,7 @@
function createPanel() {
function createPanel() {
const panel = document.createElement('div');
const panel = document.createElement('div');
panel.className = 'openvibe-notif-panel';
panel.className = 'openvibe-notif-panel';
+ panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Notifications');
document.body.appendChild(panel);
document.body.appendChild(panel);
buildView(panel, { mode: 'panel' });
buildView(panel, { mode: 'panel' });
document.addEventListener('click', (e) => {
document.addEventListener('click', (e) => {
@@ -619,7 +999,7 @@
@@ -619,7 +999,7 @@
_panelEl = panel;
_panelEl = panel;
return panel;
return panel;
}
}
- function closePanel() { _panelEl?.classList.remove('open'); _bellEl?.classList.remove('open'); }
+ function closePanel() { _panelEl?.classList.remove('open'); _bellEl?.classList.remove('open'); syncLayer(); }
function togglePanel() {
function togglePanel() {
if (!_panelEl) createPanel();
if (!_panelEl) createPanel();
const opening = !_panelEl.classList.contains('open');
const opening = !_panelEl.classList.contains('open');
@@ -633,6 +1013,8 @@
@@ -633,6 +1013,8 @@
}
}
_panelEl.classList.toggle('open', opening);
_panelEl.classList.toggle('open', opening);
_bellEl?.classList.toggle('open', opening);
_bellEl?.classList.toggle('open', opening);
+ _bellEl?.setAttribute('aria-expanded', String(opening));
+ syncLayer();
if (opening) { reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread(); }
if (opening) { reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread(); }
}
}
@@ -685,8 +1067,8 @@
@@ -685,8 +1067,8 @@
if (!st.supported) { btn.style.display = 'none'; return; }
if (!st.supported) { btn.style.display = 'none'; return; }
btn.style.display = '';
btn.style.display = '';
btn.classList.toggle('on', st.subscribed);
btn.classList.toggle('on', st.subscribed);
- btn.innerHTML = st.subscribed ? `${SVG.bellOn}<span>Push on</span>` : `${SVG.bellOff}<span>Enable push</span>`;
- btn.title = st.subscribed ? 'Push alerts are on for this device — click to turn off' : (st.permission === 'denied' ? 'Notifications are blocked in your browser settings' : 'Get desktop / mobile alerts on this device even when the tab is closed');
+ btn.innerHTML = st.subscribed ? `${svg(ICON.bellOn)}<span>Push on</span>` : `${svg(ICON.bellOff)}<span>Enable push</span>`;
+ btn.title = st.subscribed ? 'Push alerts are on for this device: select to turn off' : (st.permission === 'denied' ? 'Notifications are blocked in your browser settings' : 'Get alerts on this device even when the site is closed');
btn.disabled = st.permission === 'denied' && !st.subscribed;
btn.disabled = st.permission === 'denied' && !st.subscribed;
}
}
async function togglePush(v) {
async function togglePush(v) {
@@ -694,8 +1076,8 @@
@@ -694,8 +1076,8 @@
const say = (msg, ms = 4000) => { if (!note) return; note.textContent = msg; note.style.display = ''; clearTimeout(note._t); note._t = setTimeout(() => { note.style.display = 'none'; }, ms); };
const say = (msg, ms = 4000) => { if (!note) return; note.textContent = msg; note.style.display = ''; clearTimeout(note._t); note._t = setTimeout(() => { note.style.display = 'none'; }, ms); };
try {
try {
const st = await pushStatus();
const st = await pushStatus();
- if (st.subscribed) { await disablePush(); say('Push alerts turned off for this device.'); }
- else { await enablePush(); say('Push alerts enabled — you\'ll get alerts here even when this site is closed.'); }
+ if (st.subscribed) { await disablePush(); say('Push alerts are off for this device.'); }
+ else { await enablePush(); say('Push alerts are on: you\'ll get them here even when this site is closed.'); }
} catch (e) { say(e.message || 'Could not change push setting', 6000); }
} catch (e) { say(e.message || 'Could not change push setting', 6000); }
for (const view of _views.values()) refreshPushButton(view);
for (const view of _views.values()) refreshPushButton(view);
}
}
@@ -716,7 +1098,7 @@
@@ -716,7 +1098,7 @@
stopPolling();
stopPolling();
stopRealtime();
stopRealtime();
_toastContainer?.remove(); _panelEl?.remove();
_toastContainer?.remove(); _panelEl?.remove();
- _toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null;
+ _toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null; _queue.length = 0;
},
},
/** Create and inject the bell icon. Returns the DOM element. */
/** Create and inject the bell icon. Returns the DOM element. */
createBell(container) {
createBell(container) {
@@ -732,7 +1114,7 @@
@@ -732,7 +1114,7 @@
if (!container) return null;
if (!container) return null;
container.classList.add('openvibe-notif-inbox');
container.classList.add('openvibe-notif-inbox');
const v = buildView(container, { mode: 'inbox' });
const v = buildView(container, { mode: 'inbox' });
- if (opts.tab) { v.tab = opts.tab; container.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('active', b.dataset.tab === opts.tab)); }
+ if (opts.tab) { v.tab = opts.tab; container.querySelectorAll('[data-tab]').forEach(b => { b.classList.toggle('active', b.dataset.tab === opts.tab); b.setAttribute('aria-selected', String(b.dataset.tab === opts.tab)); }); }
reload(v);
reload(v);
return v;
return v;
},
},
@@ -754,6 +1136,8 @@
@@ -754,6 +1136,8 @@
},
},
/** The realtime feed's state ({ state: 'off' | 'ticket' | 'connecting' | 'open' | 'backoff' | 'hidden' | 'blocked' | 'failed' | 'signed-out' | 'unavailable', … }), or null when off. */
/** The realtime feed's state ({ state: 'off' | 'ticket' | 'connecting' | 'open' | 'backoff' | 'hidden' | 'blocked' | 'failed' | 'signed-out' | 'unavailable', … }), or null when off. */
realtimeState() { return _live ? _live.state() : null; },
realtimeState() { return _live ? _live.state() : null; },
+ /** How a notification reads (title, message, chips, tone, glyph, group key): the same for toasts and the list. */
+ present,
togglePanel, closePanel,
togglePanel, closePanel,
enablePush, disablePush, pushStatus,
enablePush, disablePush, pushStatus,
get unreadCount() { return _unreadCount; },
get unreadCount() { return _unreadCount; },
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.26.1",
+ "version": "4.27.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -361,7 +361,9 @@ const created = (subject, over = {}) => ({
@@ -361,7 +361,9 @@ const created = (subject, over = {}) => ({
const { brotli } = require('../scripts/size-report');
const { brotli } = require('../scripts/size-report');
const size = (f) => brotli(fs.readFileSync(path.join(ROOT, f)));
const size = (f) => brotli(fs.readFileSync(path.join(ROOT, f)));
assert.ok(size('notification-live.js') <= 3 * 1024, `notification-live.js is ${(size('notification-live.js') / 1024).toFixed(2)} KB brotli, budget 3 KB`);
assert.ok(size('notification-live.js') <= 3 * 1024, `notification-live.js is ${(size('notification-live.js') / 1024).toFixed(2)} KB brotli, budget 3 KB`);
- assert.ok(size('notification-ui.js') <= 13 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 13 KB`);
+ // 13 → 20 KB on 2026-10-28 (4.27.0, Notifications 2.0: line glyphs, System cards, grouping, layering, priority, the
+ // bell's rich list). It loads only after sign-in, when the bell mounts, never on first paint; measured 19.16 KB.
+ assert.ok(size('notification-ui.js') <= 20 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 20 KB`);
console.log('notification live: all checks passed');
console.log('notification live: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });