Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
41d86af
test(editor): add day-0 unit-test infrastructure
claude May 10, 2026
33b5b39
test(editor): add paste-sanitizer spec; reject unsafe URL schemes
claude May 10, 2026
d7df09f
fix(editor): treat empty headings as visually-empty for placeholder
claude May 10, 2026
1cd63a6
fix(editor): always sanitize text/html paste; remove childCount short…
claude May 10, 2026
ff56263
fix(editor): sanitize text/html drops; wire drop handler in EmailEditor
claude May 10, 2026
08b3527
fix(editor): harden image upload flow against destroy + dup-src races
claude May 10, 2026
20f9bcc
test(editor): add serializer round-trip corpus + fast-check property
claude May 10, 2026
f09a759
test(editor): add typed contract test for image-upload-error event
claude May 10, 2026
dc00ce0
test(editor): add EmailEditor controller spec
claude May 10, 2026
79b179a
test(editor): add inspector section specs (border/padding/size/typogr…
claude May 10, 2026
0321127
test(editor): cover the remaining 4 inspector sections
claude May 10, 2026
d1a8054
test(editor): add inspector breadcrumb spec
claude May 10, 2026
3fb852c
test(editor): cover slash command commands.tsx and utils.ts
claude May 10, 2026
db6e7a6
test(editor): snapshot 10 untested extensions through their React Ema…
claude May 10, 2026
263f498
test(editor): cover h-padding legacy branch and mergeCssJs
claude May 10, 2026
367a73e
test(editor): cover image file-handler ProseMirror plugin
claude May 10, 2026
0bfd284
test(editor): cover extend() identity for EmailMark/EmailNode
claude May 10, 2026
13c2b96
test(editor): enable v8 coverage gate on the seams the plan locked down
claude May 10, 2026
37094f6
chore(editor): drop now-redundant .gitkeep files
claude May 11, 2026
58495cf
chore(editor): strip Linear ticket references from test comments
claude May 11, 2026
09e1568
test(editor): expand fixture corpora with realistic email + paste sam…
claude May 11, 2026
87f3e83
revert(editor): restore original create-drop-handler implementation
claude May 11, 2026
cd1984b
fix(editor): lint cleanup + two review findings
claude May 11, 2026
8665d28
fix
danilowoz May 11, 2026
b9fec52
test(editor): address PR review feedback
claude May 13, 2026
2a95350
chore(editor): sync pnpm-lock.yaml after removing @vitest/coverage-v8
claude May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@
"!**/*.d.ts",
"!**/out",
"!**/.turbo",
"!**/prism.ts"
"!**/prism.ts",
"!packages/editor/src/__tests__/fixtures"
]
}
}
1 change: 1 addition & 0 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
"@types/prismjs": "1.26.6",
"@vitejs/plugin-react": "catalog:",
"@vitest/browser-playwright": "4.1.4",
"fast-check": "3.23.2",
"playwright": "1.59.1",
"postcss": "8.5.10",
"postcss-import": "16.1.1",
Expand Down
56 changes: 56 additions & 0 deletions packages/editor/src/__tests__/arbitraries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { JSONContent } from '@tiptap/core';
import * as fc from 'fast-check';

/**
* Generators for `fast-check` property tests.
*
* Constrained intentionally to keep shrinking fast on ProseMirror trees:
* - shallow nesting (heading/paragraph/list only)
* - small text content
* - schema-valid by construction (so `fc.pre()` filters are rare)
*/

const safeText = fc
.string({ minLength: 1, maxLength: 30 })
.filter((s) => /^[a-zA-Z0-9 .,!?-]+$/.test(s));

const textNode = safeText.map<JSONContent>((text) => ({ type: 'text', text }));

const paragraphNode = fc
.array(textNode, { minLength: 1, maxLength: 3 })
.map<JSONContent>((content) => ({ type: 'paragraph', content }));

const headingNode = fc
.tuple(fc.integer({ min: 1, max: 3 }), safeText)
.map<JSONContent>(([level, text]) => ({
type: 'heading',
attrs: { level },
content: [{ type: 'text', text }],
}));

const listItemNode = paragraphNode.map<JSONContent>((p) => ({
type: 'listItem',
content: [p],
}));

const bulletListNode = fc
.array(listItemNode, { minLength: 1, maxLength: 3 })
.map<JSONContent>((content) => ({ type: 'bulletList', content }));

const blockNode: fc.Arbitrary<JSONContent> = fc.oneof(
paragraphNode,
headingNode,
bulletListNode,
);

