feat(news): announce with a toast, open the modal only on request

Entering the editor could stack two overlays: the starter-template
dialog opened on mount, then four seconds later the "What's new" modal
landed on top of it. Two dimmed backdrops over each other, and the
smaller, *less* important surface in front — the z-indexes said it
outright, news 10000 against the starter's 9000.

The delay made it worse rather than gentler. Arriving after the user has
started reading the templates is the most disruptive moment available;
appearing instantly would at least have let them clear it before
engaging.

The rule this restores: a modal is for what BLOCKS, and an announcement
never blocks. News now slides into the corner as a toast that dims
nothing, steals no focus (role="status", polite), and retires by itself.
The full post is one click behind "Read more" — a modal the user asked
for is a categorically different thing from one thrown at them — and
stays reachable from Help ▸ What's new once the toast is gone.

Worth noting the audience argument too: the starter dialog only fires on
a PRISTINE visit, i.e. for someone brand new, and "what's new" is
meaningless without a baseline to compare against. Returning users, the
ones it is actually written for, arrive with a project loaded and never
saw the collision.

Renamed NewsModal → NewsAnnouncer: it is not a modal by default any more,
and leaving the old name would have mislead the next reader.

Strings are new keys under `news.*`, filled in for all nine locales — the
previous modal had "What's new" and "Got it" hardcoded in English.

Toast sits at z-index 8000, deliberately BELOW the starter dialog, so if
the two ever coincide again the blocking task keeps the foreground.
This commit is contained in:
David Montero Crespo 2026-08-10 19:52:55 +02:00
parent 28eca48168
commit 99471d0657
15 changed files with 296 additions and 73 deletions

View File

