diff --git a/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.2.md b/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.2.md deleted file mode 100644 index 527bea329fe..00000000000 --- a/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.2.md +++ /dev/null @@ -1,5 +0,0 @@ -## 2.2.2 - -### Patch Changes - -- Removed html-minifier from runtime dependencies; it is only used by the package's tests diff --git a/.changeset/changelogs/@tryghost!koenig-lexical@1.11.0.md b/.changeset/changelogs/@tryghost!koenig-lexical@1.11.0.md deleted file mode 100644 index 618ee77da2b..00000000000 --- a/.changeset/changelogs/@tryghost!koenig-lexical@1.11.0.md +++ /dev/null @@ -1,9 +0,0 @@ -## 1.11.0 - -### Minor Changes - -- Added an embedPreviewUrl card config option that previews embed cards in a renderer served from a separate origin - -### Patch Changes - -- Updated dependencies diff --git a/.changeset/little-foxes-rule.md b/.changeset/little-foxes-rule.md deleted file mode 100644 index 2f762631c3a..00000000000 --- a/.changeset/little-foxes-rule.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": patch ---- - -Updated dependencies diff --git a/.changeset/proud-tigers-notice.md b/.changeset/proud-tigers-notice.md deleted file mode 100644 index 22385aa089c..00000000000 --- a/.changeset/proud-tigers-notice.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/kg-default-nodes": patch ---- - -Removed html-minifier from runtime dependencies; it is only used by the package's tests diff --git a/.changeset/weak-suns-enjoy.md b/.changeset/weak-suns-enjoy.md deleted file mode 100644 index 70ed7ecceaa..00000000000 --- a/.changeset/weak-suns-enjoy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": minor ---- - -Added an embedPreviewUrl card config option that previews embed cards in a renderer served from a separate origin diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecd4188ff2d..ca8a918fa0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1494,6 +1494,9 @@ jobs: - name: Docker meta (core) id: meta-core uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 + env: + # Tag PR builds `sha-` with the PR head, not the synthetic merge commit + DOCKER_METADATA_PR_HEAD_SHA: 'true' with: images: ${{ steps.strategy.outputs.image-core-name }} tags: | @@ -1512,6 +1515,8 @@ jobs: - name: Docker meta (full) id: meta-full uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 + env: + DOCKER_METADATA_PR_HEAD_SHA: 'true' with: images: ${{ steps.strategy.outputs.image-full-name }} tags: | @@ -1762,6 +1767,8 @@ jobs: continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} id: meta-e2e uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 + env: + DOCKER_METADATA_PR_HEAD_SHA: 'true' with: images: ${{ steps.strategy.outputs.image-e2e-name }} tags: | diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 8a1fc7f258c..ad65e1168e7 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -7,10 +7,21 @@ on: jobs: deploy: name: Deploy Preview - # Runs when the "preview" label is added — requires collaborator write access + # Runs when "preview" or a "preview:" label is added — either needs + # collaborator write access. Adding a profile label to an already-labelled PR + # has to redeploy, or the label would claim a profile the preview is not on; + # Ghost-Moya notices the change and reseeds. + # + # Removing a profile label redeploys for the same reason, in reverse: the + # labels would otherwise say "default" while the preview stayed seeded on + # whatever the removed label named. `startsWith` excludes the bare `preview` + # label, which has no colon, so removing that still only destroys. if: >- - github.event.action == 'labeled' - && github.event.label.name == 'preview' + (github.event.action == 'labeled' + && (github.event.label.name == 'preview' + || startsWith(github.event.label.name, 'preview:'))) + || (github.event.action == 'unlabeled' + && startsWith(github.event.label.name, 'preview:')) runs-on: ubuntu-latest permissions: contents: read @@ -92,23 +103,77 @@ jobs: if [ "$STATE" != "open" ]; then echo "::warning::PR is no longer open ($STATE), skipping dispatch" echo "skip=true" >> "$GITHUB_OUTPUT" - elif [ "$HAS_LABEL" != "true" ]; then - echo "::warning::preview label was removed, skipping dispatch" + exit 0 + fi + # `preview` is the switch; `preview:` only says which data to + # use. A profile label on its own is not a request for a preview, and + # saying so beats the older "label was removed" warning, which is + # actively misleading when it was never added. + if [ "$HAS_LABEL" != "true" ]; then + if echo "$PR" | jq -e '.labels | any(startswith("preview:"))' > /dev/null; then + echo "::warning::A preview: label needs the 'preview' label too, skipping dispatch" + else + echo "::warning::preview label was removed, skipping dispatch" + fi + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # A `preview:` label picks how much seed data the preview starts + # with. The names are defined in TryGhost/Ghost-Moya's + # preview/profiles.json and validated there, so nothing here needs to + # know them — passing the suffix through keeps one source of truth. + # Sending nothing means Moya applies its own default. + # Counted with jq rather than by word-splitting: a label may contain a + # space, and counting words would read one such label as two profiles + # and reject it with a message about a conflict that does not exist. + COUNT=$(echo "$PR" | jq '[.labels[] | select(startswith("preview:"))] | length') + PROFILE=$(echo "$PR" | jq -r '[.labels[] | select(startswith("preview:")) | ltrimstr("preview:")] | first // ""') + + if [ "$COUNT" -gt 1 ]; then + NAMES=$(echo "$PR" | jq -r '[.labels[] | select(startswith("preview:"))] | join(", ")') + echo "::error::Multiple preview profile labels ($NAMES) — remove all but one" echo "skip=true" >> "$GITHUB_OUTPUT" + exit 1 + fi + + if [ "$COUNT" -eq 1 ]; then + # Shape only, not a list of names — the names stay Ghost-Moya's to + # own. This rejects a malformed label here, with a message that says + # what is wrong, rather than dispatching it to fail further away. + # + # Matched with [[ =~ ]] rather than grep. grep works a line at a + # time, so ^ and $ bound a line, not the string, and `grep -q` + # succeeds if any one line matches: a label containing a newline + # would pass on its first line and then write a second line into + # GITHUB_OUTPUT, which becomes a step output of its own. This + # workflow is pull_request_target and carries a dispatch token, so + # that is worth closing rather than noting. bash anchors to the + # whole string, so a newline cannot match at all. + if ! [[ "$PROFILE" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "::error::Malformed profile label 'preview:${PROFILE}' — expected lowercase words separated by hyphens" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 1 + fi + echo "Preview profile: ${PROFILE}" + echo "profile=${PROFILE}" >> "$GITHUB_OUTPUT" else - echo "PR still eligible for preview deploy" - echo "skip=false" >> "$GITHUB_OUTPUT" + echo "No preview: label, Ghost-Moya will use its default" + echo "profile=" >> "$GITHUB_OUTPUT" fi + echo "PR still eligible for preview deploy" + echo "skip=false" >> "$GITHUB_OUTPUT" + - name: Resolve image digest from GHCR id: digest if: steps.recheck.outputs.skip != 'true' env: IMAGE_REPO: tryghost/ghost - PR_NUMBER: ${{ github.event.pull_request.number }} run: | - # Pin the deploy to an immutable digest so Artifact Registry's pull-through - # cache cannot serve a stale manifest for the mutable pr-N tag. + # Resolve the per-commit tag (not mutable pr-N) so a push during the wait + # cannot swap the image, then pin the digest past Artifact Registry's cache. + IMAGE_TAG="sha-${HEAD_SHA:0:7}" TOKEN=$(curl -sf "https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_REPO}:pull" | jq -r '.token') if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then echo "::error::Failed to acquire anonymous GHCR token for ${IMAGE_REPO}" @@ -119,13 +184,13 @@ jobs: -H "Accept: application/vnd.oci.image.index.v1+json" \ -H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" \ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ - "https://ghcr.io/v2/${IMAGE_REPO}/manifests/pr-${PR_NUMBER}" \ + "https://ghcr.io/v2/${IMAGE_REPO}/manifests/${IMAGE_TAG}" \ | awk 'tolower($1) == "docker-content-digest:" {print $2}' | tr -d '\r\n') if [[ ! "$DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]; then - echo "::error::Could not resolve digest for ghcr.io/${IMAGE_REPO}:pr-${PR_NUMBER} (got: '$DIGEST')" + echo "::error::Could not resolve digest for ghcr.io/${IMAGE_REPO}:${IMAGE_TAG} (got: '$DIGEST')" exit 1 fi - echo "Resolved digest: $DIGEST" + echo "Resolved ${IMAGE_TAG} digest: $DIGEST" echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" - name: Dispatch deploy to Ghost-Moya @@ -140,7 +205,7 @@ jobs: "pr_number": "${{ github.event.pull_request.number }}", "image_digest": "${{ steps.digest.outputs.digest }}", "action": "deploy", - "seed": "true" + "profile": "${{ steps.recheck.outputs.profile }}" } destroy: 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 ( +