Skip to content

feat(packages): make <media-poster> act like <picture> - #2039

Open
decepulis wants to merge 11 commits into
claude/poster-storyboard-metadata-uv9ieffrom
claude/poster-picture-model
Open

feat(packages): make <media-poster> act like <picture>#2039
decepulis wants to merge 11 commits into
claude/poster-storyboard-metadata-uv9ieffrom
claude/poster-picture-model

Conversation

@decepulis

@decepulis decepulis commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #1798.

Sandbox

What is this?

  1. <media-poster> fills in the source of the image already inside it, the way <picture> does. As a side effect, it extends MediaElement rather than MediaUIElement now, and drops its lazy, unused i18n chunk.
  2. The packaged skins carry that image as slot fallback content
  3. posterPlaceholder comes from the store and reaches CSS as --media-poster-placeholder, and the skins paint it on img { background-image: var(--media-poster-placeholder) }.
  4. poster and placeholdersrc come off the skins. Both values come from the player now.

The API

Passing to the provider

<video-player poster="poster.jpg" poster-placeholder="url('data:image/svg+xml,…')">
  <video-skin>
    <video src="video.mp4" playsinline></video>
  </video-skin>
</video-player>
<Player.Provider poster="poster.jpg" posterPlaceholder={`url('${dataUrl}')`}>
  <VideoSkin>
    <Video src="video.mp4" playsInline />
  </VideoSkin>
</Player.Provider>

Consuming in the image

<!-- No source, so the poster fills in the player's -->
<media-poster>
  <img alt="">
</media-poster>

<!-- A source of its own, so the poster leaves it alone -->
<media-poster>
  <img src="hero.jpg" srcset="hero-480.jpg 480w" alt="Keynote speaker">
</media-poster>

<source> candidates inside a <picture> count as a source too.

In React the component is the image:

// Fills in the player's poster
<Poster />

// Image attributes go straight on it
<Poster srcSet="poster-480.jpg 480w, poster-1080.jpg 1080w" sizes="100vw" />

// Swap the element, keep the resolved src
<Poster render={(props) => <Image {...props} alt="" fill />} />

Overriding the skin image

<video-skin>
  <video src="video.mp4" playsinline></video>
  <img slot="poster" src="hero.jpg" alt="Keynote speaker">
</video-skin>

Painting a poster placeholder

background-image: var(--media-poster-placeholder, none);
background-size: var(--media-object-fit, contain);

What this isn't

Funny business

  • <media-poster> alone renders nothing. Your layout supplies the image, and a __DEV__ warning fires when a poster resolves without one. <picture> behaves the same way, though it's a platform element, so expectations differ.
  • A skin can only style an image you slot directly. ::slotted(img) doesn't reach inside a <picture> or a framework image component, and shadow CSS has no other way in. That sizing is yours.
  • Quoting is yours, and getting it wrong fails silently. setProperty no-ops on a value it can't parse, so a data URL carrying double quotes needs url('…').
  • Blur has to be baked into the image. Painting on the image drops the ::before layer, and --media-poster-placeholder-blur with it. Recorded as a decision.
  • A failed poster shows the browser's broken-image glyph over the placeholder. Holding the image at opacity: 0 until data-loaded would hide its background too.
  • Ownership is settled once, when an image becomes active. Handing one back means re-slotting it with a source, the way React settles controlled inputs. For the same reason, an image that rewrites its own src in place keeps data-loaded through the next fetch: we listen for load and error but don't watch the attribute.

🤖 Generated with Claude Code

https://claude.ai/code/session_019fBNWkGLWPzsP4QK6w7u2L


Note

High Risk
Breaking public API change: poster/placeholder leave skin props for the player, and the HTML poster element is rewritten. Skins, CSS blur-up behavior, and consumer markup all need updates.

Overview
Reworks poster so the URL lives on the player, and the component only coordinates the image.

<media-poster> now behaves like <picture>: it fills src on a child/slotted image when none is authored, and leaves src/srcset/<picture> alone otherwise. React Poster does the same for its rendered <img>. Packaged skins ship a fallback <img> in the poster slot.

poster and posterPlaceholder move off skins onto the provider (Player.Provider / <video-player>). Placeholder is a CSS image (e.g. url('…')), exposed as --media-poster-placeholder. Skins paint it as the image’s background-image instead of a blurred ::before layer — --media-poster-placeholder-blur is gone.

Also adds data-loading / data-loaded / data-error, updates sandbox/docs/ejected skins, and drops skin placeholdersrc / poster / placeholder props.

Reviewed by Cursor Bugbot for commit 73cee0f. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v10-sandbox Ready Ready Preview Aug 11, 2026 10:59pm

Request Review

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size Report

🎨 @videojs/html