@ -158,6 +158,14 @@ export const EditorMenuBar: React.FC = () => {
];
const helpItems: Item[] = [
// Only present once a post has been delivered — the announcement is a
// toast now, and this is how it stays reachable after it retires.
{
kind: 'command',
id: 'help.whatsNew',
label: t('news.kicker', "What's new"),
optional: true,
},
{ kind: 'link', href: `${SITE}/docs`, label: t('header.nav.documentation', 'Documentation') },
{ kind: 'link', href: '/examples', label: t('header.nav.examples', 'Examples') },
{ kind: 'link', href: `${SITE}/pricing`, label: t('header.nav.pricing', 'Pricing') },

View File

@ -229,3 +229,85 @@
.velxio-news-ok:hover {
filter: brightness(1.1);
}
/* ── Toast ────────────────────────────────────────────────────────────────
The default surface for an announcement. No backdrop and no inset:0 —
the page stays fully usable underneath, which is the whole point. Sits
above app chrome but BELOW the starter dialog's 9000, so if the two ever
coincide the blocking task keeps the foreground. */
.velxio-news-toast {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 8000;
width: min(340px, calc(100vw - 32px));
background: #1e2228;
border: 1px solid #3c4049;
border-radius: 10px;
padding: 12px 14px 10px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
animation: velxio-news-toast-in 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes velxio-news-toast-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* Respect a reduced-motion preference: appear, don't slide. */
@media (prefers-reduced-motion: reduce) {
.velxio-news-toast { animation: none; }
}
.velxio-news-toast-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.velxio-news-toast-close {
background: none;
border: none;
color: #8b93a0;
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.velxio-news-toast-close:hover {
color: #fff;
}
.velxio-news-toast-title {
color: #e8eaed;
font-size: 13.5px;
font-weight: 600;
line-height: 1.35;
margin: 6px 0 8px;
}
.velxio-news-toast-more {
background: none;
border: none;
color: #4da3ff;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
padding: 0;
}
.velxio-news-toast-more:hover {
text-decoration: underline;
}
/* The toast would otherwise sit on top of the mobile bottom bar. */
@media (max-width: 700px) {
.velxio-news-toast {
right: 12px;
left: 12px;
bottom: 12px;
width: auto;
}
}

View File

@ -0,0 +1,146 @@
/**
* Product news ("What's New") — a toast, and a modal only on request.
*
* This used to auto-open a modal a few seconds after the editor loaded.
* That put a passive announcement on top of whatever the user was doing,
* and on a pristine visit it landed squarely over the starter-template
* dialog: two stacked overlays, two dimmed backdrops, and the *less*
* important one on top (news z-index 10000 vs starter 9000). The delay
* made it worse, not better — it arrived after the user had begun
* reading, which is the most disruptive moment to interrupt.
*
* So: a modal is for what BLOCKS, and an announcement never blocks. The
* post now slides in as a corner toast that steals no focus and dims
* nothing, and auto-retires. The full post is one click away — a modal
* the user asked for is a different thing from one thrown at them — and
* stays reachable afterwards from Help ▸ What's new.
*
* The queue/seen logic still lives in lib/newsSource.ts (OSS:
* localStorage over /api/news/feed; pro: server-backed per user). The
* fetch is delayed so it never competes with editor startup, and every
* failure path resolves to "nothing shown".
*/
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { getNextNews, type NewsPost } from '../../lib/newsSource';
import { NewsMarkdown } from './NewsMarkdown';
import { registerEditorCommand } from '../../lib/editorCommands';
import './NewsAnnouncer.css';
const FETCH_DELAY_MS = 4000;
/** Long enough to read a title and decide, short enough to not linger. */
const TOAST_DISMISS_MS = 14000;
export function NewsAnnouncer() {
const { t } = useTranslation();
const [post, setPost] = useState<NewsPost | null>(null);
const [expanded, setExpanded] = useState(false);
const [toastVisible, setToastVisible] = useState(false);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => {
void getNextNews().then((p) => {
if (cancelled || !p) return;
setPost(p);
setToastVisible(true);
});
}, FETCH_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, []);
// Help ▸ What's new re-opens the last delivered post. Registered even
// with nothing to show yet; the handler no-ops until a post arrives,
// which keeps the menu row's enabled state honest once one has.
useEffect(() => {
if (!post) return;
return registerEditorCommand('help.whatsNew', () => {
setToastVisible(false);
setExpanded(true);
});
}, [post]);
// Auto-retire the toast. Cancelled if the user opens the full post, so
// reading it never races with the timer.
useEffect(() => {
if (!toastVisible || expanded) return;
const timer = setTimeout(() => setToastVisible(false), TOAST_DISMISS_MS);
return () => clearTimeout(timer);
}, [toastVisible, expanded]);
if (!post) return null;
if (expanded) {
return createPortal(
<div className="velxio-news-overlay" onClick={() => setExpanded(false)}>
<div
className="velxio-news-modal"
role="dialog"
aria-modal="true"
aria-label={post.title}
onClick={(e) => e.stopPropagation()}
>
<div className="velxio-news-header">
<div>
<span className="velxio-news-kicker">
{t('news.kicker', "What's new")}
</span>
<h2 className="velxio-news-title">{post.title}</h2>
</div>
<button
className="velxio-news-close"
aria-label={t('news.close', 'Close')}
onClick={() => setExpanded(false)}
>
×
</button>
</div>
<div className="velxio-news-body">
<NewsMarkdown>{post.body_md}</NewsMarkdown>
</div>
<div className="velxio-news-footer">
<button className="velxio-news-ok" onClick={() => setExpanded(false)}>
{t('news.gotIt', 'Got it')}
</button>
</div>
</div>
</div>,
document.body,
);
}
if (!toastVisible) return null;
return createPortal(
// role="status" (not "alert"): polite, so a screen reader finishes the
// current utterance instead of cutting the user off — the accessible
// equivalent of not stealing focus.
<div className="velxio-news-toast" role="status" aria-live="polite">
<div className="velxio-news-toast-head">
<span className="velxio-news-kicker">{t('news.kicker', "What's new")}</span>
<button
className="velxio-news-toast-close"
aria-label={t('news.close', 'Close')}
onClick={() => setToastVisible(false)}
>
×
</button>
</div>
<div className="velxio-news-toast-title">{post.title}</div>
<button
className="velxio-news-toast-more"
onClick={() => {
setExpanded(true);
}}
>
{t('news.readMore', 'Read more')} →
</button>
</div>,
document.body,
);
}

View File

@ -1,71 +0,0 @@
/**
* "What's New" modal — shows the next pending product-news post when the
* user enters the editor. At most one per day, each post only ever once;
* the queue/seen logic lives in lib/newsSource.ts (OSS: localStorage over
* the local /api/news/feed proxy; pro overlay: server-backed per-user).
*
* The fetch is delayed a few seconds so it never competes with editor
* startup, and every failure path resolves to "no modal".
*/
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { getNextNews, type NewsPost } from '../../lib/newsSource';
import { NewsMarkdown } from './NewsMarkdown';
import './NewsModal.css';
const FETCH_DELAY_MS = 4000;
export function NewsModal() {
const [post, setPost] = useState<NewsPost | null>(null);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => {
void getNextNews().then((p) => {
if (!cancelled && p) setPost(p);
});
}, FETCH_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, []);
if (!post) return null;
return createPortal(
<div className="velxio-news-overlay" onClick={() => setPost(null)}>
<div
className="velxio-news-modal"
role="dialog"
aria-modal="true"
aria-label={post.title}
onClick={(e) => e.stopPropagation()}
>
<div className="velxio-news-header">
<div>
<span className="velxio-news-kicker">What's new</span>
<h2 className="velxio-news-title">{post.title}</h2>
</div>
<button
className="velxio-news-close"
aria-label="Close"
onClick={() => setPost(null)}
>
×
</button>
</div>
<div className="velxio-news-body">
<NewsMarkdown>{post.body_md}</NewsMarkdown>
</div>
<div className="velxio-news-footer">
<button className="velxio-news-ok" onClick={() => setPost(null)}>
Got it
</button>
</div>
</div>
</div>,
document.body,
);
}

View File

@ -433,5 +433,11 @@
"privateHint": "Nur du kannst dieses Projekt sehen."
}
}
},
"news": {
"kicker": "Neuigkeiten",
"readMore": "Mehr erfahren",
"gotIt": "Verstanden",
"close": "Schließen"
}
}

