From 7abbc3b021249c0fb8a758e7855efd2386f45c66 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 14 Oct 2026 01:56:21 +1100 Subject: [PATCH] fix(home): share styles in the browser email preview --- app/components/WeeklyEmailPreview.vue | 5 ++- shared/weekly-preview.ts | 49 +++++++++++++++++++++++++++ test/unit/weekly-template.test.ts | 18 +++++++++- 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 shared/weekly-preview.ts diff --git a/app/components/WeeklyEmailPreview.vue b/app/components/WeeklyEmailPreview.vue index 50d186fa..7252cb30 100644 --- a/app/components/WeeklyEmailPreview.vue +++ b/app/components/WeeklyEmailPreview.vue @@ -5,6 +5,9 @@ */ import type { DigestDemoResponse } from '#layers/identity/server/api/digest/demo.get' +import { compactWeeklyPreview, weeklyPreviewCss } from '#shared/weekly-preview' + +useHead({ style: [{ key: 'weekly-preview', innerHTML: weeklyPreviewCss }] }) // A failed demo costs the section its picture, never its copy or its CTA: // `data` stays null on error and `hasDemo` gates the whole frame. @@ -12,7 +15,7 @@ import type { DigestDemoResponse } from '#layers/identity/server/api/digest/demo // the whole email as `html`, which this preview never renders. const { data: demo } = await useFetch('/api/digest/demo', { key: 'home-digest-demo-v2', - transform: ({ card, rowCount }: DigestDemoResponse) => ({ card, rowCount }), + transform: ({ card, rowCount }: DigestDemoResponse) => ({ card: compactWeeklyPreview(card), rowCount }), }) const hasDemo = computed(() => !!demo.value?.card && (demo.value?.rowCount ?? 0) > 0) diff --git a/shared/weekly-preview.ts b/shared/weekly-preview.ts new file mode 100644 index 00000000..a4b4350b --- /dev/null +++ b/shared/weekly-preview.ts @@ -0,0 +1,49 @@ +/** Browser preview styles. Unknown email styles keep their inline fallback. */ +const styles = [ + 'width:100%;max-width:600px;background:var(--email-surface);border:1px solid var(--email-border);border-radius:8px;', + 'padding:22px 20px 26px;', + 'border-bottom:1px solid var(--email-border);padding-bottom:14px;', + 'font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;font-weight:600;color:var(--email-text);letter-spacing:-0.01em;', + 'display:inline-block;padding:11px 0;color:var(--email-text);text-decoration:none;', + 'display:inline-block;width:22px;height:22px;margin-right:9px;border:0;border-radius:5px;vertical-align:middle;', + 'vertical-align:middle;', + 'color:var(--email-faint);font-weight:400;', + 'font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;color:var(--email-faint);font-variant-numeric:tabular-nums;', + 'padding:22px 0 8px;', + 'margin:0;font-family:\'Plus Jakarta Sans\',-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;font-size:20px;font-weight:600;line-height:1.35;color:var(--email-text);', + 'margin-top:6px;font-family:\'Plus Jakarta Sans\',-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.6;color:var(--email-body);', + 'padding:28px 0 10px;', + 'margin:0;font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:1.5;letter-spacing:0.08em;text-transform:uppercase;color:var(--email-faint);', + 'padding:14px 0;border-top:1px solid var(--email-border);', + 'width:44px;padding-right:12px;', + 'width:36px;height:36px;border-radius:18px;display:block;border:1px solid var(--email-border);background:var(--email-quote);', + 'display:inline-block;padding:0 0 3px;font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:15px;line-height:1.45;font-weight:600;color:var(--email-text);text-decoration:underline;text-decoration-color:var(--email-borderStrong);text-underline-offset:3px;', + 'margin-top:6px;', + 'width:16px;font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:1.55;color:var(--email-faint);', + 'font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:1.55;color:var(--email-body);', + 'margin-top:8px;font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:1.55;color:var(--email-muted);font-variant-numeric:tabular-nums;', + 'margin-top:4px;font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;', + 'display:inline-block;padding:12px 12px 12px 0;color:var(--email-accent);text-decoration:underline;text-underline-offset:3px;', + 'height:3px;background:var(--email-mark);font-size:0;line-height:0;', + 'padding:16px 20px 20px;border-top:1px solid var(--email-border);font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:1.7;color:var(--email-faint);', + 'display:inline-block;padding:12px 0;color:var(--email-muted);text-decoration:underline;text-underline-offset:3px;', +] as const + +const classes = new Map(styles.map((style, index) => [style, `wp${index}`])) + +export const weeklyPreviewCss = styles.map((style, index) => `.weekly-preview .wp${index}{${style}}`).join('') + +/** Share repeated browser styles while preserving every email element and attribute. */ +export function compactWeeklyPreview(card: string): string { + return card.replace(/<([a-z][\w-]*)\b([^<>]*?)\sstyle="([^"]*)"([^<>]*)>/gi, (tag, name: string, before: string, style: string, after: string) => { + const className = classes.get(style) + if (!className) + return tag + const selfClosing = /\/\s*$/.test(after) + const attributes = `${before}${after.replace(/\/\s*$/, '')}` + const styled = /\bclass="[^"]*"/.test(attributes) + ? attributes.replace(/\bclass="([^"]*)"/, (_match, value: string) => `class="${value} ${className}"`) + : `${attributes} class="${className}"` + return `<${name}${styled}${selfClosing ? ' /' : ''}>` + }) +} diff --git a/test/unit/weekly-template.test.ts b/test/unit/weekly-template.test.ts index 88441176..60fffcbd 100644 --- a/test/unit/weekly-template.test.ts +++ b/test/unit/weekly-template.test.ts @@ -3,6 +3,7 @@ import type { WeeklyRenderInput, WeeklyTrendingSkill } from '../../layers/identi import { describe, expect, it } from 'vitest' import { formatWindow, renderWeekly, renderWeeklyPreview } from '../../layers/identity/server/utils/weekly-template' import { SPARK_BARS } from '../../shared/braille-spark' +import { compactWeeklyPreview } from '../../shared/weekly-preview' const WINDOW_END = 1_755_648_000 const WINDOW_START = WINDOW_END - 7 * 86_400 @@ -40,19 +41,34 @@ function trending(overrides: Partial = {}): WeeklyTrendingS } describe('weekly template', () => { + it('keeps a multi-row browser preview smaller than the delivered email card', () => { + const source = input({ likedChanges: [likedChange(), likedChange(), likedChange()] }) + const email = renderWeekly(source).card + const preview = compactWeeklyPreview(renderWeeklyPreview(source)) + expect(Buffer.byteLength(JSON.stringify(preview))).toBeLessThan(Buffer.byteLength(JSON.stringify(email)) * 0.65) + }) it('keeps preview content and links while email retains inline colours', () => { const source = input({ trending: [trending()] }) const email = renderWeekly(source).card - const preview = renderWeeklyPreview(source) + const preview = compactWeeklyPreview(renderWeeklyPreview(source)) const emailElement = document.createElement('div') const previewElement = document.createElement('div') emailElement.innerHTML = email previewElement.innerHTML = preview expect(previewElement.textContent?.replace(/\s+/g, '')).toBe(emailElement.textContent?.replace(/\s+/g, '')) expect([...previewElement.querySelectorAll('a')].map(a => a.href)).toEqual([...emailElement.querySelectorAll('a')].map(a => a.href)) + expect([...previewElement.querySelectorAll('img')].map(image => [image.src, image.width, image.height, image.alt])).toEqual([...emailElement.querySelectorAll('img')].map(image => [image.src, image.width, image.height, image.alt])) expect(preview).toContain('var(--email-') expect(email).not.toContain('var(--email-') }) + it('keeps new preview styles as inline fallbacks', () => { + const card = '
Future layout
' + const element = document.createElement('div') + element.innerHTML = compactWeeklyPreview(card) + expect(element.querySelector('td')?.style.color).toBe('rebeccapurple') + expect(element.querySelector('td')?.style.padding).toBe('19px') + expect(element.textContent).toBe('Future layout') + }) it('states the star count once for a star-only row', () => { const { html, text } = renderWeekly(input({ trending: [trending({ reason: { _tag: 'popular', stars: 46_712 }, stars: 46_712 })],