diff --git a/README.md b/README.md
index 3151bc29..dd2a60b7 100644
--- a/README.md
+++ b/README.md
@@ -345,7 +345,7 @@ change the CORS origin used during local development.
| Variable | Default | Description |
| --- | --- | --- |
| `FRONTEND_URL` | `http://localhost:5173` | Origin allowed by CORS for local Vite dev |
-| `VELXIO_NEWS` | `on` | Product news: the backend fetches release notes and product news from velxio.dev's public feed (cached ~6h, anonymous, no identifiers sent) and the editor shows each item once as a "What's New" modal. Set to `off` to disable entirely; an offline host degrades to no news automatically. |
+| `VELXIO_NEWS` | `on` | Product news: the backend fetches release notes and product news from velxio.dev's public feed (cached ~6h, anonymous, no identifiers sent) and the editor shows each item once as a "What's New" modal. Posts may embed remote media (screenshots, GIFs, YouTube previews) which the viewer's browser loads directly from the hosting site; video players only load after an explicit click. Set to `off` to disable entirely; an offline host degrades to no news automatically. |
> **Deploying behind a reverse proxy?** The container listens on plain HTTP
> on port 80 and accepts any `Host` header — no `server_name` whitelist.
diff --git a/backend/app/api/routes/news.py b/backend/app/api/routes/news.py
index 5be843f7..11c1f28c 100644
--- a/backend/app/api/routes/news.py
+++ b/backend/app/api/routes/news.py
@@ -4,12 +4,15 @@ The editor shows a once-per-browser "What's New" modal (see
frontend/src/lib/newsSource.ts). Rather than every user's browser
fetching velxio.dev directly, the backend proxies the public feed:
- - users' browsers only ever talk to this server;
- - one cached fetch per instance instead of one per visitor;
+ - the FEED fetch never leaves this server (one cached fetch per
+ instance instead of one per visitor, no identifiers sent) — though
+ media embedded in a post (images/GIFs, YouTube thumbnails) does
+ load in the viewer's browser from wherever it's hosted, as the
+ README documents;
- a single, documented opt-out: VELXIO_NEWS=off (also the automatic
behavior when the host has no internet — errors degrade to []).
-The upstream feed is plain public JSON (no auth, no identifiers sent).
+The upstream feed is plain public JSON (no auth).
"""
from __future__ import annotations
diff --git a/frontend/src/components/layout/NewsMarkdown.tsx b/frontend/src/components/layout/NewsMarkdown.tsx
new file mode 100644
index 00000000..686b3afa
--- /dev/null
+++ b/frontend/src/components/layout/NewsMarkdown.tsx
@@ -0,0 +1,84 @@
+/**
+ * Markdown renderer for product-news content ("What's New" modal and the
+ * admin authoring preview — keep both on this one component so what the
+ * admin previews is exactly what users see).
+ *
+ * On top of plain GFM:
+ * - Any YouTube link (watch/shorts/youtu.be/embed) renders as a
+ * click-to-play preview: thumbnail + play badge, swapped for the
+ * youtube-nocookie iframe on click. Nothing loads from YouTube's
+ * player until the user opts in; the thumbnail is a static image.
+ * - Images (and GIFs) render inline. Deliberate product decision:
+ * news posts embed screenshots/demos, so viewers' browsers DO fetch
+ * remote media from the posts — documented in the OSS README next
+ * to the VELXIO_NEWS opt-out.
+ * - All links open in a new tab (the modal sits on top of the editor;
+ * navigating away would lose workspace state).
+ */
+
+import { useState } from 'react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+
+function youTubeId(href: string): string | null {
+ const m = href.match(
+ /^https?:\/\/(?:www\.|m\.)?(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/)|youtu\.be\/)([\w-]{11})/,
+ );
+ return m ? m[1] : null;
+}
+
+function YouTubePreview({ id }: { id: string }) {
+ const [playing, setPlaying] = useState(false);
+ if (playing) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ );
+}
+
+export function NewsMarkdown({ children }: { children: string }) {
+ return (
+ {
+ const id = href ? youTubeId(href) : null;
+ if (id) {
+ const text = Array.isArray(kids) ? kids.join('') : String(kids ?? '');
+ return (
+ <>
+
+ {text && text !== href && (
+ {kids}
+ )}
+ >
+ );
+ }
+ return (
+ {kids}
+ );
+ },
+ }}
+ >
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/layout/NewsModal.css b/frontend/src/components/layout/NewsModal.css
index 674da64a..e2f4cd3a 100644
--- a/frontend/src/components/layout/NewsModal.css
+++ b/frontend/src/components/layout/NewsModal.css
@@ -145,6 +145,57 @@
border-radius: 8px;
}
+.velxio-news-video {
+ position: relative;
+ display: block;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ border: none;
+ border-radius: 8px;
+ overflow: hidden;
+ margin: 4px 0 10px;
+ padding: 0;
+ background: #000;
+ cursor: pointer;
+}
+
+.velxio-news-video iframe {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ display: block;
+}
+
+.velxio-news-video-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 0;
+ display: block;
+}
+
+.velxio-news-play {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.7);
+ color: #fff;
+ font-size: 22px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding-left: 4px;
+ transition: background 0.15s;
+}
+
+.velxio-news-video-thumb:hover .velxio-news-play {
+ background: #e62117;
+}
+
.velxio-news-body table {
border-collapse: collapse;
margin: 0 0 10px;
diff --git a/frontend/src/components/layout/NewsModal.tsx b/frontend/src/components/layout/NewsModal.tsx
index b1ac59c5..b128fd7a 100644
--- a/frontend/src/components/layout/NewsModal.tsx
+++ b/frontend/src/components/layout/NewsModal.tsx
@@ -10,20 +10,12 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
-import ReactMarkdown from 'react-markdown';
-import remarkGfm from 'remark-gfm';
import { getNextNews, type NewsPost } from '../../lib/newsSource';
+import { NewsMarkdown } from './NewsMarkdown';
import './NewsModal.css';
const FETCH_DELAY_MS = 4000;
-// Self-hosted privacy promise: the feed is proxied through the local
-// backend precisely so users' browsers never contact velxio.dev — an
-// inline remote image would undo that (one request per viewer). OSS
-// builds therefore render images as their alt text; the pro cloud build
-// (VITE_PRO_BUILD) shows them normally.
-const SHOW_REMOTE_IMAGES = import.meta.env.VITE_PRO_BUILD === 'true';
-
export function NewsModal() {
const [post, setPost] = useState(null);
@@ -65,16 +57,7 @@ export function NewsModal() {