/**
* Arbitrary doc with up to `maxBlocks` top-level block nodes.
*/
export function proseMirrorDocArbitrary(
options: { maxBlocks?: number } = {},
): fc.Arbitrary<JSONContent> {
const maxBlocks = options.maxBlocks ?? 5;
return fc
.array(blockNode, { minLength: 1, maxLength: maxBlocks })
.map((content) => ({ type: 'doc', content }));
}
52 changes: 52 additions & 0 deletions packages/editor/src/__tests__/editor-test-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
type AnyExtension,
Editor,
type EditorOptions,
type JSONContent,
} from '@tiptap/core';
import { StarterKit } from '../extensions';

interface CreateTestEditorOptions {
content?: EditorOptions['content'];
extensions?: AnyExtension[];
editorProps?: EditorOptions['editorProps'];
}

/**
* Creates a real tiptap `Editor` instance for unit tests.
* Mirrors the inline `createEditorWithContent` pattern from
* `core/serializer/compose-react-email.spec.tsx` so all specs share the
* same setup. Always remember to `editor.destroy()` in `afterEach`.
*/
export function createTestEditor(
options: CreateTestEditorOptions = {},
): Editor {
const editorOptions: EditorOptions = {
extensions: options.extensions ?? [StarterKit],
} as EditorOptions;
if (options.content !== undefined) {
(editorOptions as { content: EditorOptions['content'] }).content =
options.content;
}
if (options.editorProps !== undefined) {
(
editorOptions as { editorProps: EditorOptions['editorProps'] }
).editorProps = options.editorProps;
}
return new Editor(editorOptions);
}

/**
* Convenience: returns the doc JSON for a single-paragraph string of `text`.
*/
export function paragraphDoc(text: string): JSONContent {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: text ? [{ type: 'text', text }] : undefined,
},
],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<blockquote>To be, or not to be.</blockquote>
<p>Inline <code>code()</code> mid-sentence.</p>
<pre><code>const x = 1;
const y = 2;
</code></pre>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<h1 style="font-size: 22px; color: #1d1c1d; margin: 0 0 8px;">The Weekly Digest</h1>
<p style="font-size: 14px; color: #6b7280; margin: 0 0 32px;">Issue #84 · May 11, 2026</p>

<h3 style="font-size: 18px; color: #1d1c1d; margin: 0 0 8px;">What's new</h3>
<p style="font-size: 16px; line-height: 24px; color: #1d1c1d; margin: 0 0 16px;">We shipped granular API key permissions this week. You can now scope a key to a single domain or audience. <a href="https://acme.example/changelog/api-keys" style="color: #0670DB;">Read the changelog →</a></p>

<h3 style="font-size: 18px; color: #1d1c1d; margin: 24px 0 8px;">From the blog</h3>
<ul style="font-size: 16px; line-height: 24px; color: #1d1c1d; padding-left: 20px;">
<li><a href="https://acme.example/blog/dkim-2048" style="color: #0670DB;">2048-bit DKIM keys are now the default</a></li>
<li><a href="https://acme.example/blog/inbox-placement" style="color: #0670DB;">A field guide to inbox placement in 2026</a></li>
<li><a href="https://acme.example/blog/transactional-vs-marketing" style="color: #0670DB;">Transactional vs marketing: which IP pool fits</a></li>
</ul>

