diff --git a/backend/e2e/newsletter-preview-overflow.spec.js b/backend/e2e/newsletter-preview-overflow.spec.js new file mode 100644 index 00000000..8a25f431 --- /dev/null +++ b/backend/e2e/newsletter-preview-overflow.spec.js @@ -0,0 +1,124 @@ +const { test, expect } = require('@playwright/test'); +const { createDocsCacheRoot } = require('./helpers/docs-content-root'); +const { startOwnedTestServer, stopOwnedTestServer } = require('./helpers/isolated-capability-server'); + +// t2320u: long URLs and long inline code on the newsletter issue preview must +// wrap inside the card on every tab, and the shareable (code) preview shows +// only the reader-facing issue. + +const LONG_URL = `https://courses.datatalks.club/register/production-ai-agents-generative-media/${'a'.repeat(140)}?utm_source=newsletter&utm_medium=email&utm_campaign=${'b'.repeat(80)}`; +const LONG_CODE = `PYTHONPATH=/home/orin/git/dataops-knowledge/tools/${'c'.repeat(120)}/read_course_inventory.py`; +const SCREENSHOTS = process.env.NEWSLETTER_PREVIEW_SCREENSHOT_DIR || ''; + +let server; +test.use({ screenshot: 'off', trace: 'off', video: 'off' }); + +test.beforeAll(async () => { + server = await startOwnedTestServer({ + environment: { DTC_CACHE_ROOT: createDocsCacheRoot('newsletter-preview-overflow') }, + }); +}); + +test.afterAll(async () => { + await stopOwnedTestServer(server); +}); + +async function expectNoHorizontalOverflow(page, label) { + const metrics = await page.evaluate(() => { + const doc = document.documentElement; + const viewport = doc.clientWidth; + const offenders = []; + for (const node of document.querySelectorAll('#newsletter-preview-root *')) { + const rect = node.getBoundingClientRect(); + if (rect.width === 0) continue; + // Content inside a horizontally scrollable box is allowed to extend past it. + let clipped = false; + for (let p = node.parentElement; p && p !== document.body; p = p.parentElement) { + const ox = getComputedStyle(p).overflowX; + if (ox === 'auto' || ox === 'scroll' || ox === 'hidden') { clipped = true; break; } + } + if (!clipped && rect.right > viewport + 1) { + offenders.push(`${node.tagName.toLowerCase()}.${node.className} right=${Math.round(rect.right)}`); + } + } + const cards = [...document.querySelectorAll('#newsletter-preview-root > .sponsor-doc-card')]; + const cardOverflow = cards + .filter((card) => card.scrollWidth > card.clientWidth + 1) + .map((card) => `${card.dataset.previewSection || card.dataset.previewView || 'card'} ${card.scrollWidth}>${card.clientWidth}`); + return { scrollWidth: doc.scrollWidth, viewport, offenders: offenders.slice(0, 5), cardOverflow }; + }); + expect(metrics.scrollWidth, `${label}: page scrollWidth ${metrics.scrollWidth} > viewport ${metrics.viewport}`).toBeLessThanOrEqual(metrics.viewport); + expect(metrics.offenders, `${label}: elements past the right edge`).toEqual([]); + expect(metrics.cardOverflow, `${label}: card content wider than card`).toEqual([]); +} + +test('newsletter preview wraps long URLs and inline code on every tab; shareable preview shows only the issue', async ({ page, request }) => { + test.setTimeout(90_000); + + const issueRes = await request.post(`${server.baseURL}/api/newsletter-issues`, { + data: { kind: 'weekly', publicationDate: '2026-11-16', status: 'planned' }, + }); + expect(issueRes.ok(), await issueRes.text()).toBeTruthy(); + const issue = await issueRes.json(); + + const longLine = `Authenticated course inventory ran: \`${LONG_CODE}\` read 24 courses, see ${LONG_URL} and [the register page](${LONG_URL}).`; + const issueMd = [ + 'Subject: Weekly overflow regression', + '', + 'Preview: Long lines must wrap.', + '', + '# Weekly overflow regression', + '', + longLine, + '', + `- ${longLine}`, + '', + ].join('\n'); + const briefMd = `# Brief\n\n- ${longLine}\n`; + const sourcesMd = `# Sources for the weekly\n\n- INTERNAL-SOURCES-MARKER ${longLine}\n`; + + const putRes = await request.put(`${server.baseURL}/api/newsletter-issues/${issue.id}/markdown`, { + data: { issue: issueMd, brief: briefMd, sources: sourcesMd }, + }); + expect(putRes.ok(), await putRes.text()).toBeTruthy(); + const saved = await putRes.json(); + expect(saved.accessCode).toBeTruthy(); + + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 900 }); + + // Internal (staff) page: all four tabs, each contained. + await page.setExtraHTTPHeaders({}); + await page.goto(`${server.baseURL}/newsletter-issues/${issue.id}/preview`); + await expect(page.locator('[data-preview-staff-bar]')).toBeVisible(); + await expect(page.locator('[data-preview-tab]')).toHaveCount(4); + for (const tab of ['issue', 'brief', 'sources', 'raw']) { + await page.locator(`[data-preview-tab="${tab}"]`).click(); + if (tab === 'raw') { + await expect(page.locator('pre.sponsor-doc-export-pre')).toHaveCount(3); + } else { + await expect(page.locator(`[data-preview-canvas="${tab}"] code`).first()).toBeVisible(); + } + await expectNoHorizontalOverflow(page, `staff ${tab} @${width}`); + if (SCREENSHOTS) await page.screenshot({ path: `${SCREENSHOTS}/staff-${tab}-${width}.png`, fullPage: true }); + } + + // Shareable link: anonymous viewer with the access code sees only the issue. + const external = await page.context().browser().newContext({ + viewport: { width, height: 900 }, + extraHTTPHeaders: { 'x-newsletter-preview-anonymous': 'true' }, + }); + const extPage = await external.newPage(); + await extPage.goto(`${server.baseURL}/newsletter-issues/${issue.id}/preview?code=${encodeURIComponent(saved.accessCode)}`); + await expect(extPage.locator('[data-preview-canvas="issue"]')).toBeVisible(); + await expect(extPage.locator('[data-preview-canvas="issue"] code').first()).toBeVisible(); + await expect(extPage.locator('[data-preview-staff-bar]')).toHaveCount(0); + await expect(extPage.locator('[data-preview-tab]')).toHaveCount(0); + await expect(extPage.locator('body')).not.toContainText('INTERNAL-SOURCES-MARKER'); + await expect(extPage.locator('body')).not.toContainText('Sources (sources.md)'); + await expect(extPage.locator('body')).not.toContainText('Raw Markdown'); + await expectNoHorizontalOverflow(extPage, `shareable @${width}`); + if (SCREENSHOTS) await extPage.screenshot({ path: `${SCREENSHOTS}/shareable-${width}.png`, fullPage: true }); + await external.close(); + } +}); diff --git a/backend/src/newsletter/markdownPreview.ts b/backend/src/newsletter/markdownPreview.ts index 123395df..737cee93 100644 --- a/backend/src/newsletter/markdownPreview.ts +++ b/backend/src/newsletter/markdownPreview.ts @@ -467,8 +467,12 @@ function buildPreviewResponsePayload( }; } + // The shareable (code-gated) preview is reader-facing only: brief.md and + // sources.md are internal working notes and never leave the staff view. return { ...basePayload, + markdown: { ...basePayload.markdown, sources: '', brief: '' }, + rendered: { ...basePayload.rendered, sourcesHtml: '', briefHtml: '' }, viewerRole: 'external' as const, }; } diff --git a/backend/tests/newsletter-markdown.test.ts b/backend/tests/newsletter-markdown.test.ts index e1863c74..865d90de 100644 --- a/backend/tests/newsletter-markdown.test.ts +++ b/backend/tests/newsletter-markdown.test.ts @@ -232,6 +232,12 @@ describe('Newsletter Markdown Storage & HTML Preview (/api/newsletter-issues/:id assert.strictEqual(extPayload.accessCode, undefined); assert.strictEqual(extPayload.shareUrl, undefined); assert.strictEqual(extPayload.rendered.subject, 'Next Query 2026: How data teams build for analytics and AI'); + // Shareable preview is reader-facing only: no internal brief/sources. + assert.strictEqual(extPayload.markdown.issue, issueMd); + assert.strictEqual(extPayload.markdown.sources, ''); + assert.strictEqual(extPayload.markdown.brief, ''); + assert.strictEqual(extPayload.rendered.sourcesHtml, ''); + assert.strictEqual(extPayload.rendered.briefHtml, ''); // Rotate code -> old code fails, new code works const rotateRes = await staffReq('POST', `/api/newsletter-issues/${issue.id}/markdown/rotate-code`, {}); diff --git a/frontend/src/styles/newsletter-preview.css b/frontend/src/styles/newsletter-preview.css index 95b6381f..d136ba33 100644 --- a/frontend/src/styles/newsletter-preview.css +++ b/frontend/src/styles/newsletter-preview.css @@ -138,3 +138,80 @@ border-bottom: 1px solid var(--dk-border-default); padding-bottom: var(--dk-space-3); } + +/* Long-line containment (t2320u): every grid level is pinned to the card width + so an unbreakable URL or inline code span can never stretch a track past the + card. Prose and inline code wrap; code blocks scroll inside the card. */ +.newsletter-preview-shell, +.newsletter-preview-shell > .sponsor-doc-card, +.newsletter-email-canvas, +.newsletter-envelope { + grid-template-columns: minmax(0, 1fr); + min-width: 0; + max-width: 100%; +} + +.newsletter-preview-shell > .sponsor-doc-card > *, +.newsletter-email-canvas > *, +.newsletter-envelope > * { + min-width: 0; +} + +.newsletter-email-canvas, +.newsletter-envelope, +.newsletter-preview-shell .sponsor-doc-field, +.newsletter-preview-shell .sponsor-doc-title-row, +.newsletter-preview-shell .sponsor-doc-meta { + overflow-wrap: anywhere; + word-break: break-word; +} + +.newsletter-email-canvas a, +.newsletter-email-canvas code, +.newsletter-envelope a, +.newsletter-envelope code { + overflow-wrap: anywhere; + word-break: break-word; +} + +.newsletter-email-canvas :not(pre) > code { + white-space: normal; +} + +.newsletter-email-canvas pre { + margin: 0; + max-width: 100%; + overflow-x: auto; + white-space: pre; + padding: var(--dk-space-3); + background-color: var(--dk-bg-muted); + border: 1px solid var(--dk-border-default); + border-radius: var(--dk-radius-md); +} + +.newsletter-email-canvas pre code { + white-space: pre; + overflow-wrap: normal; + word-break: normal; +} + +.newsletter-email-canvas img, +.newsletter-email-canvas table { + max-width: 100%; +} + +.newsletter-email-canvas table { + display: block; + overflow-x: auto; +} + +.newsletter-preview-shell .sponsor-doc-export-pre { + max-width: 100%; + overflow-x: auto; +} + +@media (max-width: 480px) { + .newsletter-email-canvas { + padding: var(--dk-space-4) var(--dk-space-3); + } +} diff --git a/frontend/src/surfaces/operations/newsletter-preview.js b/frontend/src/surfaces/operations/newsletter-preview.js index 43296db3..29a0de05 100644 --- a/frontend/src/surfaces/operations/newsletter-preview.js +++ b/frontend/src/surfaces/operations/newsletter-preview.js @@ -501,13 +501,19 @@ export function createNewsletterPreviewPage({ const previewCard = el("div", "sponsor-doc-card"); previewCard.dataset.previewSection = "rendered"; + // Brief, Sources and Raw are internal working material: only staff get the + // tab bar. The shareable (code-gated) preview shows the reader-facing issue. + const isStaff = previewData.viewerRole === "staff"; + if (!isStaff) activeTab = "issue"; const tabsBar = el("div", "newsletter-preview-tabs"); - const tabDefs = [ - { id: "issue", label: "Email Preview (issue.md)" }, - { id: "brief", label: "Brief (brief.md)" }, - { id: "sources", label: "Sources (sources.md)" }, - { id: "raw", label: "Raw Markdown" }, - ]; + const tabDefs = isStaff + ? [ + { id: "issue", label: "Email Preview (issue.md)" }, + { id: "brief", label: "Brief (brief.md)" }, + { id: "sources", label: "Sources (sources.md)" }, + { id: "raw", label: "Raw Markdown" }, + ] + : []; for (const tabDef of tabDefs) { const btn = el( "button", @@ -524,7 +530,7 @@ export function createNewsletterPreviewPage({ }); tabsBar.append(btn); } - previewCard.append(tabsBar); + if (isStaff) previewCard.append(tabsBar); const rendered = previewData.rendered || {}; const md = previewData.markdown || {}; diff --git a/frontend/test/newsletter-preview.test.mjs b/frontend/test/newsletter-preview.test.mjs index dfa46239..9a53ff06 100644 --- a/frontend/test/newsletter-preview.test.mjs +++ b/frontend/test/newsletter-preview.test.mjs @@ -145,7 +145,7 @@ function harness(options = {}) { return { root, identity, live, calls, history, copied, store, controller }; } -test("newsletter preview page unlocks with ?code= query parameter, strips code from URL, and renders HTML preview tabs", async () => { +test("newsletter preview page unlocks with ?code= query parameter, strips code from URL, and renders only the reader-facing issue", async () => { const { root, identity, history, copied, controller } = harness(); await controller.start(); await nextTicks(); @@ -168,18 +168,12 @@ test("newsletter preview page unlocks with ?code= query parameter, strips code f await nextTicks(); assert.equal(copied.at(-1), "Free summit"); - // Switch to brief tab, sources tab, and raw tab - root.querySelector('[data-preview-tab="brief"]').dispatch("click"); - assert.ok(root.querySelector('[data-preview-canvas="brief"]')); - - root.querySelector('[data-preview-tab="sources"]').dispatch("click"); - assert.ok(root.querySelector('[data-preview-canvas="sources"]')); - - root.querySelector('[data-preview-tab="raw"]').dispatch("click"); - const copyRawIssue = root.querySelector('[data-preview-action="copy-raw-issue"]'); - copyRawIssue.dispatch("click"); - await nextTicks(); - assert.match(copied.at(-1), /Subject: Next Query 2026/); + // Shareable (code) preview is reader-facing only: no Brief/Sources/Raw tabs. + for (const tab of ["issue", "brief", "sources", "raw"]) { + assert.equal(root.querySelector(`[data-preview-tab="${tab}"]`), null); + } + assert.equal(root.querySelector('[data-preview-canvas="sources"]'), null); + assert.equal(root.querySelector('[data-preview-action="copy-raw-issue"]'), null); // Lock returns to gate const lockBtn = identity.querySelector('[data-preview-action="lock"]'); @@ -222,6 +216,19 @@ test("staff viewer sees shareable preview link, rotates code, and edits Markdown assert.ok(root.querySelector("[data-preview-staff-bar]")); + // Switch to brief tab, sources tab, and raw tab + root.querySelector('[data-preview-tab="brief"]').dispatch("click"); + assert.ok(root.querySelector('[data-preview-canvas="brief"]')); + + root.querySelector('[data-preview-tab="sources"]').dispatch("click"); + assert.ok(root.querySelector('[data-preview-canvas="sources"]')); + + root.querySelector('[data-preview-tab="raw"]').dispatch("click"); + const copyRawIssue = root.querySelector('[data-preview-action="copy-raw-issue"]'); + copyRawIssue.dispatch("click"); + await nextTicks(); + assert.match(copied.at(-1), /Subject: Next Query 2026/); + const copyShareBtn = root.querySelector('[data-preview-action="copy-share-url"]'); copyShareBtn.dispatch("click"); await nextTicks();