Path Base initial PR initial Initial diff Lazy diff
/video/minimal-skin.tailwind 56.25 kB 56.60 kB +357 B (+0.6%) 0 B
/video/skin 56.12 kB 56.44 kB +326 B (+0.6%) 0 B
/video/skin.tailwind 56.77 kB 57.16 kB +397 B (+0.7%) 0 B
/live-video/minimal-skin 51.92 kB 52.33 kB +421 B (+0.8%) 0 B
/live-video/minimal-skin.tailwind 52.56 kB 52.92 kB +363 B (+0.7%) 0 B
/live-video/skin.tailwind 55.14 kB 55.48 kB +344 B (+0.6%) 0 B
/ui/poster 8.52 kB 8.35 kB -180 B (-2.1%) -56.93 kB
/video (default) 55.96 kB 56.45 kB +500 B (+0.9%) 0 B
/video (default + hls) 194.12 kB 194.49 kB +385 B (+0.2%) 0 B
/video (minimal) 55.61 kB 56.03 kB +428 B (+0.8%) 0 B
/video (minimal + hls) 193.79 kB 194.20 kB +417 B (+0.2%) 0 B
Small changes (19, ≤ 300 B)
Path Base initial PR initial Initial diff Lazy diff
/video/minimal-skin.css 5.92 kB 5.87 kB -49 B (-0.8%)
/video/skin.css 5.92 kB 5.87 kB -47 B (-0.8%)
/video/minimal-skin 55.68 kB 55.96 kB +283 B (+0.5%) 0 B
/audio/minimal-skin 44.41 kB 44.32 kB -89 B (-0.2%) 0 B
/audio/minimal-skin.tailwind 45.00 kB 44.86 kB -139 B (-0.3%) 0 B
/audio/skin 48.22 kB 48.16 kB -62 B (-0.1%) 0 B
/audio/skin.tailwind 48.77 kB 48.62 kB -156 B (-0.3%) 0 B
/live-video/minimal-skin.css 5.92 kB 5.87 kB -49 B (-0.8%)
/live-video/skin.css 5.92 kB 5.87 kB -47 B (-0.8%)
/live-video/skin 54.51 kB 54.80 kB +300 B (+0.5%) 0 B
/live-audio/minimal-skin 36.73 kB 36.66 kB -77 B (-0.2%) 0 B
/live-audio/minimal-skin.tailwind 36.15 kB 36.12 kB -34 B (-0.1%) 0 B
/live-audio/skin 40.68 kB 40.61 kB -73 B (-0.2%) 0 B
/live-audio/skin.tailwind 40.23 kB 40.14 kB -96 B (-0.2%) 0 B
/ui/buffering-indicator 8.18 kB 8.18 kB +2 B (+0.0%)
/ui/seek-button 10.90 kB 10.91 kB +3 B (+0.0%) 0 B
/skin-element 1.45 kB 1.39 kB -57 B (-3.8%)
/audio (default) 48.22 kB 48.19 kB -32 B (-0.1%) 0 B
/audio (minimal) 44.44 kB 44.35 kB -95 B (-0.2%) 0 B
Presets (7)
Entry Initial Lazy
/video (default) 56.45 kB 56.93 kB
/video (default + hls) 194.49 kB 56.93 kB
/video (minimal) 56.03 kB 56.93 kB
/video (minimal + hls) 194.20 kB 56.93 kB
/audio (default) 48.19 kB 56.93 kB
/audio (minimal) 44.35 kB 56.93 kB
/background 5.59 kB 56.93 kB
Media (13)
Entry Initial Lazy
/media/background-video 1.14 kB
/media/container 2.58 kB 56.93 kB
/media/dash-video 211.36 kB
/media/google-cast 6.44 kB
/media/hlsjs-video 140.20 kB
/media/mux-audio 141.09 kB
/media/mux-data 26.69 kB
/media/mux-video 141.35 kB
/media/native-hls-video 7.51 kB
/media/simple-hls-audio-only 21.59 kB
/media/simple-hls-video 27.08 kB
/media/vimeo-video 12.62 kB
/media/youtube-video 6.67 kB
Players (5)
Entry Initial Lazy
/video/player 10.17 kB 56.93 kB
/audio/player 7.16 kB 56.93 kB
/background/player 5.32 kB 56.93 kB
/live-video/player 9.54 kB 56.93 kB
/live-audio/player 7.15 kB 56.93 kB
Skins (30)
Entry Type Initial Lazy
/video/minimal-skin.css css 5.87 kB
/video/skin.css css 5.87 kB
/video/minimal-skin js 55.96 kB 56.93 kB
/video/minimal-skin.tailwind js 56.60 kB 56.93 kB
/video/skin js 56.44 kB 56.93 kB
/video/skin.tailwind js 57.16 kB 56.93 kB
/audio/minimal-skin.css css 4.11 kB
/audio/skin.css css 4.05 kB
/audio/minimal-skin js 44.32 kB 56.93 kB
/audio/minimal-skin.tailwind js 44.86 kB 56.93 kB
/audio/skin js 48.16 kB 56.93 kB
/audio/skin.tailwind js 48.62 kB 56.93 kB
/background/skin.css css 133 B
/background/skin js 1.14 kB
/live-video/minimal-skin.css css 5.87 kB
/live-video/skin.css css 5.87 kB
/live-video/minimal-skin js 52.33 kB 56.93 kB
/live-video/minimal-skin.tailwind js 52.92 kB 56.93 kB
/live-video/skin js 54.80 kB 56.93 kB
/live-video/skin.tailwind js 55.48 kB 56.93 kB
/live-audio/minimal-skin.css css 4.11 kB
/live-audio/skin.css css 4.05 kB
/live-audio/minimal-skin js 36.66 kB 56.93 kB
/live-audio/minimal-skin.tailwind js 36.12 kB 56.93 kB
/live-audio/skin js 40.61 kB 56.93 kB
/live-audio/skin.tailwind js 40.14 kB 56.93 kB
/global.css css 183 B
/shared.css css 104 B
/tailwind.css css 161 B
/skin-element js 1.39 kB
UI Components (39)
Entry Initial Lazy
/ui/airplay-button 10.92 kB 56.93 kB
/ui/alert-dialog 4.14 kB
/ui/alert-dialog-close 1.94 kB
/ui/alert-dialog-description 1.61 kB
/ui/alert-dialog-title 1.61 kB
/ui/audio-track-radio-group 10.67 kB 56.93 kB
/ui/buffering-indicator 8.18 kB
/ui/captions-button 11.17 kB 56.93 kB
/ui/captions-radio-group 10.73 kB 56.93 kB
/ui/cast-button 10.89 kB 56.93 kB
/ui/compounds 36.33 kB 56.93 kB
/ui/controls 8.32 kB 56.93 kB
/ui/error-dialog 11.31 kB 56.93 kB
/ui/fullscreen-button 10.88 kB 56.93 kB
/ui/hotkey 9.46 kB
/ui/menu 20.75 kB 56.93 kB
/ui/mute-button 10.87 kB 56.93 kB
/ui/pip-button 10.87 kB 56.93 kB
/ui/play-button 10.87 kB 56.93 kB
/ui/playback-rate-button 11.04 kB 56.93 kB
/ui/playback-rate-radio-group 10.45 kB 56.93 kB
/ui/popover 7.78 kB
/ui/poster 8.35 kB
/ui/quality-radio-group 11.23 kB 56.93 kB
/ui/seek-button 10.91 kB 56.93 kB
/ui/seek-indicator 12.65 kB
/ui/seek-indicator-value 1.02 kB
/ui/slider 11.74 kB 56.93 kB
/ui/status-announcer 10.29 kB 56.93 kB
/ui/status-indicator 13.15 kB 56.93 kB
/ui/status-indicator-value 1.02 kB
/ui/thumbnail 9.29 kB
/ui/time 10.65 kB 56.93 kB
/ui/time-slider 14.66 kB 56.93 kB
/ui/tooltip 9.06 kB 56.93 kB
/ui/volume-indicator 13.17 kB 56.93 kB
/ui/volume-indicator-fill 1.03 kB
/ui/volume-indicator-value 1.02 kB
/ui/volume-slider 12.34 kB 56.93 kB
⚛️ @videojs/react — 27 small size changes
Path Base initial PR initial Initial diff Lazy diff
/video/minimal-skin.css 5.81 kB 5.77 kB -47 B (-0.8%)
/video/skin.css 5.81 kB 5.76 kB -49 B (-0.8%)
/video/minimal-skin 43.62 kB 43.78 kB +155 B (+0.3%) 0 B
/video/minimal-skin.tailwind 49.90 kB 49.94 kB +50 B (+0.1%) 0 B
/video/skin 43.57 kB 43.68 kB +112 B (+0.3%) 0 B
/video/skin.tailwind 49.83 kB 49.94 kB +117 B (+0.2%) 0 B
/audio/minimal-skin 36.01 kB 35.94 kB -73 B (-0.2%) 0 B
/live-video/minimal-skin.css 5.81 kB 5.77 kB -47 B (-0.8%)
/live-video/skin.css 5.81 kB 5.76 kB -49 B (-0.8%)
/live-video/minimal-skin 38.66 kB 38.80 kB +151 B (+0.4%) 0 B
/live-video/minimal-skin.tailwind 44.82 kB 44.95 kB +133 B (+0.3%) 0 B
/live-video/skin 38.62 kB 38.78 kB +167 B (+0.4%) 0 B
/live-video/skin.tailwind 44.94 kB 45.13 kB +199 B (+0.4%) 0 B
/ui/mute-button 10.10 kB 10.10 kB +2 B (+0.0%) 0 B
/ui/play-button 10.08 kB 10.09 kB +5 B (+0.0%) 0 B
/ui/playback-rate 6.94 kB 6.93 kB -5 B (-0.1%)
/ui/popover 7.47 kB 7.47 kB +1 B (+0.0%)
/ui/poster 7.01 kB 7.20 kB +200 B (+2.8%)
/ui/seek-indicator 11.93 kB 11.93 kB +2 B (+0.0%)
/ui/time 9.60 kB 9.60 kB +1 B (+0.0%) 0 B
/ui/time-slider 12.22 kB 12.22 kB +5 B (+0.0%) 0 B
/ui/tooltip 8.21 kB 8.21 kB +2 B (+0.0%)
/ui/volume-indicator 12.50 kB 12.49 kB -5 B (-0.0%) 0 B
/video (default) 43.70 kB 43.81 kB +118 B (+0.3%) 0 B
/video (default + hls) 180.58 kB 180.58 kB +3 B (+0.0%) 0 B
/video (minimal) 43.70 kB 43.82 kB +123 B (+0.3%) 0 B
/video (minimal + hls) 180.64 kB 180.71 kB +75 B (+0.0%) 0 B
Presets (7)
Entry Initial Lazy
/video (default) 43.81 kB 56.93 kB
/video (default + hls) 180.58 kB 56.93 kB
/video (minimal) 43.82 kB 56.93 kB
/video (minimal + hls) 180.71 kB 56.93 kB
/audio (default) 36.02 kB 56.93 kB
/audio (minimal) 36.09 kB 56.93 kB
/background 581 B
Media (12)
Entry Initial
/media/background-video 394 B
/media/dash-video 209.68 kB
/media/google-cast 5.34 kB
/media/hlsjs-video 138.60 kB
/media/mux-audio 139.39 kB
/media/mux-data 25.47 kB
/media/mux-video 139.65 kB
/media/native-hls-video 5.75 kB
/media/simple-hls-audio-only 19.89 kB
/media/simple-hls-video 25.41 kB
/media/vimeo-video 10.79 kB
/media/youtube-video 4.78 kB
Skins (27)
Entry Type Initial Lazy
/tailwind.css css 161 B
/video/minimal-skin.css css 5.77 kB
/video/skin.css css 5.76 kB
/video/minimal-skin js 43.78 kB 56.93 kB
/video/minimal-skin.tailwind js 49.94 kB 56.93 kB
/video/skin js 43.68 kB 56.93 kB
/video/skin.tailwind js 49.94 kB 56.93 kB
/audio/minimal-skin.css css 3.97 kB
/audio/skin.css css 3.90 kB
/audio/minimal-skin js 35.94 kB 56.93 kB
/audio/minimal-skin.tailwind js 38.49 kB 56.93 kB
/audio/skin js 35.94 kB 56.93 kB
/audio/skin.tailwind js 40.26 kB 56.93 kB
/background/skin.css css 90 B
/background/skin js 273 B
/live-video/minimal-skin.css css 5.77 kB
/live-video/skin.css css 5.76 kB
/live-video/minimal-skin js 38.80 kB 56.93 kB
/live-video/minimal-skin.tailwind js 44.95 kB 56.93 kB
/live-video/skin js 38.78 kB 56.93 kB
/live-video/skin.tailwind js 45.13 kB 56.93 kB
/live-audio/minimal-skin.css css 3.97 kB
/live-audio/skin.css css 3.90 kB
/live-audio/minimal-skin js 26.60 kB 56.93 kB
/live-audio/minimal-skin.tailwind js 29.82 kB 56.93 kB
/live-audio/skin js 26.64 kB 56.93 kB
/live-audio/skin.tailwind js 30.01 kB 56.93 kB
UI Components (33)
Entry Initial Lazy
/ui/airplay-button 10.16 kB 56.93 kB
/ui/alert-dialog 3.25 kB
/ui/audio-track 7.63 kB 56.93 kB
/ui/buffering-indicator 7.39 kB
/ui/captions-button 10.15 kB 56.93 kB
/ui/captions-radio-group 7.76 kB 56.93 kB
/ui/cast-button 10.14 kB 56.93 kB
/ui/controls 7.12 kB
/ui/error-dialog 10.09 kB 56.93 kB
/ui/fullscreen-button 10.11 kB 56.93 kB
/ui/gesture 7.51 kB
/ui/hotkey 7.95 kB
/ui/live-button 8.55 kB 56.93 kB
/ui/menu 20.75 kB 56.93 kB
/ui/mute-button 10.10 kB 56.93 kB
/ui/pip-button 10.11 kB 56.93 kB
/ui/play-button 10.09 kB 56.93 kB
/ui/playback-rate 6.93 kB
/ui/playback-rate-button 10.09 kB 56.93 kB
/ui/popover 7.47 kB
/ui/poster 7.20 kB
/ui/quality 8.13 kB 56.93 kB
/ui/seek-button 10.12 kB 56.93 kB
/ui/seek-indicator 11.93 kB
/ui/slider 12.10 kB 56.93 kB
/ui/status-announcer 9.80 kB 56.93 kB
/ui/status-indicator 12.40 kB 56.93 kB
/ui/thumbnail 8.22 kB
/ui/time 9.60 kB 56.93 kB
/ui/time-slider 12.22 kB 56.93 kB
/ui/tooltip 8.21 kB
/ui/volume-indicator 12.49 kB 56.93 kB
/ui/volume-slider 11.52 kB 56.93 kB
🧩 @videojs/core — 1 small size change
Path Base initial PR initial Initial diff Lazy diff
. 11.77 kB 11.86 kB +89 B (+0.7%)
Entries (76)
Entry Initial Lazy
. 11.86 kB
/dom 20.12 kB
/components 900 B
/i18n 3.11 kB 56.93 kB
/i18n/locales/all 35.05 kB
/i18n/locales/ar 1.22 kB
/i18n/locales/az 1.09 kB
/i18n/locales/bg 1.29 kB
/i18n/locales/bn 1.28 kB
/i18n/locales/bs 1.00 kB
/i18n/locales/ca 1.07 kB
/i18n/locales/cs 1.07 kB
/i18n/locales/cy 1.02 kB
/i18n/locales/da 1006 B
/i18n/locales/de 1.09 kB
/i18n/locales/el 1.49 kB
/i18n/locales/en 785 B
/i18n/locales/es 1018 B
/i18n/locales/et 1.05 kB
/i18n/locales/eu 1.03 kB
/i18n/locales/fa 1.22 kB
/i18n/locales/fi 1.04 kB
/i18n/locales/fr 1.07 kB
/i18n/locales/gd 1.10 kB
/i18n/locales/gl 1022 B
/i18n/locales/he 1.14 kB
/i18n/locales/hi 1.30 kB
/i18n/locales/hr 1.02 kB
/i18n/locales/hu 1.10 kB
/i18n/locales/id 919 B
/i18n/locales/it 1.01 kB
/i18n/locales/ja 1.18 kB
/i18n/locales/ko 1.14 kB
/i18n/locales/lt 1.02 kB
/i18n/locales/lv 1.09 kB
/i18n/locales/mr 1.30 kB
/i18n/locales/nb 987 B
/i18n/locales/ne 1.29 kB
/i18n/locales/nl 1.00 kB
/i18n/locales/nn 987 B
/i18n/locales/oc 1.08 kB
/i18n/locales/pl 1.14 kB
/i18n/locales/pt 1.03 kB
/i18n/locales/pt-BR 1.03 kB
/i18n/locales/pt-PT 1.01 kB
/i18n/locales/ro 1.07 kB
/i18n/locales/ru 1.40 kB
/i18n/locales/sk 1.10 kB
/i18n/locales/sl 1.04 kB
/i18n/locales/sr 1.07 kB
/i18n/locales/sv 1.01 kB
/i18n/locales/te 1.32 kB
/i18n/locales/th 1.29 kB
/i18n/locales/tr 1.08 kB
/i18n/locales/uk 1.41 kB
/i18n/locales/vi 1.11 kB
/i18n/locales/zh 1.02 kB
/i18n/locales/zh-CN 1.02 kB
/i18n/locales/zh-TW 1.02 kB
/i18n/text/airplay 101 B
/i18n/text/buttons 137 B
/i18n/text/captions 93 B
/i18n/text/cast 114 B
/i18n/text/common 90 B
/i18n/text/container 75 B
/i18n/text/errors 285 B
/i18n/text/fullscreen 98 B
/i18n/text/live 126 B
/i18n/text/menu 249 B
/i18n/text/pip 101 B
/i18n/text/playback 80 B
/i18n/text/seek 105 B
/i18n/text/slider 65 B
/i18n/text/status 222 B
/i18n/text/time 280 B
/i18n/text/volume 133 B
🏷️ @videojs/element — no changes
Entries (2)
Entry Initial
. 996 B
/context 943 B
📦 @videojs/store — no changes
Entries (3)
Entry Initial
. 1.71 kB
/html 703 B
/react 366 B
🔧 @videojs/utils — no changes
Entries (13)
Entry Initial
/array 104 B
/dom 3.29 kB
/events 320 B
/function 369 B
/i18n 44 B
/jwt 176 B
/object 508 B
/predicate 297 B
/percent 281 B
/string 239 B
/style 188 B
/time 1.01 kB
/number 158 B
📦 @videojs/jsx — no changes
Entries (3)
Entry Initial
. 393 B
/jsx-runtime 371 B
/jsx-dev-runtime 375 B
📦 @videojs/media — no changes
Entries (15)
Entry Initial
. 1.04 kB
/dom 121 B
/dom/audio-host 1.10 kB
/dom/custom-media-element 2.09 kB
/dom/dash 209.38 kB
/dom/google-cast 4.06 kB
/dom/hls-js 138.13 kB
/dom/media-host 1.20 kB
/dom/media-played-ranges 576 B
/dom/mux 161.76 kB
/dom/native-hls 5.26 kB
/dom/video-host 1.39 kB
/dom/vimeo 10.21 kB
/dom/youtube 4.17 kB
/media-tracks 1.98 kB
📦 @videojs/spf — no changes
Entries (7)
Entry Initial
. 4.53 kB
/dom 6.57 kB
/hls 20.86 kB
/media-tracks 504 B
/background-video 14.77 kB
/simple-hls 24.92 kB
/simple-hls-audio-only 19.39 kB

