From 7352f845802a91a33aea6383234bd66fff96e34f Mon Sep 17 00:00:00 2001 From: Peter Zimon Date: Tue, 29 Sep 2026 18:18:08 +0200 Subject: [PATCH 01/17] Improved editor preview layout and controls (#31090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refines the React editor preview so the controls stay centered and the content uses the available space. Fixes [PLA-459 — Preview refinements](https://linear.app/ghost/issue/PLA-459/preview-refinements). - Centers pill-shaped format/device controls and ghost audience selectors with dropdown chevrons when space allows. Controls shift toward the title as space narrows; device controls hide below 800px, and the title and audience selectors hide below 640px, keeping format tabs and actions on one row. - Makes desktop Web previews fill the area below the header without gutters. - Groups Copy preview link and Open in new tab in an icon-only Share menu; Close uses ghost styling. - Limits desktop Email previews to 720px on a light grey canvas, using the sidebar corner radius and mobile preview shadow. Both Web and Email mobile previews use the same light grey canvas. - Keeps long newsletter and tier labels clear of the actions, and the top of mobile previews accessible on small screens. - Preserves centered loading and error states while mobile frames align to the top. - Refines Shade Select spacing globally: removes the extra chevron left margin and reduces right padding from 12px to 8px when a chevron is visible. - Makes the desktop email subject editable, saving through the editor session on blur or Enter with title fallback and a 300-character limit. Sender and subject controls align in shared columns at a local 28px height, with the subject outline always visible. Test sending waits for saved edits; failed saves preserve the entered subject. If an invalid subject blocks reopening preview, the failure screen keeps it editable so it can be corrected without losing other edits. Validation: 43 preview browser tests, 64 editor header/history browser tests, 15 preview URL unit tests, 531 editor session/tracker/field unit tests, Admin typecheck, focused lint, and commit hooks passed. Visually checked Web/Email previews and Share controls at desktop widths, 640px, and 390px mobile. Shade typecheck, lint, and all 284 unit tests also pass; Select spacing was checked in Storybook and the editor preview. Existing role permissions, audience links, and save-before-preview behavior remain covered. - [x] I've read and followed the [Contributor Guide](https://github.com/TryGhost/Ghost/blob/main/.github/CONTRIBUTING.md) - [x] I've explained my change - [x] I've written an automated test to prove my change works --- .../src/editor/editor-header-actions.tsx | 8 + .../editor/editor-header.acceptance.test.tsx | 148 +++++++++ .../src/editor/engine/change-tracker.test.ts | 1 + .../admin/src/editor/engine/change-tracker.ts | 2 + apps/admin/src/editor/fullscreen-dialog.tsx | 19 +- apps/admin/src/editor/preview/README.md | 30 +- .../src/editor/preview/browser-preview.tsx | 13 +- .../src/editor/preview/email-preview.tsx | 103 +++--- .../src/editor/preview/email-subject.tsx | 79 +++++ .../post-preview-modal.component.test.tsx | 203 +++++++++++- .../src/editor/preview/post-preview-modal.tsx | 294 +++++++++++------- .../src/editor/preview/preview.screen.ts | 8 +- .../src/editor/preview/send-test-email.tsx | 2 +- apps/admin/src/editor/session/README.md | 2 +- .../src/editor/session/projection.test.ts | 37 +++ apps/admin/src/editor/session/projection.ts | 11 + .../editor/session/settings-fields.test.ts | 8 + .../src/editor/session/settings-fields.ts | 7 +- .../settings/use-settings-field.test.ts | 1 + .../src/components/ui/select.stories.tsx | 3 +- apps/shade/src/components/ui/select.tsx | 3 +- .../testing/test-data/src/selectors/editor.ts | 2 + 22 files changed, 806 insertions(+), 178 deletions(-) create mode 100644 apps/admin/src/editor/preview/email-subject.tsx create mode 100644 apps/admin/src/editor/session/projection.test.ts diff --git a/apps/admin/src/editor/editor-header-actions.tsx b/apps/admin/src/editor/editor-header-actions.tsx index 824f28d341a..e3441871434 100644 --- a/apps/admin/src/editor/editor-header-actions.tsx +++ b/apps/admin/src/editor/editor-header-actions.tsx @@ -113,6 +113,14 @@ export function EditorHeaderActions({ } const preview: HeaderPreviewProps = { + subjectEditor: { + value: session.settings.email_subject, + fallback: session.title, + hasUnsavedChanges: session.isDirty(), + isSaving, + onChange: (value) => session.stageSettings({ email_subject: value }), + onSave: saveBeforePreview, + }, isPost: postType === 'post', newsletterSlug: post.newsletter ?? undefined, open: previewOpen, diff --git a/apps/admin/src/editor/editor-header.acceptance.test.tsx b/apps/admin/src/editor/editor-header.acceptance.test.tsx index 4612a923f9a..50133312f56 100644 --- a/apps/admin/src/editor/editor-header.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-header.acceptance.test.tsx @@ -378,6 +378,154 @@ describe('Editor header actions', () => { await expect(previewScreen.modal()).toHaveCount(0); }); + it.each(['Enter', 'Tab'])( + 'saves the email subject from preview on %s and keeps it when reopened', + async (key) => { + publishChrome({ newsletters: 1 }); + const saveApi = fakeSavablePost({ email_subject: null }); + fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, { + email_previews: [ + { subject: 'Hello from React', html: '

Email body

', plaintext: 'Email body' }, + ], + }); + await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON); + await editorScreen.previewButton().click(); + await previewScreen.emailTab().click(); + await expect.element(previewScreen.emailSubject()).toHaveValue('Hello from React'); + + await previewScreen.emailSubject().fill('A custom email subject'); + await expect.element(previewScreen.testEmailButton()).toBeDisabled(); + await userEvent.keyboard(`{${key}}`); + await expect.poll(() => submittedPost(saveApi)?.email_subject).toBe('A custom email subject'); + await expect.element(previewScreen.testEmailButton()).toBeEnabled(); + await previewScreen.closeButton().click(); + await editorScreen.previewButton().click(); + await previewScreen.emailTab().click(); + await expect.element(previewScreen.emailSubject()).toHaveValue('A custom email subject'); + + await previewScreen.emailSubject().fill(''); + await userEvent.keyboard('{Tab}'); + await expect.poll(() => saveApi.requests.length).toBe(2); + expect(submittedPost(saveApi, 1)).toMatchObject({ email_subject: '' }); + await expect + .element(previewScreen.emailSubject()) + .toHaveAttribute('placeholder', 'Hello from React'); + }, + ); + + it('keeps an invalid email subject editable without saving or enabling test sends', async () => { + publishChrome({ newsletters: 1 }); + const saveApi = fakeSavablePost(); + fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, { + email_previews: [ + { subject: 'Hello from React', html: '

Email body

', plaintext: 'Email body' }, + ], + }); + await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON); + await editorScreen.previewButton().click(); + await previewScreen.emailTab().click(); + await previewScreen.emailSubject().fill('a'.repeat(301)); + await userEvent.keyboard('{Enter}'); + await expect.element(previewScreen.emailSubject()).toHaveAttribute('aria-invalid', 'true'); + await expect + .element(page.getByRole('alert')) + .toHaveTextContent('Email subject cannot be longer than 300 characters.'); + await expect.element(previewScreen.testEmailButton()).toBeDisabled(); + expect(saveApi.requests).toHaveLength(0); + + await previewScreen.emailSubject().fill('a'.repeat(300)); + await userEvent.keyboard('{Enter}'); + await expect.poll(() => submittedPost(saveApi)?.email_subject).toBe('a'.repeat(300)); + await expect.element(previewScreen.testEmailButton()).toBeEnabled(); + }); + + it.each(['Close', 'Escape'])( + 'recovers an invalid subject after leaving preview with %s', + async (dismiss) => { + publishChrome({ newsletters: 1 }); + const saveApi = fakeSavablePost(); + fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, { + email_previews: [ + { subject: 'Hello from React', html: '

Email body

', plaintext: 'Email body' }, + ], + }); + await renderAdminApp(`/editor/post/${POST_ID}`, withoutAutosave(MAILGUN_ON)); + await editorScreen.previewButton().click(); + await previewScreen.emailTab().click(); + await previewScreen.emailSubject().fill('a'.repeat(301)); + if (dismiss === 'Close') { + await previewScreen.closeButton().click(); + } else { + await userEvent.keyboard('{Escape}'); + } + await expect(previewScreen.modal()).toHaveCount(0); + await editorScreen.titleInput().fill('Keep this title edit'); + await editorScreen.previewButton().click(); + await expect.element(previewScreen.saveFailed()).toBeVisible(); + await expect(previewScreen.browserFrame()).toHaveCount(0); + await expect(previewScreen.emailFrame()).toHaveCount(0); + await expect.element(previewScreen.shareButton()).toBeDisabled(); + await expect.element(previewScreen.emailSubject()).toHaveValue('a'.repeat(301)); + await expect.element(previewScreen.emailSubject()).toHaveAttribute('aria-invalid', 'true'); + expect(saveApi.requests).toHaveLength(0); + + await previewScreen.emailSubject().fill('A corrected subject'); + await userEvent.keyboard('{Enter}'); + await expect(saveApi).toHaveSavedFields({ + email_subject: 'A corrected subject', + title: 'Keep this title edit', + }); + await expect(previewScreen.saveFailed()).toHaveCount(0); + await expect.element(previewScreen.emailFrame()).toBeVisible(); + await expect.element(previewScreen.testEmailButton()).toBeEnabled(); + await expect.element(previewScreen.shareButton()).toBeEnabled(); + }, + ); + + it('retains the subject and blocks test sends when saving fails', async () => { + publishChrome({ newsletters: 1 }); + const saveApi = fakeSavablePost({}, { failWith: 422 }); + fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, { + email_previews: [ + { subject: 'Hello from React', html: '

Email body

', plaintext: 'Email body' }, + ], + }); + await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON); + await editorScreen.previewButton().click(); + await previewScreen.emailTab().click(); + await previewScreen.emailSubject().fill('Keep this subject'); + await userEvent.keyboard('{Enter}'); + await expect.poll(() => saveApi.requests.length).toBe(1); + await expect.element(previewScreen.emailSubject()).toHaveAttribute('aria-invalid', 'true'); + await expect.element(previewScreen.emailSubject()).toHaveValue('Keep this subject'); + await expect.element(previewScreen.testEmailButton()).toBeDisabled(); + }); + + it('keeps a newer subject while an earlier subject save is pending', async () => { + publishChrome({ newsletters: 1 }); + const held = deferred(); + const saveApi = fakeSavablePost({}, { holdFirstSave: held.promise }); + fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, { + email_previews: [ + { subject: 'Hello from React', html: '

Email body

', plaintext: 'Email body' }, + ], + }); + await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON); + await editorScreen.previewButton().click(); + await previewScreen.emailTab().click(); + await previewScreen.emailSubject().fill('First subject'); + await userEvent.keyboard('{Enter}'); + await expect.poll(() => saveApi.requests.length).toBe(1); + await previewScreen.emailSubject().fill('Newer subject'); + await userEvent.keyboard('{Enter}'); + await expect.element(previewScreen.testEmailButton()).toBeDisabled(); + held.resolve(); + await expect.poll(() => saveApi.requests.length).toBe(2); + expect(submittedPost(saveApi, 1)).toMatchObject({ email_subject: 'Newer subject' }); + await expect.element(previewScreen.emailSubject()).toHaveValue('Newer subject'); + await expect.element(previewScreen.testEmailButton()).toBeEnabled(); + }); + it('keeps the failure in the publish flow and sends nothing more', async () => { publishChrome(); const saveApi = fakeSavablePost({}, { failWith: 422 }); diff --git a/apps/admin/src/editor/engine/change-tracker.test.ts b/apps/admin/src/editor/engine/change-tracker.test.ts index 819d17c48b6..f8bd5a0c77c 100644 --- a/apps/admin/src/editor/engine/change-tracker.test.ts +++ b/apps/admin/src/editor/engine/change-tracker.test.ts @@ -74,6 +74,7 @@ function post(overrides: Partial = {}): EditablePostProj visibility: 'public', tiers: [], authors: [{ id: 'author-1' }], + email_subject: null, meta_title: null, meta_description: null, canonical_url: null, diff --git a/apps/admin/src/editor/engine/change-tracker.ts b/apps/admin/src/editor/engine/change-tracker.ts index 5cf49cc53d9..556c0af53dc 100644 --- a/apps/admin/src/editor/engine/change-tracker.ts +++ b/apps/admin/src/editor/engine/change-tracker.ts @@ -39,6 +39,7 @@ export interface EditablePostProjection { lexical: string | null; tags: ReadonlyArray; custom_excerpt: string | null; + email_subject: string | null; feature_image: string | null; feature_image_alt: string | null; feature_image_caption: string | null; @@ -117,6 +118,7 @@ const PROJECTION_KEYS: ReadonlyArray = [ 'lexical', 'tags', 'custom_excerpt', + 'email_subject', 'feature_image', 'feature_image_alt', 'feature_image_caption', diff --git a/apps/admin/src/editor/fullscreen-dialog.tsx b/apps/admin/src/editor/fullscreen-dialog.tsx index d2da9d8890c..0bc0fd50116 100644 --- a/apps/admin/src/editor/fullscreen-dialog.tsx +++ b/apps/admin/src/editor/fullscreen-dialog.tsx @@ -1,4 +1,5 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@tryghost/shade/components'; +import { Box } from '@tryghost/shade/primitives'; import { cn } from '@tryghost/shade/utils'; import { useLayoutEffect, useRef, type ComponentPropsWithoutRef, type ReactNode } from 'react'; @@ -24,6 +25,8 @@ export interface FullscreenDialogProps extends Omit layout?: keyof typeof LAYOUTS; /** Controls rendered in the header row beside the title. */ headerActions?: ReactNode; + /** View controls centered between the title and actions on wide screens. */ + headerControls?: ReactNode; } /** @@ -37,6 +40,7 @@ export function FullscreenDialog({ title, layout = 'plain', headerActions, + headerControls, className, children, onCloseAutoFocus, @@ -79,9 +83,18 @@ export function FullscreenDialog({ {...props} > {layout === 'header' ? ( - - {title} - {headerActions} + + + {title} + + {headerControls && {headerControls}} + {headerActions} ) : ( {title} diff --git a/apps/admin/src/editor/preview/README.md b/apps/admin/src/editor/preview/README.md index bdfe27042be..525c5602642 100644 --- a/apps/admin/src/editor/preview/README.md +++ b/apps/admin/src/editor/preview/README.md @@ -9,11 +9,26 @@ | `previewUrl` | The post's public preview URL; empty until the post has a uuid | | `isPost` | Pages have no email preview | | `newsletterSlug` | The post's own newsletter, preselected in the email preview | +| `subjectEditor` | Live subject, title fallback, save state, and session callbacks for editing the subject | | `onBeforeOpen` | Awaited before the preview renders, so the caller can save the draft it previews | | `onPublish` | Renders a Publish button; supplied for every user who can publish | | `publishDisabled` | Keeps the Publish button rendered but disabled while the caller cannot open its publish flow | -The modal never writes to the post. `onBeforeOpen` exists because a draft must be persisted before the site or the email renderer can see the latest content; what that means — dirty checks, a save in flight — belongs to the caller. +The modal delegates subject edits and saves through the optional `subjectEditor` port to the editor session. `onBeforeOpen` exists because a draft must be persisted before the site or the email renderer can see the latest content; what that means — dirty checks, a save in flight — belongs to the caller. + +## Layout and controls + +View controls are centered in the header when space allows and shift toward the title +as the screen narrows. Device controls hide below 800px. Below 640px, the title and audience selectors +are hidden so format tabs and actions stay on one row. Format and device controls use pill groups; audience +and tier selectors use the ghost header treatment. The icon-only Share menu keeps +copying the audience-specific preview link and opening it in a new tab together, +and stays disabled until the post has been saved successfully. + +Desktop Web previews fill the space below the header without gutters or device +chrome. Desktop Email previews are centered at a maximum width of 720px on a +muted canvas, with the sidebar's corner radius and the mobile frame's shadow. +Mobile previews retain their phone frame on the same muted canvas in either format. ## Audience @@ -38,6 +53,17 @@ The newsletters offered are the site's active ones, read from the same full brow Switching newsletters re-renders the preview against that newsletter, and the test send goes to exactly one address — the current user's, unless it is edited — for the audience currently selected. +The sender and subject controls share a label column and a local 28px height; +the subject input keeps its visible outline. The desktop subject field stages edits +in the session and saves on blur or Enter. +An empty subject falls back to the post title. The session enforces the 300-character +limit on every save; validation and save errors appear beside the field. Test sending +stays disabled while edits are unsaved or a save is pending. The mobile frame displays +the live subject as text, matching Ember. Closing preview preserves unsaved subject +edits. If those edits prevent saving when preview reopens, the save-failure screen +keeps the subject field available for correction. Saving the corrected subject retries +preparation before displaying the preview or enabling sharing and test sends. + ## Not here yet -Known gaps, listed so they are not mistaken for decisions: the email subject is read-only (editing it would write to the post), there is no over-100kB "may get clipped" warning, an Escape pressed inside the site preview frame does not close the modal, an already-sent post is re-rendered by the preview endpoint rather than showing its stored email, and the sender address does not apply the managed-email override. +Known gaps, listed so they are not mistaken for decisions: there is no over-100kB "may get clipped" warning, an Escape pressed inside the site preview frame does not close the modal, an already-sent post is re-rendered by the preview endpoint rather than showing its stored email, and the sender address does not apply the managed-email override. diff --git a/apps/admin/src/editor/preview/browser-preview.tsx b/apps/admin/src/editor/preview/browser-preview.tsx index 67dbaec0e55..5d1e686b024 100644 --- a/apps/admin/src/editor/preview/browser-preview.tsx +++ b/apps/admin/src/editor/preview/browser-preview.tsx @@ -1,4 +1,5 @@ import { EmptyIndicator, PreviewChrome } from '@tryghost/shade/components'; +import { Box } from '@tryghost/shade/primitives'; import { LucideIcon } from '@tryghost/shade/utils'; import { postPreviewBrowser, @@ -19,7 +20,7 @@ export function BrowserPreview({ previewUrl, audience, device }: BrowserPreviewP if (!previewUrl) { return ( +