VideoPress: rebuild the first-run and upload experience - #51268
VideoPress: rebuild the first-run and upload experience#51268adampickering wants to merge 44 commits into
Conversation
Extracts the first-run work from a mixed local branch onto current trunk. - New /upload route: dropzone, real wp/v2/media upload with progress, details step, and a success step with a copyable share link. - Tab order responds to library state so an empty library leads with Upload. - Welcome modal on first visit, dismissal persisted per site and user. - Admin_Menu::enqueue_design_tokens() so --wpds-* tokens resolve on the VideoPress admin page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
Splits the dashboard into two shapes and picks between them from one rule: first run only when nothing is published and the library is empty. - New Home screen (routes/home): a recent-videos rail with upload and add-to-a-post actions, and no KPI tiles. No card may render a fabricated "0 views" — when stats are unknown the slot shows nothing. - Upload stops being a tab once you have videos and becomes a header button, so the action stays one click away instead of disappearing. - Welcome modal rebuilt on @wordpress/ui's Dialog, led by a video of the player rather than a two-tab description of it. Fixes a redirect that ejected users mid-flow: every successful upload invalidates the library query before the details step, which on a connected paid site flipped the count and threw the user to the Library before details and success could render. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
The video details screen was a 660px column — the narrowest surface in the product, on the one screen people write on. It now uses the same 1344px container as the rest of the dashboard, split into a writing canvas and a settings rail. - Group the file name, title, description, chapters and thumbnail control into a single "Video details" card below the player. ThumbnailCard keeps only its read-outs and becomes VideoInfoCard. - Move "Add to a post or page" into the page header, where it can now create a new page as well as a new post. It renders only when both a GUID and a content nonce are present, so its items can no longer open a blank editor. - Show the in-progress title in the page heading as it is typed, falling back to "Untitled" so the page never loses its accessible name. Typing does not save. - Split the rating options into a label and a description, so a screen reader announces "G" rather than the whole sentence as the option's name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
The player led the canvas at full width: 828x466 at a 1440 viewport and 892x502 at 1920, which is over half the visible page before a single field has been read. The settings it pushed into the 380px rail did not fit -- roughly 900px of cards against a cap of 584px on a 1440x900 laptop -- so the rail grew its own scrollbar, with no border, background or scroll shadow to tell it apart from the page's. Two scrollbars, no boundary. Both are the same problem. Swapping what each column holds fixes both: the player becomes a 380x214 preview at the head of the right column, and every control moves to the wide canvas. The right column is now ~346px, so the height cap, the sticky positioning, the two viewport-chrome custom properties and the flex-shrink guard all go with it. One scroll surface. The canvas reads in the order someone works: what the video says, what it looks like, who can see it. * Thumbnail is a card of its own and shows the current poster next to the control that replaces it. It was a label and a single button, which is what it had been reduced to; the poster goes through usePosterUrl so a private video shows a skeleton until its playback token lands rather than a broken image. * Subtitles is a card of its own. Both ways in were nearly invisible: a minimal small button reading "Manage" -- the full string was only in an aria-label -- at the foot of the second rail card, below the fold at common heights and absent without a GUID, plus an iconless item in an unlabelled kebab. The kebab keeps its entry. * File name moves to Video info, next to the link, shortcode and upload date. It is a fact about the upload, derived from the source URL, not something a person writes next to the title. The player is a grid sibling of both columns rather than a child of either, placed by area, so the stacked layout below 1100px can lead with it while the read-outs stay last. Row 2 is a flexible track: the canvas spans both rows, and two intrinsic rows would distribute its excess height across them and strand the read-outs halfway down the page. Verified against the real components and the compiled stylesheet in a headless harness at five viewports: player 380x214, right column 346px with no inner scrollbar, no horizontal overflow, and player -> canvas -> read-outs when stacked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
…psibles
Four refinements from Adam's design pass on the preview-player layout:
* Privacy & sharing and Rating move under Video info in the right column.
The split becomes authoring vs. configuring: the canvas holds what a
person writes (title, description, chapters, thumbnail, subtitles), the
column holds the video, the values that address it, and settings picked
once from a fixed set.
* Both settings cards become CollapsibleCards, collapsed by default, with
the current value (privacy level, rating) carried in the header via
HeaderDescription. Expanded, the column ran 407px past the canvas beside
it; collapsed, the two columns are within 4px. Collapsing is not lossy:
CollapsibleCard.Content defaults to hidden-until-found, so find-in-page
still reaches the controls, and the header summary reaches assistive
tech as the trigger's accessible description.
* The thumbnail dropdown becomes a row of 16:9 tiles: current poster,
Upload image, Select from video — the choice is visual, so a menu that
hides both options behind a label was the wrong control. The design
system has no media picker, upload tile or dashed container (the survey
is documented in thumbnail-tile.tsx), so the tile borrows Button's
unstyled variant for the interaction and paints the frame from WPDS
tokens. thumbnail-update-button.tsx is removed.
* The Subtitles card stacks its state line above the button — it shared a
row with it, which read as fragments ("None [Manage subtitles]") — with
the copy softened to "No subtitles yet." and a Skeleton while the
tracks query resolves.
Adds first-ever test coverage for the Privacy & sharing and Rating cards
(collapsed default, header value, expand-and-edit, find-in-page survival).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
…un state Checkpoint of the in-progress first-run work before the modal is rebuilt to the new Figma design (VideoPress Revival, node 56-12912): Dialog-based modal with intro video band, useSettledFirstRunState() so returning users aren't misrouted while the library count loads, and upload-stage tweaks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
The first-run modal now leads with the product itself: the intro film playing in a full-bleed 16:9 band over the brand wireframe artwork, then the pitch on white — "Your Video. Your Player.", three hairline-separated value cards, and a Learn more / Upload a Video footer. - Band background is the committed wireframe artwork on deep green, so the modal never flashes a foreign colour while the film loads and still reads as designed if the video is missing. - IntroVideo prefers a VideoPress GUID (the player selling itself) and falls back to the bundled film; getAssetUrl no longer throws the whole dashboard down on a malformed buildUrl. - Value cards use @wordpress/icons glyphs with the one brand-green accent; columns collapse to a stack below 540px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
Three independent design audits (spacing, typography, DS craft) over the rendered modal, all findings fixed: - Headline and lede now actually render at the spec's 32/40 and 15/24 — Dialog.Title/Description pin their own internal variants and route font-size through the design system's global-css-defense custom properties, so the tokens are applied through both channels. - The cards list dropped the user-agent's 40px list indent that pushed the columns out of alignment with the headline; hairlines are now centered pseudo-elements so all three columns share one content width. - Column collapse is a container query on the modal's own width, not a viewport media query — the popup shrinks long before any phone breakpoint. - 24px rhythm: lede to columns and columns to footer, with the cards-to-footer distance split across the scroll seam so content clears the separator when the body scrolls. - Close button: rendered before the player so it is one Tab away, white focus ring for the dark band, 44px hit target, 16px inset. - Autoplay parity between the embed and the bundled fallback, both gated on prefers-reduced-motion. - Card titles use heading-lg instead of a hand-rolled weight override; middle card copy tightened so all columns hold two lines; "Upload a video" in sentence case; role="list" restores Safari list semantics. - Bundled fallback film updated to the v8 master: 4K-derived, scored, with embedded cover art. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
Card 2 promises "move library videos over in one click" — now the footer delivers on it. On sites whose media library holds videos VideoPress could host, the secondary button becomes "Move N videos over" with the real count, deep-linking to the Library pre-filtered to local videos where the existing bulk "Upload to VideoPress" action does the moving. Empty sites keep the "Learn more" link. - New use-onboarding-counts hook: two 1-row count queries (VideoPress / local) following the first-run count idiom; nothing acts until both settle, so the modal never opens with a wrong label. - The modal's gate widens to "no VideoPress videos yet" — the migration pitch is exactly for sites full of local videos. The landing redirect and tab order keep the stricter zero-of-any-videos rule. - The primary CTA now navigates to /upload: with the wider gate the modal can open over Library or Home, where nothing upload-shaped is underneath. - The Library seeds its type filter from the route's ?type=local search param (read via the router — wp-admin encodes app search inside p=). Seed-once; filters stay unpersisted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
The one-shot guard was a module variable, but each route ships its own bundle with its own copy of the module — so "once" was really "once per route bundle". Landing on Home burned Home's copy; navigating to the Library loaded a fresh copy that fired the "landing" redirect again and bounced straight back, making the Library unreachable from Home (and swallowing the welcome modal's new deep-link). A window-scoped flag is shared across bundle copies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
…onboarding # Conflicts: # projects/packages/videopress/src/dashboard/components/add-to-content-menu/index.tsx
- The File Name fields never saved anywhere (the publish PATCH carries only title/description) — an input that edits nothing is a lie. The filename now renders as static text in the row header. - StepFlow froze its wrapper height at step entry, so content growing within a step (error banners, per-row messages) overflowed the frame. A ResizeObserver now tracks the active card for its whole life. - Copy buttons on the success step used navigator.clipboard directly, which is undefined on plain-HTTP dev sites; they now fall back to the hidden-textarea path the clipboard libraries use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
Succeeded uploads now stay in the shared queue carrying their media
result ({ id, guid, src }) until a consumer acknowledges them, instead
of self-deleting after two seconds — which threw away the only handle
anything downstream had on the finished video. This is the substrate
for the upload pill and the upload→edit transition, both of which need
to link a completed upload to /video/:id.
- UploadItem gains `media`, populated from the tus success payload.
- New `acknowledgeUpload( id )` removes settled items only; in-flight
items are untouched (cancellation is a different operation).
- The Library's in-flight row splice already excludes success rows, so
retention adds no duplicate rows there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
The /upload flow ran its own plain wp/v2/media XHR — a documented local- dev stand-in that produced attachments the edit screen cannot open and uploads invisible to the rest of the dashboard. On connected sites it now feeds the shared tus queue: the real VideoPress uploader, resumable, observed by the Library's in-flight rows and useFreeTier alike. - Queue items for the current batch render through the existing step UI via a mapped view; when the batch settles, results are snapshotted into flow-local state and acknowledged out of the queue. - Back from details/success acknowledges settled rows; in-flight rows keep uploading (Back is not Cancel). - The XHR path survives solely as the disconnected-site fallback, keyed off the new isWpcomConnected() helper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
Drop one video and the flow cross-fades immediately to the same editing surface the /video route uses — in a draft session: title and description are editable from the first second (client state needs no attachment), the player slot is the upload's stage (progress → processing → a one-time "Your video is live" moment with the share link and Add to a post), and Save enables the moment the real record binds. Typed edits survive the draft→real swap. Failure keeps the form intact with Retry in the player slot. Multi-file drops keep the batch path. - Editor extracted from routes/video into video-details/editor.tsx (+ co-located styles) so both routes render one surface; the video route's behavior is unchanged. - useVideo now polls GUID-less records too — previously a video could gain its GUID and the screen would never notice. - useVideoDetailsForm gains preserve-dirty-on-rebind for the id swap. - Cards that need a GUID render skeletons during the session instead of popping in; the ⋯ menu waits for a real attachment. - markFirstPublish fires when the live moment first shows — this flow's publish moment; there is no separate publish button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
Live testing caught a self-inflicted remount: the FIRST upload on a first-run site flips the first-run state mid-flight (the library count goes 1), the tab order changes, and the flow component remounts — wiping the component-state pointer to the in-flight batch while the window-scoped queue kept uploading without a listener. The edit surface then sat on "Uploading… 0%" forever. Queue items now carry a context tag naming the flow that started them, and the flow adopts its own unacknowledged items from the queue on mount — the queue is the durable truth; component state is just a view of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
Second remount casualty from live testing: the attachment id was copied into EditStep state at bind time and the queue row acknowledged away — so when the first-run flip remounted the subtree, the new instance had neither the state nor the row, and the player stage sat on "Uploading… 0%" over an already-finished video. The id is now derived from the queue row on every render, and the row lives for the whole edit session (acknowledged on the flow's exit paths, not at bind). Component state is a view; the queue is the truth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
Design feedback from live review: - The "Get your first video online" tile card duplicated the dropzone beneath it (and its only working tile just opened the same picker); the import tile was a disabled promise. One card, one action. - The welcome modal's cards-to-buttons distance widens from 24px to 32px, split across the scroll seam as before. - The modal sits at 42% rather than dead center — dialog convention weights the empty space toward the bottom, and the band-heavy layout reads better lifted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
The dropzone is the page's one action; at full container width it read as a void. 718px per design review, centered. Only the dropzone card narrows — the edit surface that follows it in the same flow keeps the full width. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
Design review needs to re-see the modal at will; hand-clearing localStorage is nobody's workflow. The `welcome=1` search param opens the modal past the dismissal flag and the library gates, and forgets the stored dismissal so plain loads behave fresh again afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
The stage panel inherits the player frame's width: 100% and adds its own padding — as content-box, the padding landed outside the 380px preview track, hanging the panel 40px past the layout (off-screen on smaller windows). border-box keeps the padding inside the frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
On a fresh page load the router only parses search it finds inside the encoded `p` path param, so `p=%2Fhome%3Fwelcome%3D1` never reached useSearch. The raw query string check makes `&welcome=1` on admin.php work — which is also the easier URL to hand around for review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
Dropping 2+ videos now lands on the Library — whose in-flight rows already show per-file progress — with one floating pill (bottom-right, every dashboard screen) carrying the batch: collapsed it reads "Uploading 3 videos — 47%" with a combined bar; expanded it offers per-file cancel/retry and, once settled, "Add details" links that walk the batch through the edit screen. Failed rows never auto-dismiss; dismissing the pill is not cancelling (cancel is per-row, confirmed). - cancelUpload joins the shared queue: pending rows are removed, the active upload aborts through the owning instance's tus handle; a non-owner cancel marks the row and the owner cleans up on its next callback — no zombies, no cross-instance handle passing. - The pill stands down while the /upload edit session is the progress surface (a context-tag suppression rule, not route sniffing). - Free plan: files discarded by the one-video slice now say so at drop time, with the real count — previously they vanished wordlessly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
Design review: nobody adds a video to a post from the Home overview — that action lives with the video itself, so the header keeps only Upload video. And a user whose library has been emptied now gets the actual drag-and-drop experience inline on Home instead of a button that bounces to another page: dropping a file starts the shared-queue upload and lands directly in the edit session (the /upload stage now resumes into the edit step when it adopts a single in-flight item). The dropzone itself is extracted to a shared component — one drop surface, one free-plan slice + message, used by /upload and Home. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
The empty-state Home stacked four upload invitations: the header
button, an EmptyState text block, the dropzone's heading, and the
dropzone's button — under a "Recent videos" label with no recents.
Now the dropzone IS the empty state: the text block and section label
go, its useful promise ("ready to share or drop into a post") moves
into the dropzone sub-copy, and the header's Upload button stands down
until videos exist. Status stays announced via a visually-hidden
heading. The /upload page keeps its own first-run copy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
…fecycle
Four independent reviews of the upload work found two designer-reported
symptoms sharing three root causes. This fixes all of them.
The handoff (the design truth): a single upload no longer lives out its
life in an editor embedded under the dashboard tab strip. The upload
screen is now a bridge — it shows progress and takes the title you type
— and the moment the video registers with WordPress.com it hands you to
/video/:id, the same dedicated screen the Library opens, carrying your
typed edits and the "your video is live" moment with it. The ~150 lines
where the bridge duplicated that route's save/delete/captions wiring are
gone.
The queue's lifecycle: rows were durable but nothing owned their end, so
a deleted video left a row behind that haunted the pill ("Add details"
to a 404) and let /upload resurrect a dead session as an eternal
"processing" screen. Deletes now reconcile the queue, adoption ignores
settled rows, and every upload session ends by acknowledging its row.
Uploads also gained real per-session identity: cancelling during the
token round trip no longer leaks a headless upload or writes one video's
result onto another's row, and two mounts can no longer dispatch the
same file twice.
The navigation gates: /upload no longer ejects users who own videos —
which silently broke Home's own Upload button and the welcome modal's
CTA. The landing redirect only fires for a genuine landing (a bare URL,
no app path), the tab order freezes for the life of a screen instead of
reshuffling mid-flow, and welcome=1 is consumed once instead of
reopening the modal on every navigation.
Also: deletes confirm before destroying (they are permanent, no trash);
deleting your last video lands on Home, which has the dropzone, rather
than an empty Library; the thumbnail and subtitle cards hold skeletons
through the whole upload-and-processing wait instead of appearing late;
Home's dropzone gets the same card and measure as the upload screen; the
celebration announces itself and takes focus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
The post-upload celebration was junk of my own making. It stacked a raw URL, Copy link, Add to a post or page, and Watch video into the player's slot — and every one of those already existed on the same screen: its Copy link called the same helper as the Link to video field beneath it, its Add to a post or page was the *same component* as the header's (both mounted at once, identically labelled, so screen readers announced two identical triggers), and Watch video existed only to dismiss the celebration. Worse, it didn't overlay the player, it replaced it: at the one moment the product should prove "your video, your player", there was no player. Now the upload's processing stage hands the slot straight back to the player, and a snackbar marks the moment for the flow that earns one — a single upload, not each file of a batch chained through "Add details". Underneath, the share plumbing that produced the duplication: - One link resolver. `linkForVideo` fell back to the attachment id, producing videopress.com/v/<id> — a dead link behind a Copy button that copied it anyway. Home's resolver (null when there's no honest link) wins and moves to utils/video-links.ts; the info card hides the field rather than offering a lie. - The queue's `celebrated` flag and its setter go with the celebration; a row is now consumed by standing on its video's page. Left deliberately: Home's card actions (a list's quick actions, not duplicates on one video), the Privacy & sharing Share toggle, and the player's own share menu — the preview should show what viewers see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
The team already had this one on its list — "transcoded video interrupts playback, forces the user to hit play again" — and our code was causing it: the player slot SWAPPED the embed for the upload stage, so the instant `isProcessing` flipped, the iframe was torn down and rebuilt and whatever was playing stopped dead. Our 2s polling made that flip land promptly, so we made it easier to hit, not harder. The stage now overlays a player that stays mounted for the whole session. The regression test asserts element identity across the transition — anything weaker passes on the old code. Also documents the bundled intro film as a placeholder: its footage and music are stand-ins, not cleared for release, and a fully licensed video replaces it before release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
…d-onboarding # Conflicts: # projects/packages/videopress/src/dashboard/components/overview/free-tier-notice.tsx
|
Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.
Interested in more tips and information?
|
|
Thank you for your PR! When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:
This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖 Follow this PR Review Process:
If you have questions about anything, reach out in #jetpack-developers for guidance! Jetpack plugin: The Jetpack plugin has different release cadences depending on the platform:
If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack. Videopress plugin: No scheduled milestone found for this plugin. If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack. |
The upload card sat at 718px while the free-plan notice above it ran the full 1344px container, so the empty screen read as a wide bar over a narrow card, and neither matched the Settings tab beside it. Both now take the canonical 660px Jetpack settings measure, centered — the width Settings already uses (routes/settings/style.scss). Only these narrow; the edit surface in the same flow still needs the full width. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
Code Coverage SummaryCoverage changed in 21 files. Only the first 5 are listed here.
22 files are newly checked for coverage. Only the first 5 are listed here.
Full summary · PHP report · JS report If appropriate, add one of these labels to override the failing coverage check:
Covered by non-unit tests
|
|
Pushed a width fix: the upload area and the free-plan notice now take the same 660px measure as the Settings tab, centered. Before, the upload card sat at 718px while the free-plan notice above it ran the full container width, so the screen read as a wide bar over a narrower card, and neither lined up with Settings next to it. Both are now 660px — the same value Only those two narrow. The edit screen that follows an upload still uses the full width, since it needs the room for the two-column layout. |
Stats, Home and the Upload page all sit in a 1344px column with a 24px gutter; the Library had no container at all, so its listing ran edge to edge and didn't line up with the tabs either side of it. It now takes the same measure, as a flex column with `flex: 1` and `min-block-size: 0` so the DataViews viewport still grows into the tab panel and keeps its pagination pinned to the bottom. The at-limit notice loses its own gutter margin, since the container's padding supplies it now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
|
Width audit across every tab, measured on a live site at a 1600px viewport:
Stats and Home already agreed. The Library was the outlier — it had no container at all, so its listing ran edge to edge while the tabs either side of it sat in a column. It now takes the same 1344px measure and 24px gutter, kept as a flex column so the DataViews listing still fills the tab panel and keeps its pagination pinned to the bottom. Upload and Settings intentionally keep the narrower 660px measure for their content: Settings because that's the canonical Jetpack settings width, Upload because the drop zone is the page's only action and reads as a void at full width. Upload's outer container still matches the others, so the page starts at the same left edge as its neighbours. |
This reverts commit d1b17bd.
Three fixes from a walkthrough:
The upgrade notice said two different things depending on which tab you
were on — the free-plan sentence on Home and Stats, a shorter at-limit
line on Library, Settings and Upload. Every surface now shows the same
sentence. The short form survives only where a full sentence doesn't
fit: the disabled-upload tooltips and the rejected-drop toast. Home was
hard-coding its own copy of that tooltip string; it now shares the one
constant.
Opening VideoPress from the WordPress menu lands on a bare URL, which
resolves to the Library because Library owns `/`. The landing redirect
then sends you to Home or Upload, so the Library painted first and was
yanked away — it read as the page loading twice. The body now waits for
that decision, but only on the bare landing and only while the count is
unknown, so a failed count still renders rather than hanging.
Home's free-plan notice only takes the narrow measure when the dropzone
is beneath it. Next to the recents rail it stays full width; centred at
660 over left-aligned cards it just looked misaligned.
(The zoom on that same load is WordPress's own admin view transitions —
`@view-transition { navigation: auto }` from core's
wp-view-transitions-admin-inline-css — not this dashboard.)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcAb177MwHKkw7D5RPrSvq
An audit of the dashboard turned up 46 things; these are the ones worth fixing on this branch. The rest are pre-existing code outside this PR's diff, or need a decision rather than a patch. The one real bug: `&welcome=1` — the URL in this PR's own testing instructions — did not open the modal on a site that already had videos. A bare admin URL carries no app path, so it resolves to Library and the landing redirect fires; each route is its own bundle, so that navigation remounts the modal, by which point the one-shot param had already been consumed and `isOpen` fell through to the empty-library check. It only appeared to work on an empty site because the modal would have opened there anyway. The window latch is now two values: consumption still drives the one-shot `clearDismissal()`, while a separate flag keeps the modal open for the rest of the page load. A dismissal still sticks — `isDismissed` re-seeds from storage on every mount. Screen readers: * The upload panel wrapped the percentage in `aria-live`, so it spoke a new number on every progress tick. Now only the phase is announced, matching what the upload pill already does; the percentage stays visible. * The video-details cards rendered their titles as plain divs, leaving the screen with one heading. They are h2s now, as the design system's own docblock prescribes. * Both copy buttons in the video-info card were named "Copy"; they now name their field. * The rating radio group and the frame scrubber had no accessible name. * The free-plan notice is permanent, but was being spoken aloud on all five screens on every navigation. Visible fixes: * The same video read `0:34` on Home and `00:34` in the Library. Home had its own duration formatter; deleted in favour of the shared one. * At the free-plan limit, the upload button was styled as unavailable on Library but looked completely live on Home. * Picking a video frame made the *other* thumbnail tile say "Updating…". The busy state now sits on the thing that is actually changing. * Four straight apostrophes among curly ones, all in strings new here. Also corrects a docblock in video-details-card that confidently claimed the two label styles already agree. They do not — measured 600 vs 499 weight and a different label-to-field gap. The comment now says so, and why it is being tolerated for now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpbCbrkUsQUCDsaLxyMsd4
Two testers walked the product as customers — one brand new, one returning with a library. These are the things that stopped them. **"Add to a post or page" created an empty draft.** The button opens post-new.php with the video's GUID, and a `default_content` filter is supposed to turn that into a video block. The filter was registered *after* an early return that fires whenever the standalone VideoPress plugin is absent — so on any site running VideoPress through the Jetpack plugin it was never hooked at all, and the editor opened on an untouched empty string. The guard is right for the shortcode registrations beside it, which would otherwise collide with the ones the Jetpack module already makes; this filter has nothing to collide with, so it now registers unconditionally. **Dropping a file painted two screens on top of each other.** The outgoing card is absolutely positioned for the cross-fade, and `inset: 0` pinned its bottom edge too, stretching it to the height of the editor arriving underneath. Its vertically-centred contents drifted into the middle of that editor, so "Drag and drop your video here" printed through the Description field. It now pins only the top and sides and keeps its own height. **At the plan limit the drop zone silently ate files.** Three separate floors — the component dropped its handlers while disabled, and both callers returned early — under a surface still reading "Drag and drop your video here". Refusal now lives in the component, so every drop, click and pick raises the same notice, with the upgrade action the library already uses. The button matches the surface instead of contradicting it, and it explains itself on click rather than only on hover. **A .txt renamed .mp4 uploaded all the way through**, took the free plan's only video slot, and left the thumbnail and subtitle panels loading forever. The Library route already had the right check; it now lives beside the shared drop zone so every entry point gets it. The panels also stop pretending: a video that never resolves now says so instead of animating indefinitely. Polling is unchanged — this is a display bound, not a claim about the backend. **Arriving from the WordPress menu flashed the wrong navigation.** An earlier fix held the body back but not the tab strip, so the first-run tabs still painted for ~400ms and then renamed themselves. Both are now held over the same window. A brand-new user still gets the optimistic first-run strip immediately on every other arrival. **The modal's background artwork never loaded.** The stylesheet asked for `url(images/…)`, but the CSS is injected by JS, so it resolved against /wp-admin/ and 404'd on every load. It now goes through the same build-URL helper as the film, keeping the flat green underneath for when that can't be resolved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpbCbrkUsQUCDsaLxyMsd4
Follow-up to the walkthrough fixes. Testers re-ran the journeys and found three of them incomplete, plus one thing the last round broke. **The file check didn't catch the case it was written for.** It trusted `File.type` — but browsers derive that from the extension, so a text file renamed `.mp4` reports `video/mp4` and passed. It now reads the first 64 bytes and checks the container signature: ISO-BMFF for the mp4/mov family, EBML for WebM and Matroska, and Ogg. It fails open on purpose — an extension with no known signature rides on its extension, and an unreadable file is accepted, because refusing somebody's real video is far worse than accepting a renamed text file. `.avi`, `.wmv` and the MPEG family still have no signature and so still pass on their extension alone; those formats want a follow-up rather than a half-remembered magic number that rejects real footage. An invalid file also now says it is invalid even when you are at the plan limit, instead of blaming the limit for it. **The tab strip stopped guessing.** It was deriving first-run state from a count that reads 0 until it loads, so every in-app arrival painted the first-run tabs and then corrected itself — a rename on one route, a tab appearing and vanishing on another. That guess is now only made when there is nothing better: the first settled count proving a library is not empty is remembered, and later loads answer from it immediately. A brand-new user is unaffected and still gets the first-run strip on the first render, which was the reason the guess existed. The same absence of a record was behind the second bug: the "has published" flag was only ever written by an upload made in this browser, so anyone whose videos predate it looked brand new the moment they deleted their last one. **The welcome modal no longer opens over a running upload.** Emptying your library does legitimately restore the first-run condition, but somebody with a file in the queue has already found the one thing the modal is selling. It stands down for the rest of the page load. Also: the disabled upload button inside the drop zone now looks disabled and explains itself on hover, matching the header buttons rather than contradicting them; refusal notices replace each other instead of stacking; and the landing hold now reads as loading rather than as a page that has finished rendering nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpbCbrkUsQUCDsaLxyMsd4
…ether Last round of fixes from the walkthroughs. **A bare landing no longer paints a screen it is about to leave.** The hold added previously released on three conditions that all flip before the destination paints — the settled count commits a render before the effect that navigates can run, and the redirect rewrites the URL while Library is still the mounted route. So the Library rendered anyway, for about 400ms, complete with a live "Upload video" button, on the one path where Library is never the answer. How the mount arrived is now captured once, so the hold is constant for its life: spinner, then the real destination. A deliberate later click on Library is still never hijacked. The structural fix is for Library to stop owning `/` — there is a note saying so where the next person will find it. **A returning user is recognised from any route.** Both first-run flags were written from components that only some routes mount, so opening a video link in a fresh browser recorded nothing, and deleting that video looked exactly like a first run. They now have one writer, mounted in the wrapper every route shares. The two flags stay separate on purpose: the modal should still greet a site full of local videos with the migration pitch, which "library seen" proves and "has published" does not. **A real WebM is no longer told it isn't a video.** The refusal was right — the backend doesn't accept it — but the message was a lie, and the file picker was offering the formats it would then reject. The picker now offers only what the backend takes, and the two refusals read differently: an unsupported container says which formats to convert to, a file lying about its extension says it isn't a video. **The drop no longer fades two layouts through each other.** Both cards animated over the same window, so through the middle both were painted — and a drop zone and an editor form have no shared line to fade between. The handover is now sequenced: the old card leaves, then the new one arrives, in the same total time. Transitions between similarly-shaped steps keep the cross-fade, which is what it was designed for. Also fixes a test helper that shipped literal NUL bytes, which had been making git treat that file as binary and hiding its diffs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpbCbrkUsQUCDsaLxyMsd4
Handover tidy-up, driven by the coverage report for this PR. No behaviour changes — deletions and tests only. **Deleted the parked "Try a sample" block.** `SampleVideoModal` and its six exclusive helpers were referenced by nothing but each other, behind an eslint-disable saying so. Around 250 lines of unreachable code in the first file anyone reads, plus the stylesheet block for an element that no longer exists. Every symbol was checked against the whole package before removal. `routes/upload/stage.tsx` is down from 1742 lines to 1491. **Tested the disconnected-site upload path, which had never once run.** Its tests pinned `isWpcomConnected` to true and never flipped it, and the live walkthroughs all ran against connected sites — so the fallback that uploads through `wp/v2/media` had no coverage from either direction. It is also the path anyone running a local install hits first. It turns out to work: the drop, the upload, the details step and the success step all pass, and so do the failure paths — a network error, a non-2xx with and without a usable server message, and a missing nonce. Six tests, plus a direct one for the connection helper, which reads as untested only because every caller mocks it. **Tested the Library's refusal gates.** The same gates on the upload screen were covered; these were not, on the surface where a silently eaten drop was one of the bugs this branch set out to fix. Seven tests over the not-a-video message, the plan-limit notice and its upgrade action, the partial-selection count, and the header button's refusal. Neutering the four gates fails six of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpbCbrkUsQUCDsaLxyMsd4
Sequencing the hand-off removed the double-print, and a live capture confirmed it: not one frame paints both cards. But it exposed the wrapper's height transition, which starts when the step changes and so eases open during the exit — and through a sequenced hand-off both cards are at opacity 0, so what expands is an empty box. Measured on a real drop, the wrapper had reached 654 of 734px with nothing painted in it, turning a card-sized pause into a full-width blank about 470px tall. Delaying the height by the exit duration holds it until the outgoing card has actually gone, then eases it open with the card arriving to fill it. Scoped to the sequenced case: the same-shape steps want the height to track the card already fading in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpbCbrkUsQUCDsaLxyMsd4
The previous commit was measured on a live site and made no difference to a single pixel. Holding the wrapper's height was the wrong lever: the blank the user sees is bounded by the white admin panel behind the flow, not by the wrapper, which only reserves space below itself. A frame diff of before and after came back at 0.00% changed. That change is reverted here along with the constant it needed. The gap was never the height. It was the incoming card being held at opacity 0 until the outgoing one had finished leaving — two timings butted end to end, with ~105ms of nothing between them. So stop animating the outgoing card at all. On a shape-changing handover it is now simply not drawn, exactly as the reduced-motion branch has always done, and the editor fades in immediately over the full duration. A drop is a deliberate act with its own feedback; the dropzone does not need a farewell. One thing moves instead of two, there is no dead band between two clocks to fall into, and "the two cards never co-occupy" is true because one of them was never painted rather than because the timings happen to line up. Same-shape steps keep their cross-fade, which is what it was designed for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpbCbrkUsQUCDsaLxyMsd4
Every entry on this branch was filed against packages/videopress only. A package entry reaches that package's changelog and nowhere else — the plugins that bundle it get, at most, the release tooling's generic "Update package dependencies" line, which never reaches readme.txt. Since users, release posts and support docs all read the plugin changelog, a whole rebuild of the first-run and upload experience would have shipped invisible. Adds entries to both plugins that ship the package, written from their own users' point of view rather than copied: the new first-run and upload-to-edit experience, and the add-to-a-post fix, which is the one change here that a user could have hit as a plain bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpbCbrkUsQUCDsaLxyMsd4
The entry I hand-wrote used `fixed`, which is valid for a package and for the VideoPress plugin but not for the Jetpack plugin — that one defines its own set, where the equivalent is `bugfix`. Changelogger validity caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpbCbrkUsQUCDsaLxyMsd4
Tests only, no behaviour change. The coverage report flagged both files as carrying more uncovered lines than trunk despite higher percentages, and the uncovered lines were not incidental. In the upload queue they were the cancel/abandon guards the file's comments spend paragraphs justifying: a progress or error event arriving for a row the user already cancelled, a tus handle turning up after its session ended, and cancelling in the window before tus has produced a handle at all. Those guards are the reason a cancelled upload can't have its media attributed to the next row in the queue. That is the worst bug this file can produce and the hardest to reproduce, and two people are about to build on it, so it should not have rested on the comments alone. In the dashboard layout it was the guard that keeps the active tab in the order when the order has dropped it — which the comment above it describes as reachable in normal use, because the first successful upload flips the state to `home` and drops `upload` while the user is still standing on it. Without the guard the test renders a blank page, which is exactly what it prevents. Two of the new tests pass whether or not their guard is present, and are labelled as such rather than counted as proof: `onError`'s abandon branch is currently unobservable, because falling through releases the queue anyway and the abort it would call is undefined in every state the store can presently reach. It is defensive, not load-bearing — unlike its counterpart in `onProgress`, which is the real one. If a future change lets a live row be removed while its session still holds a handle, that branch starts mattering and its test starts being able to fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpbCbrkUsQUCDsaLxyMsd4
…icon Two things from a review of the home and upload screens. The recent-video card's picture did nothing. It is the biggest target on the card and the thing people aim at, but only the title underneath opened the video. It opens it now. It stays out of the accessibility tree rather than becoming a second button: the title already goes to the same screen, and two tab stops onto one destination is worse for a keyboard user than one — the thumbnail is decorative either way, since the title is the card's accessible name. The upload icon was a bare glyph where the rest of the dashboard puts this kind of icon in a circle. It now uses the design system's own `EmptyState.Icon`, which is where Jetpack Social gets the same treatment, so the circle, border and fill come from the tokens rather than from a shape drawn here. Its trailing margin is dropped because that spacing is meant for the design system's empty-state column and this surface already sets its rhythm with a flex gap; left in, the two stack and the icon floats away from its heading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpbCbrkUsQUCDsaLxyMsd4
Proposed changes
This rebuilds what happens the first time someone opens VideoPress, and what happens when they upload a video.
Before: you landed on a stats page full of zeros. The upload button was a small button in the top right corner. If you found it and uploaded something, you got a progress bar, then a form to fill in, then a "published" page, and only after all that could you actually see or edit your video. If you already had videos sitting in your WordPress media library, nothing ever told you they could be moved to VideoPress.
After: you land on the upload screen. There's one big drop zone and one button. That's the whole page.
Here's the full journey now:
A welcome modal introduces the product. It plays a short film inside the actual VideoPress player, so the thing making the argument is the product itself. Underneath: three short claims (a player you own, bring existing videos, share it anywhere) and two buttons. If your media library already has videos that aren't on VideoPress, the second button counts them and says "Move 3 videos over", and takes you to your library filtered to exactly those videos, where you can move them across in bulk. If you have nothing to move, it's a "Learn more" link instead.
Dropping one video takes you straight into editing it. No progress page, no form, no success screen. The edit screen appears immediately and you can type the title and description while the file is still uploading. The upload itself shows in the player area, so you can see where it's up to. When the video finishes registering, you're handed over to that video's own page — the same full screen you'd get from the library — with everything you typed still there and Save now available. When it's ready to play, the player appears and a small message confirms it's live.
Dropping several videos takes you to the library instead. Each one shows its progress in the list, and a small panel in the bottom corner tracks the batch: "Uploading 3 videos — 47%". Open it and you get each file with its own cancel or retry, and when they're done, links to go and add details to each one. It follows you around the dashboard, so you can carry on working while they upload.
If something fails, the error appears where the video should be, with a retry button, and anything you'd already typed stays where it is.
Deleting your last video puts you back on the home screen with the drop zone waiting, rather than an empty list.
Other things fixed along the way
Related product discussion/links
Does this pull request change what data or activity we track or use?
No.
Testing instructions
You need a site connected to WordPress.com. A Jurassic Ninja site with Jetpack connected works.
Start empty. Delete any videos in the library first, so you get the first-run experience.
/video/<id>and the page should become that video's own screen, with your title still in the field.To see the modal again after dismissing it, add
&welcome=1to the dashboard URL. It reopens it and clears the saved dismissal.Notes for whoever picks this up
A few things are deliberate but worth knowing:
routes/upload/stage.tsxstill carries the old "Try a sample" code, which nothing renders. It's left in place rather than deleted in this PR.&welcome=1works in production, not just locally. It clears the saved dismissal.Known things we didn't fix here
We audited the whole dashboard for polish before handing this over. Most of what came back was either pre-existing code this PR doesn't touch, or needed a decision rather than a patch. Listing it so it isn't rediscovered from scratch:
The two worth doing next:
Smaller, all self-contained:
VIEWS,TITLE,WATCH TIME— capitals baked into the translatable string — and then applies uppercase in CSS on top. Six strings to sentence case, and the KPI tiles read out as "VIEWS0" because the label and value run together with no separator. Its two ranking cards also aren't named in parallel ("Most viewed" vs "Top videos by watch time"), and "Views trends" isn't quite English.inert, which React 18.3 here doesn't accept as a plain boolean.routes/video-editor/style.scssdefines its own--vp-*colour and radius variables instead of using the design system's, and Analytics and Library still have hardcoded hex greys.routes/upload/style.scssis fully converted and shows what the tokens should be.