From 99471d06572390738a2e90a5cc656126d3bb123f Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Mon, 10 Aug 2026 19:52:55 +0200 Subject: [PATCH] feat(news): announce with a toast, open the modal only on request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/editor/EditorMenuBar.tsx | 8 + .../{NewsModal.css => NewsAnnouncer.css} | 82 ++++++++++ .../src/components/layout/NewsAnnouncer.tsx | 146 ++++++++++++++++++ frontend/src/components/layout/NewsModal.tsx | 71 --------- frontend/src/i18n/locales/de/common.json | 6 + frontend/src/i18n/locales/en/common.json | 6 + frontend/src/i18n/locales/es/common.json | 6 + frontend/src/i18n/locales/fr/common.json | 6 + frontend/src/i18n/locales/it/common.json | 6 + frontend/src/i18n/locales/ja/common.json | 6 + frontend/src/i18n/locales/pt-br/common.json | 6 + frontend/src/i18n/locales/ru/common.json | 6 + frontend/src/i18n/locales/zh-cn/common.json | 6 + frontend/src/lib/editorCommands.ts | 4 + frontend/src/pages/EditorPage.tsx | 4 +- 15 files changed, 296 insertions(+), 73 deletions(-) rename frontend/src/components/layout/{NewsModal.css => NewsAnnouncer.css} (67%) create mode 100644 frontend/src/components/layout/NewsAnnouncer.tsx delete mode 100644 frontend/src/components/layout/NewsModal.tsx diff --git a/frontend/src/components/editor/EditorMenuBar.tsx b/frontend/src/components/editor/EditorMenuBar.tsx index dc33a7ed..ba076baa 100644 --- a/frontend/src/components/editor/EditorMenuBar.tsx +++ b/frontend/src/components/editor/EditorMenuBar.tsx @@ -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') }, diff --git a/frontend/src/components/layout/NewsModal.css b/frontend/src/components/layout/NewsAnnouncer.css similarity index 67% rename from frontend/src/components/layout/NewsModal.css rename to frontend/src/components/layout/NewsAnnouncer.css index e2f4cd3a..a07ab8ed 100644 --- a/frontend/src/components/layout/NewsModal.css +++ b/frontend/src/components/layout/NewsAnnouncer.css @@ -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; + } +} diff --git a/frontend/src/components/layout/NewsAnnouncer.tsx b/frontend/src/components/layout/NewsAnnouncer.tsx new file mode 100644 index 00000000..18b71ae6 --- /dev/null +++ b/frontend/src/components/layout/NewsAnnouncer.tsx @@ -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(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( +
setExpanded(false)}> +
e.stopPropagation()} + > +
+
+ + {t('news.kicker', "What's new")} + +

{post.title}

+
+ +
+
+ {post.body_md} +
+
+ +
+
+
, + 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. +
+
+ {t('news.kicker', "What's new")} + +
+
{post.title}
+ +
, + document.body, + ); +} diff --git a/frontend/src/components/layout/NewsModal.tsx b/frontend/src/components/layout/NewsModal.tsx deleted file mode 100644 index b128fd7a..00000000 --- a/frontend/src/components/layout/NewsModal.tsx +++ /dev/null @@ -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(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( -
setPost(null)}> -
e.stopPropagation()} - > -
-
- What's new -

{post.title}