ℹ️ How to interpret

Each entry is independently bundled, minified, and brotli-compressed. Initial size includes its static import graph; lazy dynamic chunks are reported separately.

Entries are not additive because their dependency graphs overlap. Preset rows represent realistic combined bundles.

Changes of 300 B or less across initial, lazy, and total size are collapsed, not discarded.

Run pnpm size locally to check current initial sizes.

@decepulis decepulis left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A PR review for Claude!

generally, I'm not qualified to say if this PR follows the usual core/ui primitive boundaries. Double check that, too.

Comment thread apps/e2e/scripts/generate-pages.ts Outdated
? `import { Video, VideoSkin, videoFeatures } from '@videojs/react/video';`
: `import { ${reactMedia.component} } from '${reactMedia.importPath}';\nimport { VideoSkin, videoFeatures } from '@videojs/react/video';`;

// The poster URL is player configuration, not a skin prop.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove. I don't like comments that talk about historical shape. If people want archaeology, they can read the PR 😅

Comment on lines +11 to +17
/**
* How far the image the poster coordinates has got.
*
* Reported by the binding rather than derived here: only the binding holds the
* image, and its source may be one the author set rather than the resolved
* `src` below. `'none'` covers both no image and an image with no source.
*/

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo throughout this whole PR, you've been too verbose with comments. Double check with repo rules and conventions on comments. Consider only using them when they're used in the docs by our api reference generator or particularly needed