<h3 style="font-size: 18px; color: #1d1c1d; margin: 24px 0 8px;">Customer spotlight</h3>
<blockquote style="border-left: 3px solid #e5e7eb; padding-left: 16px; margin: 0; font-size: 15px; line-height: 22px; color: #4b5563; font-style: italic;">"Switching to Acme cut our delivery latency by 60% on the first day. Setup took less time than reading our last vendor's docs."</blockquote>
<p style="font-size: 14px; color: #6b7280; margin: 8px 0 0;">— Priya N., CTO at Linear-not-actually-Linear</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<h1>Welcome</h1>
<h2>Subheader</h2>
<p>Intro paragraph.</p>
<ul>
<li>One</li>
<li>Two</li>
</ul>
<ol>
<li>First</li>
<li>Second</li>
</ol>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<p style="color: rgb(255, 0, 0); font-weight: 700;">Red bold paragraph.</p>
<h2 style="color: #1f3864;">Heading with color.</h2>
<p>Plain after.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<p>Visit <a href="https://example.com">our site</a> for more.</p>
<p><img src="https://example.com/logo.png" alt="Logo" width="200" height="50"></p>
5 changes: 5 additions & 0 deletions packages/editor/src/__tests__/fixtures/emails/magic-link.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<p style="font-size: 16px; line-height: 24px; color: #1d1c1d;">Hi Sarah,</p>
<p style="font-size: 16px; line-height: 24px; color: #1d1c1d;">Click below to sign in to Acme. The link is good for the next 10 minutes.</p>
<p style="margin: 24px 0;"><a href="https://acme.example/auth/magic?t=longtokenhere" style="display: inline-block; background-color: #1d1c1d; color: #ffffff; padding: 12px 28px; border-radius: 8px; text-decoration: none; font-weight: 600;">Sign in to Acme</a></p>
<p style="font-size: 14px; line-height: 20px; color: #6b7280;">If you didn't try to sign in, you can ignore this email. No action is needed.</p>
<p style="font-size: 14px; line-height: 20px; color: #6b7280;">For security reasons, this link expires after one use.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<p style="text-align: center;"><img src="https://example.com/hero-launch.png" alt="Acme Mobile launch" width="560" height="280" style="max-width: 100%; height: auto; border-radius: 12px;"></p>
<h1 style="font-size: 28px; line-height: 36px; color: #1d1c1d; text-align: center; margin: 32px 0 12px;">Introducing Acme Mobile</h1>
<p style="font-size: 16px; line-height: 24px; color: #4b5563; text-align: center; margin: 0 0 24px;">Everything you love about Acme, now on iPhone and Android. Same workspace, same teammates, same data — wherever you are.</p>
<p style="text-align: center; margin: 0 0 32px;"><a href="https://acme.example/mobile" style="display: inline-block; background-color: #0670DB; color: #ffffff; padding: 14px 32px; border-radius: 6px; text-decoration: none; font-weight: 600;">Download the app</a></p>
<h3 style="font-size: 18px; color: #1d1c1d; margin: 32px 0 12px;">What you get</h3>
<ul style="font-size: 16px; line-height: 24px; color: #1d1c1d; padding-left: 20px;">
<li>Real-time push notifications for replies and mentions</li>
<li>Offline drafting that syncs the moment you reconnect</li>
<li>Biometric sign-in (Face ID / fingerprint)</li>
</ul>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<p>Some <strong>bold</strong>, <em>italic</em>, <u>underline</u>, and <s>strike</s> text.</p>
<p>And <strong><em>both bold and italic</em></strong> together.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<table>
<tr>
<td>Cell A</td>
<td>Cell B</td>
</tr>
<tr>
<td colspan="2">Spans both</td>
</tr>
</table>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<p style="font-size: 16px; line-height: 24px; color: #1d1c1d;">Hi Sarah,</p>
<p style="font-size: 16px; line-height: 24px; color: #1d1c1d;"><strong>James Liu</strong> commented on your pull request <a href="https://acme.example/pr/482" style="color: #0670DB;"><em>feat: rate-limited webhook retries</em></a>:</p>
<blockquote style="border-left: 3px solid #0670DB; padding: 12px 16px; margin: 16px 0; background-color: #f9fafb; font-size: 15px; line-height: 22px; color: #1d1c1d;">Could we use the same backoff schedule as the SMS retry path? Otherwise this is great — ship it.</blockquote>
<p style="margin: 24px 0;"><a href="https://acme.example/pr/482#comment-3219" style="display: inline-block; background-color: #1d1c1d; color: #ffffff; padding: 10px 20px; border-radius: 6px; text-decoration: none; font-weight: 600;">View comment</a></p>
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 40px 0 24px;">
<p style="font-size: 12px; line-height: 18px; color: #6b7280; margin: 0 0 8px;">Acme · 100 Market St, San Francisco, CA 94103</p>
<p style="font-size: 12px; line-height: 18px; color: #6b7280; margin: 0;">You're receiving this because you authored the PR. <a href="https://acme.example/notifications" style="color: #6b7280; text-decoration: underline;">Manage notification preferences</a> · <a href="https://acme.example/unsubscribe" style="color: #6b7280; text-decoration: underline;">Unsubscribe</a></p>
21 changes: 21 additions & 0 deletions packages/editor/src/__tests__/fixtures/emails/order-receipt.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<h2 style="font-size: 20px; color: #1d1c1d; margin: 0 0 8px;">Thanks for your order</h2>
<p style="font-size: 14px; color: #6b7280; margin: 0 0 24px;">Order #AC-10847 · Placed on May 11, 2026</p>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 12px 0; border-bottom: 1px solid #e5e7eb; font-size: 14px;">Espresso Beans, 1lb</td>
<td style="padding: 12px 0; border-bottom: 1px solid #e5e7eb; font-size: 14px; text-align: right;">2 × $18.00</td>
</tr>
<tr>
<td style="padding: 12px 0; border-bottom: 1px solid #e5e7eb; font-size: 14px;">Stainless steel French press</td>
<td style="padding: 12px 0; border-bottom: 1px solid #e5e7eb; font-size: 14px; text-align: right;">$42.00</td>
</tr>
<tr>
<td style="padding: 12px 0; font-size: 14px; color: #6b7280;">Shipping</td>
<td style="padding: 12px 0; font-size: 14px; text-align: right; color: #6b7280;">$5.00</td>
</tr>
<tr>
<td style="padding: 12px 0; font-size: 16px; font-weight: 700;">Total</td>
<td style="padding: 12px 0; font-size: 16px; font-weight: 700; text-align: right;">$83.00</td>
</tr>
</table>
<p style="font-size: 14px; line-height: 20px; color: #6b7280; margin: 24px 0 0;">Tracking will be emailed when your order ships, usually within two business days.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<h2 style="font-size: 20px; color: #1d1c1d; margin: 0 0 16px;">Reset your password</h2>
<p style="font-size: 16px; line-height: 24px; color: #1d1c1d;">We received a request to reset the password for <strong>sarah@example.com</strong>. Click the button below to choose a new one. This link expires in 30 minutes.</p>
<p style="margin: 24px 0;"><a href="https://acme.example/reset?token=xyz" style="display: inline-block; background-color: #0670DB; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600;">Reset password</a></p>
<p style="font-size: 14px; line-height: 20px; color: #6b7280;">Didn't request this? You can ignore this email — your password won't change.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>Hello world.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<h2 style="font-size: 20px; color: #1d1c1d; margin: 0 0 16px;">Sarah invited you to Acme</h2>
<p style="font-size: 16px; line-height: 24px; color: #1d1c1d;"><strong>sarah@example.com</strong> wants you to join the <strong>Engineering</strong> workspace on Acme.</p>
<p style="margin: 24px 0;"><a href="https://acme.example/invite/eng-9f3a" style="display: inline-block; background-color: #0670DB; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600;">Accept invitation</a></p>
<p style="font-size: 14px; line-height: 20px; color: #6b7280;">You'll be able to see Engineering's projects, threads, and shared files. You can leave the workspace at any time from your profile.</p>
<p style="font-size: 14px; line-height: 20px; color: #6b7280;">This invitation expires in 7 days.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<h1 style="font-size: 24px; color: #1d1c1d; margin: 0 0 16px;">Welcome to Acme, Sarah</h1>
<p style="font-size: 16px; line-height: 24px; color: #1d1c1d; margin: 0 0 16px;">Thanks for signing up. Your account is ready to go. Confirm your email to start sending.</p>
<p><a href="https://acme.example/confirm?token=abc123" style="display: inline-block; background-color: #0670DB; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600;">Confirm email</a></p>
<p style="font-size: 14px; line-height: 20px; color: #6b7280; margin: 24px 0 0;">Or paste this link into your browser: https://acme.example/confirm?token=abc123</p>
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 32px 0;">
<p style="font-size: 12px; line-height: 18px; color: #6b7280; margin: 0;">If you didn't sign up, you can safely ignore this email.</p>
24 changes: 24 additions & 0 deletions packages/editor/src/__tests__/fixtures/load-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

