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)}