View File

@ -433,5 +433,11 @@
"privateHint": "Only you can see this project."
}
}
},
"news": {
"kicker": "What's new",
"readMore": "Read more",
"gotIt": "Got it",
"close": "Close"
}
}

View File

@ -433,5 +433,11 @@
"privateHint": "Solo vos podés ver este proyecto."
}
}
},
"news": {
"kicker": "Novedades",
"readMore": "Leer más",
"gotIt": "Entendido",
"close": "Cerrar"
}
}

View File

@ -433,5 +433,11 @@
"privateHint": "Seul vous pouvez voir ce projet."
}
}
},
"news": {
"kicker": "Nouveautés",
"readMore": "En savoir plus",
"gotIt": "Compris",
"close": "Fermer"
}
}

View File

@ -433,5 +433,11 @@
"privateHint": "Solo tu puoi vedere questo progetto."
}
}
},
"news": {
"kicker": "Novità",
"readMore": "Scopri di più",
"gotIt": "Ho capito",
"close": "Chiudi"
}
}

View File

@ -433,5 +433,11 @@
"privateHint": "あなただけがこのプロジェクトを見ることができます。"
}
}
},
"news": {
"kicker": "新着情報",
"readMore": "詳細を見る",
"gotIt": "了解",
"close": "閉じる"
}
}

View File

@ -433,5 +433,11 @@
"privateHint": "Apenas você pode ver este projeto."
}
}
},
"news": {
"kicker": "Novidades",
"readMore": "Saiba mais",
"gotIt": "Entendi",
"close": "Fechar"
}
}

View File

@ -433,5 +433,11 @@
"privateHint": "Только вы видите этот проект."
}
}
},
"news": {
"kicker": "Что нового",
"readMore": "Подробнее",
"gotIt": "Понятно",
"close": "Закрыть"
}
}

View File

@ -433,5 +433,11 @@
"privateHint": "只有你能看到此项目。"
}
}
},
"news": {
"kicker": "最新动态",
"readMore": "了解更多",
"gotIt": "知道了",
"close": "关闭"
}
}

View File

@ -31,6 +31,10 @@ export type EditorCommandId =
// hides them outright — see `optional` in EditorMenuBar.
| 'account.myProjects'
| 'account.login'
// Re-opens the last delivered "What's new" post. Registered by
// NewsAnnouncer once a post has actually arrived, so the Help row is
// absent while there is nothing to re-read.
| 'help.whatsNew'
| 'project.share'
| 'project.githubSync'
| 'firmware.upload'

View File

@ -20,7 +20,7 @@ import { Oscilloscope } from '../components/simulator/Oscilloscope';
import { AppHeader } from '../components/layout/AppHeader';
import { triggerSaveAction } from '../lib/proSaveAction';
import { GitHubStarBanner } from '../components/layout/GitHubStarBanner';
import { NewsModal } from '../components/layout/NewsModal';
import { NewsAnnouncer } from '../components/layout/NewsAnnouncer';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { useEditorStore } from '../store/useEditorStore';
import { useCompileLogsStore } from '../store/useCompileLogsStore';
@ -758,7 +758,7 @@ export const EditorPage: React.FC = () => {
round={starRound}
/>
)}
<NewsModal />
<NewsAnnouncer />
<NewProjectDialog
isOpen={showNewProjectDialog}
onClose={() => setShowNewProjectDialog(false)}