const FIXTURE_DIR = resolve(process.cwd(), 'src/__tests__/fixtures');
const cache = new Map<string, string>();

/**
* Reads a fixture file relative to `src/__tests__/fixtures/`.
* Cached so a single fixture file is read once per test process.
*
* Vitest runs from the package root, which is where `process.cwd()`
* points; that anchors the resolution.
*
* Example: `loadFixture('paste-sources/word.html')`
*/
export function loadFixture(relativePath: string): string {
const absolute = resolve(FIXTURE_DIR, relativePath);
let content = cache.get(absolute);
if (content === undefined) {
content = readFileSync(absolute, 'utf8');
cache.set(absolute, content);
}
return content;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"><div style="font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue',Helvetica,sans-serif;font-size:14px"><div>Hello,</div><div><br></div><div>Quoted text:</div><blockquote type="cite" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex"><div>Original message text.</div></blockquote></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="markdown prose w-full break-words dark:prose-invert dark"><p>Here's a draft welcome email for your beta users. Tone is friendly but concise.</p><hr><p><strong>Subject:</strong> Welcome to Acme — let's get you set up</p><p>Hi {{firstName}},</p><p>Thanks for joining the Acme beta! You're one of the first 100 people to try the new mobile app, and your feedback is going to shape what we build next.</p><p>Three things to try this week:</p><ol><li><strong>Set up your first audience</strong> — paste a CSV or connect your CRM.</li><li><strong>Send a test broadcast</strong> — see how delivery feels with our infrastructure.</li><li><strong>Reply to this email</strong> with anything that surprised you, good or bad.</li></ol><p>Real humans read every reply. I'll personally respond within 24 hours.</p><p>— Sarah, founder</p><p>P.S. If you'd rather opt out of beta-only emails, <a href="#" target="_new" rel="noreferrer">use this link</a>.</p></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<article class="markdown-body entry-content container-lg" itemprop="text"><h2 dir="auto" id="user-content-changelog-v240"><a class="heading-link" href="#changelog-v240"><span aria-hidden="true" class="octicon octicon-link"></span></a>Changelog v2.4.0</h2><h3 dir="auto" id="user-content-new"><a class="heading-link" href="#new"></a>New</h3><ul dir="auto"><li>Granular API key permissions: scope a key to a single domain or audience.</li><li>2048-bit DKIM keys are now the default for new domains.</li></ul><h3 dir="auto" id="user-content-fixed"><a class="heading-link" href="#fixed"></a>Fixed</h3><ul dir="auto"><li>Pasted content from Word no longer leaks <code class="notranslate">Mso*</code> classes into the editor.</li><li>Image upload errors now surface a toast instead of failing silently.</li></ul><div class="highlight highlight-source-shell notranslate position-relative overflow-auto" dir="auto"><pre>curl -X POST https://api.acme.example/v1/emails \
-H <span class="pl-pds">"</span>Authorization: Bearer <span class="pl-pds">$</span>API_KEY<span class="pl-pds">"</span> \
--data <span class="pl-s"><span class="pl-pds">'</span>{"to":"hi@example.com","from":"team@acme.example","subject":"Hi"}<span class="pl-pds">'</span></span></pre></div></article>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div dir="ltr" style="font-family:Arial,sans-serif"><div>Hi team,</div><div><br></div><div>Please review the <a href="https://example.com/doc" target="_blank" rel="noopener" style="color:#1155cc">attached document</a>.</div><div><br></div><div>Thanks,<br>Sender</div></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="ProseMirror" contenteditable="true"><p>Quick triage notes from this morning's review:</p><ul><li><p>The image-upload regression looks like an abort-controller issue when the editor unmounts mid-upload. Repro in Safari is ~1 in 4.</p></li><li><p>Slash command flicker is fixed on the latest <code>main</code>.</p></li></ul><blockquote><p>For Safari specifically — does the new event-bus path solve it, or do we still need to land the imperative theme reconfigure?</p></blockquote><p>cc <span class="mention" data-mention-id="u_sarah">@sarah</span> — want to pair on this Thursday?</p></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<meta charset="utf-8"><h1 style="font-weight: 700; font-size: 1.875em; color: rgb(55, 53, 47);">Q2 Launch Plan</h1><p style="padding: 3px 2px; color: rgb(55, 53, 47);">Owner: Sarah · Status: <span style="background: rgb(245, 224, 159); padding: 1px 6px; border-radius: 3px;">In progress</span></p><div style="background: rgb(247, 246, 243); border-radius: 3px; padding: 16px; margin: 4px 0; display: flex; gap: 8px;"><span>💡</span><div><p style="margin: 0; color: rgb(55, 53, 47);">Goal: cut activation time from 14 days to under 5 by shipping the mobile app + onboarding rewrite.</p></div></div><h2 style="font-weight: 600; font-size: 1.5em; color: rgb(55, 53, 47); margin-top: 24px;">Milestones</h2><div><label class="notion-checkbox-block"><input type="checkbox" checked><span style="text-decoration: line-through; color: rgb(155, 154, 151);">Audit current onboarding funnel</span></label></div><div><label class="notion-checkbox-block"><input type="checkbox" checked><span style="text-decoration: line-through; color: rgb(155, 154, 151);">Design new welcome flow</span></label></div><div><label class="notion-checkbox-block"><input type="checkbox"><span style="color: rgb(55, 53, 47);">Ship mobile beta to first 100 users</span></label></div><div><label class="notion-checkbox-block"><input type="checkbox"><span style="color: rgb(55, 53, 47);">Cut email template setup from 10 minutes to 2</span></label></div><h2 style="font-weight: 600; font-size: 1.5em; color: rgb(55, 53, 47); margin-top: 24px;">Risks</h2><ul style="padding-left: 1.5em;"><li>Mobile push notification setup may slip; iOS review cycles are unpredictable.</li><li>Liveblocks limits at the current tier — we'll cross that line around 250 concurrent rooms.</li></ul><p>See <a href="https://www.notion.so/acme/launch-plan-d4b91f02" style="color: rgb(46, 170, 220); text-decoration: underline;">full launch plan</a> for sub-tasks.</p>
Loading
Loading