<media-poster class="${poster(true)}">
<slot name="poster"></slot>
<slot name="poster">
<!-- Replaced by an <img slot="poster"> of your own. -->

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't introduce comments like this unless they were preexisting

Comment thread packages/html/src/ui/poster/poster-element.ts
Comment thread packages/media/src/core/state.ts Outdated
Comment on lines +150 to +154
* This is a CSS `background-image` value, not a URL — it is written onto the
* poster image untouched, so a URL has to arrive wrapped: `url("…")`. Any blur
* has to be baked into the image itself, which is what `@mux/blurup` returns.
* Resolved independently of the poster, so a player can carry one without the
* other.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WAY too verbose and specific

Suggested change
* This is a CSS `background-image` value, not a URL it is written onto the
* poster image untouched, so a URL has to arrive wrapped: `url("…")`. Any blur
* has to be baked into the image itself, which is what `@mux/blurup` returns.
* Resolved independently of the poster, so a player can carry one without the
* other.

Comment on lines +78 to +84
The blur has to be part of the image you supply, not something a skin adds. [`@mux/blurup`](https://www.npmjs.com/package/@mux/blurup) returns exactly this: a tiny frame wrapped in an SVG that carries its own Gaussian blur.

```ts
import { createBlurUp } from '@mux/blurup';

const { blurDataURL } = await createBlurUp(playbackId);
```

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want these docs to be mux specific; we'll figure out where to tell the blurup story separately


The value is a CSS image, not a URL, so wrap a URL in `url()` yourself. Anything CSS accepts works, including a gradient.

Mind the quoting. A blurred SVG carries double quotes of its own, so `url("...")` around one ends at the first of them and the browser drops the declaration silently — nothing paints, and there is no error. Use `url('...')`, as the examples above do. Written in markup the same value needs its double quotes as `&quot;`, so setting it from script is usually easier.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too specific

Suggested change
Mind the quoting. A blurred SVG carries double quotes of its own, so `url("...")` around one ends at the first of them and the browser drops the declaration silently — nothing paints, and there is no error. Use `url('...')`, as the examples above do. Written in markup the same value needs its double quotes as `&quot;`, so setting it from script is usually easier.

The component fills in a source for you, but you never lose control of the image. `srcset`, `sizes`, `loading`, `<picture>`, and framework image components all stay available.

<FrameworkCase frameworks={["react"]}>
The component *is* the `<img>`, so image attributes go straight on it:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The component *is* the `<img>`, so image attributes go straight on it:
The component is the `<img>`, so image attributes go straight on it:

</FrameworkCase>

<FrameworkCase frameworks={["html"]}>
`<media-poster>` renders no image of its own — it works like `<picture>`, filling in the `src` of the image you put inside it:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

link to mdn

}
```

The image is an ordinary child, so style it as a descendant — there is no part to reach through:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The image is an ordinary child, so style it as a descendant — there is no part to reach through:
The image is an ordinary child, so style it as a descendant

If there's no part to reach through, then why'd you mention it? You need to be way more minimal and efficient with your docs. Again, refer to the skills.

Copy link
Copy Markdown
Collaborator Author

Thanks — all of it landed in three commits on top of the four already here, so your inline comments stay attached to the diff they were written against. Squash whenever you like.

What changed

Comments. Cut throughout, and I took your specific suggestions as written. Kept only the class and component JSDoc the reference generator extracts. I also put back four comments I had deleted from the skin tailwind files without meaning to — they were yours, from before this branch.

media-poster: 281 → 214 lines. It was holding a #status field that was purely derived, a cache to avoid writing the same CSS property twice, and a release path that only ever ran on disconnect. The status is now a getter, the property is written where it is read, and "release" is just adopting null. Same behavior, same 22 tests.

Tailwind. The slottedImage / childImage locals are gone and the class lists are literal again, so those two diffs are additions rather than rewrites.

Reference page. Gone: the CSS-quoting essay, the @mux/blurup walkthrough, and the three separate places that re-explained where the placeholder gets painted. <picture> links to MDN now.

One real bug, found on a second pass. The React load handler recorded whatever src it found on the element, but the status compared that against the resolved one. An optimizing image component rewrites the attribute, so they never matched and the poster stayed data-loading forever after loading — which breaks exactly the render escape hatch the docs advertise. Fixed by recording the src the render asked for. There's a test, and I confirmed it fails without the fix.

On core/ui boundaries

Mostly clean. poster-css-vars.ts matches seven siblings in packages/core/src/core/ui/. PosterMediaState composing two feature slices matches LiveButtonMediaState, which composes three. PosterCore touches no DOM, and the slot walking and attribute writes live in the bindings.

One thing does cross a line, and I'd like your call rather than my guess:

packages/media/src/core contains no other CSS. Every CSS constant in that package sits under src/dom. But posterPlaceholder now puts a CSS value in src/core, right next to poster and storyboard, which are plain URLs. I left it because you wrote "a CSS image" in two of your own suggestions and called the third comment a good one — but the alternative is storing a URL and wrapping it in each binding, which is what main did. That would drop the quoting footgun and lose gradients.

Also worth naming

main's skins blurred the placeholder themselves, with filter: blur(var(--media-poster-placeholder-blur, 20px)) on a ::before layer. Painting it as the image's own background means that layer is gone and so is --media-poster-placeholder-blur, so blur now has to be baked into the image. That follows from the design, but it removes a skin variable and should be a decision, not a side effect.

Not acted on

An author's image that changes its own src in place, after it has already loaded, keeps data-loaded through the next fetch — we listen for load and error but don't watch the attribute. Closing it costs a MutationObserver per image. Given the direction of this review I left it alone; happy to add it if you'd rather have the guarantee.

poster-alt

I think you're right, and not in this PR. The argument is the same one that moved the URL: what the poster depicts is a property of the content, so a media donor could supply it. contentTitle is already a localized content string in the store, so there's precedent. It would need the same "only when the author supplied none" rule the src has. Worth its own issue.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Absorbed — #1998 has posterPlaceholder now, and it's since moved on. Ready for you to rebase.

151a69f2 landed unchanged, at its original SHA. I fast-forwarded rather than cherry-picked, since its parent was already this branch's tip. That should make your rebase drop the duplicate cleanly rather than relying on a patch-id match.

I left the two JSDoc comments alone, as asked — they still say nothing about URL vs CSS image.

The part that matters for your rebase

The branch is no longer at 0414637. It's now 7f4778b2, which merged main up through #2040. That merge conflicted in packages/media/src/dom/mux/media.ts and the resolution may reach your work:

Only the imports actually conflicted; the setter body auto-merged, with #refreshContentData still running before super.source = and the dispatch still after.

Verified on the merged branch

pnpm build, pnpm typecheck, pnpm lint, pnpm check:workspace clean. Tests: media 574, core 1412, html 307, react 394 — all passing. A full pnpm build is needed before pnpm typecheck after picking this up; the spf relocation leaves stale declarations that produce misleading Cannot find module '@videojs/spf/simple-hls' errors otherwise.

Title and body here are updated to cover the placeholder.


Generated by Claude Code

claude added 8 commits August 11, 2026 20:36
The value reaches CSS untouched, so a URL has to arrive wrapped and any
blur has to be baked into the image. That follows from how the poster
offers it, so it belongs with the design that does.

Refs #1798
`<media-poster>` rendered nothing of its own, so reading the store's
resolved poster left no place to put a URL. Owning an `<img>` in a shadow
root answers that, and costs three things: the image is stylable only
through `::part(img)`, skins carry two styling contracts — one for the
internal image, one for the replacement — and two images sit in the tree
with one of them hidden.

Make it a controller in the shape of `<picture>` instead. It renders no
image and coordinates the one already inside it: fills in `src` when the
child has none, leaves a child that brought its own `src`, `srcset`, or
`<source>` candidates alone, and reports `data-loading`, `data-loaded`,
and `data-error` for whichever image is active. The packaged skins carry
a plain `<img>` as their poster slot's fallback content, so an `<img
slot="poster">` displaces it and there is never a moment when a default
and an override both exist.

The placeholder needs no component and no second element either. The
poster offers the resolved value as `--media-poster-placeholder` — the
same property the skins read before this branch, now set by the component
that holds the value rather than by `SkinElement` from an attribute. The
skins paint it as the poster image's own `background-image`: an `<img>`
paints a background behind its content like any other element, so one
image covers both jobs and the poster arrives on top. `background-size`
tracks `--media-object-fit` so the stand-in occupies the poster's box.

Offering the property rather than painting it keeps the component out of
presentation: it never overwrites a background the image already had, it
costs nothing when no skin reads it, and a skin that wants a separate layer
to crossfade can still have one. Custom properties inherit down the
flattened tree, so the value reaches the image through both slot hops even
when that image lives in another tree.

Any blur has to be baked into the image, which is what `@mux/blurup`
returns and what Next.js, Angular `NgOptimizedImage`, and Nuxt Image all
do. `--media-poster-placeholder-blur` is gone with the CSS filter it fed.

Watching an image it does not own means the load treatment now covers one
you supplied. It used to apply to the component's own image only and let a
supplied one appear abruptly. It also no longer depends on load state: the
skins held the image at `opacity: 0` until `data-loaded`, which would now
hide the background along with it.

`placeholdersrc` on `<video-skin>` and `placeholder` on the React skins are
gone, and so is `poster` on the React skins — the URL is player
configuration, which is also where the generated e2e pages set it now.

`<media-poster>` alone renders nothing, so a hand-authored layout supplies
the image and a `__DEV__` warning fires when a poster resolves without one.

Refs #1798
The skin templates carry an `<img>` inside their poster slot now, so the
transform looking for an empty `<slot name="poster">` stopped matching and
left the slot element in output that has no shadow root to compose it.
Collapse the slot to the image it carried instead, and give the wrapping
`<video-player>` the poster URL, since the image no longer holds one.

The React side leaned on `BaseVideoSkinProps` to know a skin took a
poster, and that type is gone with the prop. Give the ejected video player
its own `poster`, passed to the provider it owns — a standalone player
component still needs a way to set one.

Refs #1798
Rewrite the poster reference around the image the component coordinates
rather than one it renders: how to supply your own, that it is an ordinary
child with no part to reach through, and what the three load attributes
are for. The placeholder section covers reading
`--media-poster-placeholder`, matching `background-size` to `object-fit` so
the stand-in occupies the poster's box, and the quoting — a blurred SVG
carries double quotes that end a `url("…")` early, and the browser drops
the declaration in silence.

Move the poster URL onto the provider in the demos, the home page, and the
skin examples, and give the HTML demo the image the element now needs.

Update the two poster records in place rather than adding a third: the
compound-image contract and the rejected shadow-root image belong in
`poster.md`, and the placeholder's decisions belong in the record that
already holds them.

Refs #1798
The load handler recorded the `src` it found on the element while the status
compared it against the resolved one. An optimizing image component rewrites
that attribute, so the two never matched and the poster stayed `data-loading`
after the image had loaded — the documented `render` escape hatch.

Record the `src` the render asked for instead, which needs no DOM read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fBNWkGLWPzsP4QK6w7u2L
The element carried a derived `#status` field, a write-cache for the
placeholder property, and a separate release path that only ever ran on
disconnect. Fold those in: the status is a getter, the property is written
where it is read, and adopting `null` is what releasing means. 281 lines to
214, with the same behavior and the same tests.