-
- -
-
- {post.body_md} -
-
- -
-
-
, - document.body, - ); -} diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json index a1d84208..20125535 100644 --- a/frontend/src/i18n/locales/de/common.json +++ b/frontend/src/i18n/locales/de/common.json @@ -433,5 +433,11 @@ "privateHint": "Nur du kannst dieses Projekt sehen." } } + }, + "news": { + "kicker": "Neuigkeiten", + "readMore": "Mehr erfahren", + "gotIt": "Verstanden", + "close": "Schließen" } } diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 70dad49f..76318e1b 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -433,5 +433,11 @@ "privateHint": "Only you can see this project." } } + }, + "news": { + "kicker": "What's new", + "readMore": "Read more", + "gotIt": "Got it", + "close": "Close" } } diff --git a/frontend/src/i18n/locales/es/common.json b/frontend/src/i18n/locales/es/common.json index bb549e4b..adf31d50 100644 --- a/frontend/src/i18n/locales/es/common.json +++ b/frontend/src/i18n/locales/es/common.json @@ -433,5 +433,11 @@ "privateHint": "Solo vos podés ver este proyecto." } } + }, + "news": { + "kicker": "Novedades", + "readMore": "Leer más", + "gotIt": "Entendido", + "close": "Cerrar" } } diff --git a/frontend/src/i18n/locales/fr/common.json b/frontend/src/i18n/locales/fr/common.json index 4d6ea222..7b77ffbc 100644 --- a/frontend/src/i18n/locales/fr/common.json +++ b/frontend/src/i18n/locales/fr/common.json @@ -433,5 +433,11 @@ "privateHint": "Seul vous pouvez voir ce projet." } } + }, + "news": { + "kicker": "Nouveautés", + "readMore": "En savoir plus", + "gotIt": "Compris", + "close": "Fermer" } } diff --git a/frontend/src/i18n/locales/it/common.json b/frontend/src/i18n/locales/it/common.json index 85519adf..d3b1cc3a 100644 --- a/frontend/src/i18n/locales/it/common.json +++ b/frontend/src/i18n/locales/it/common.json @@ -433,5 +433,11 @@ "privateHint": "Solo tu puoi vedere questo progetto." } } + }, + "news": { + "kicker": "Novità", + "readMore": "Scopri di più", + "gotIt": "Ho capito", + "close": "Chiudi" } } diff --git a/frontend/src/i18n/locales/ja/common.json b/frontend/src/i18n/locales/ja/common.json index 9fb40312..4ccdfd94 100644 --- a/frontend/src/i18n/locales/ja/common.json +++ b/frontend/src/i18n/locales/ja/common.json @@ -433,5 +433,11 @@ "privateHint": "あなただけがこのプロジェクトを見ることができます。" } } + }, + "news": { + "kicker": "新着情報", + "readMore": "詳細を見る", + "gotIt": "了解", + "close": "閉じる" } } diff --git a/frontend/src/i18n/locales/pt-br/common.json b/frontend/src/i18n/locales/pt-br/common.json index 95e00793..bca9d835 100644 --- a/frontend/src/i18n/locales/pt-br/common.json +++ b/frontend/src/i18n/locales/pt-br/common.json @@ -433,5 +433,11 @@ "privateHint": "Apenas você pode ver este projeto." } } + }, + "news": { + "kicker": "Novidades", + "readMore": "Saiba mais", + "gotIt": "Entendi", + "close": "Fechar" } } diff --git a/frontend/src/i18n/locales/ru/common.json b/frontend/src/i18n/locales/ru/common.json index 797917ff..8ad1c7c2 100644 --- a/frontend/src/i18n/locales/ru/common.json +++ b/frontend/src/i18n/locales/ru/common.json @@ -433,5 +433,11 @@ "privateHint": "Только вы видите этот проект." } } + }, + "news": { + "kicker": "Что нового", + "readMore": "Подробнее", + "gotIt": "Понятно", + "close": "Закрыть" } } diff --git a/frontend/src/i18n/locales/zh-cn/common.json b/frontend/src/i18n/locales/zh-cn/common.json index 54336df7..fd952e0d 100644 --- a/frontend/src/i18n/locales/zh-cn/common.json +++ b/frontend/src/i18n/locales/zh-cn/common.json @@ -433,5 +433,11 @@ "privateHint": "只有你能看到此项目。" } } + }, + "news": { + "kicker": "最新动态", + "readMore": "了解更多", + "gotIt": "知道了", + "close": "关闭" } } diff --git a/frontend/src/lib/editorCommands.ts b/frontend/src/lib/editorCommands.ts index e0daae43..b04aead4 100644 --- a/frontend/src/lib/editorCommands.ts +++ b/frontend/src/lib/editorCommands.ts @@ -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' diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index 2d973efb..de1cd578 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -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} /> )} - + setShowNewProjectDialog(false)}