Cut the comments back to what is not already in the code, keep the
class JSDoc the reference extracts, and drop the abstractions the skin
tailwind files grew so those diffs stay readable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fBNWkGLWPzsP4QK6w7u2L
The reference page had grown an essay on CSS quoting, a Mux-specific
blur-up walkthrough, and repeated explanations of where the placeholder
gets painted. Keep the mechanics, drop the commentary, and link MDN for
`<picture>` rather than describing it.

Say what a skin cannot do, too: `::slotted(img)` reaches an image you slot
directly and nothing inside a `<picture>` you slot, so that sizing is the
author's. Both records note it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fBNWkGLWPzsP4QK6w7u2L
The skins used to blur the placeholder themselves on a `::before` layer.
Painting it as the poster image's own background removes both that layer
and `--media-poster-placeholder-blur`, so name the variable that went and
why: a pre-blurred data URL is what image pipelines already return.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fBNWkGLWPzsP4QK6w7u2L
@decepulis decepulis changed the title feat(packages): coordinate the poster image instead of owning one feat(packages): make <media-poster> act like <picture> Aug 11, 2026

@decepulis decepulis left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs could use another pass. a few comments here or there.

Comment thread apps/sandbox/app/shared/sources.ts Outdated
export function getPlaceholderSrc(source: SourceId): string | undefined {
/**
* A CSS image. this upscales a 20px thumbnail instead,
* and the browser's own smoothing does the blurring.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this comment true?


The poster is visible before playback starts. Once the user plays or seeks, the poster hides permanently — pausing does not bring it back. The poster reappears when a new source is loaded.

Set the URL on the player, not on this component:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't actually accurate, is it? In react, you can set a src directly. in html, you can set a src on a child. Setting it on the player is just one way of doing it. Users should be able to self-sort depending on their use case. "You can set the poster url on the player directly. This is useful if you're trying to change the poster inside a skin, or want to set all your media metadata in one place." Something like that.

```
</FrameworkCase>

The value is a CSS image, not a URL, so wrap a URL in `url()` yourself. Anything CSS accepts works, including a gradient, and any blur has to be part of the image. The poster exposes it as the `--media-poster-placeholder` custom property for a skin to paint.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The value is a CSS image, not a URL, so wrap a URL in `url()` yourself. Anything CSS accepts works, including a gradient, and any blur has to be part of the image. The poster exposes it as the `--media-poster-placeholder` custom property for a skin to paint.
Anything CSS accepts works, including a gradient, and any blur has to be part of the image. The poster exposes it as the `--media-poster-placeholder` custom property for a skin to paint.


## Supply your own image

The component fills in a source for you, but you never lose control of the image. `srcset`, `sizes`, `loading`, [`<picture>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture), and framework image components all stay available.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on second thought, don't link to picture

<Poster srcSet="poster-480.jpg 480w, poster-1080.jpg 1080w" sizes="100vw" />
```

To render something else, use `render`. It receives the resolved `src`:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

God I hate how AI writes. "It receives the resolved src". Why should users know what "resolved" means in this context? Either define it or don't use it. Adhere to the jargon guidance in writing-style.md more diligently; consider the user arriving at this guide who doesn't have the same context you do.


<FrameworkCase frameworks={["react"]}>
React renders an `<img>` element. Add a `className` to style it:
React renders the `<img>` itself. Add a `className` and use it as the selector:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
React renders the `<img>` itself. Add a `className` and use it as the selector:
React renders an `<img>` element. Add a `className` to style it:

Why did you rephrase a perfectly legible sentence and made it more confusing

@decepulis
decepulis marked this pull request as ready for review August 11, 2026 22:32
Comment thread packages/react/src/ui/poster/poster.tsx Outdated
claude added 2 commits August 11, 2026 22:47
`Poster` filled `src` from the store whenever no `src` prop was passed,
even when the caller supplied a `srcSet`. HTML's `hasSource` treats either
attribute as the author's, so the two platforms disagreed, and the file
disagreed with itself: the load-status branch already counted `srcSet` as
a source.

It bites with `x` descriptors, where `src` joins the candidate set as the
1x entry: `<Poster srcSet="poster@2x.jpg 2x" />` would load the player's
poster on a 1x display. With the `w` descriptors the docs use, `src` is
ignored, which is why it went unnoticed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fBNWkGLWPzsP4QK6w7u2L
"Set the URL on the player, not on this component" was false in both
frameworks: React takes a `src` prop and HTML takes one on the child. Say
the player is one option and give the reasons to pick it.

Cut the jargon a reader arriving cold would trip on. "Resolved" is our
word, not theirs, so name the thing instead. Drop the MDN links on
`<picture>`, drop the "not a URL" framing the style guide rules out, and
tighten the sentences that had two referents for one pronoun.

The sandbox comment kept an "instead" whose other half was edited away.
Say what the 20px thumbnail is for and stop there.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fBNWkGLWPzsP4QK6w7u2L

Copy link
Copy Markdown
Collaborator Author

Two commits: the docs pass, and one real bug Bugbot caught.

Docs

Took your suggestions on 72 and 164 as written. The rest:

Line 44 was wrong, and you were right about why. "Set the URL on the player, not on this component" contradicts the section two headings below it. Now it says the player is one option and gives the reasons to pick it, close to your wording. The same false claim was in the Poster JSDoc — "which is why the URL is set on the provider rather than here" — so that went too.

Jargon. "Resolved" is gone from the prose in both places. The accessibility line leaned on it to make a point it could make plainly: a URL says nothing about what the image shows. Also dropped both MDN links on <picture>, not just the one you flagged — the only other reference page that links MDN links it once, so two on one page was mine, not the house style.

While in there: two sentences had one pronoun doing two jobs, and "hides permanently" was three sentences from "shows it again."

One "resolved" survives, in poster-css-vars.ts. That's your wording from the last round, so I left it.

"Is this comment true?"

True in substance, not as English. The 20px thumbnail is real, and the upscaling really is what blurs it — no filter anywhere. But "instead" lost its other half when the blurup explanation came out, so it points at nothing. It also ran to two lines for a one-line fact. Now:

/** A 20px thumbnail, upscaled by the browser into a blur. */

The bug

Bugbot found a real one and I'd missed it. Poster filled src from the store whenever no src prop was passed — including when you passed a srcSet. HTML's hasSource treats either attribute as yours, so the platforms disagreed. Worse, the file disagreed with itself: the load-status line already counted srcSet as a source.

It bites with x descriptors, where src joins the candidate set as the 1x entry. <Poster srcSet="poster@2x.jpg 2x" /> would load the player's poster on a 1x display. The docs example uses w descriptors, where src is ignored outright, which is why nothing caught it.

There's a test, and I confirmed it fails without the fix.

Checks

React 409, site 531, site build renders both framework variants. pnpm typecheck reports 12 errors in packages/store/src/html/controllers/tests/snapshot-controller.test.ts — identical with and without my changes, and CI's typecheck is green, so it looks local to this environment rather than the branch.


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8408d3a. Configure here.

Comment thread packages/react/src/ui/poster/poster.tsx
The outcome was keyed on `src` alone. Now that a `srcSet`-only image keeps
`src` empty, swapping the `srcSet` left the key unchanged, so `data-loaded`
carried over into the next fetch instead of reporting `data-loading`.

Key it on both halves of the source. React sees a prop change here, so this
costs nothing; the HTML element still cannot see an author mutate its own
image in place, which stays a known limitation.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fBNWkGLWPzsP4QK6w7u2L
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Communicate Media Metadata to the UI

2 participants