fix(desktop): WYSIWYG parity between chat composer and message timeline

Composer styling now mirrors the rendered Markdown timeline (default
variant in shared/ui/markdown.tsx) so what the user sees while typing
matches what they see after sending — every block element (p, ul, ol,
li, blockquote, pre, hr, h1/h2/h3, inline code, links).

Key changes
- Inter-block spacing driven by an owl rule (` & > * + * `) using the
  same Tailwind tokens as the timeline (mt-3, mt-3.5 for headings /
  blockquote / code-block, mt-4 for hr, mt-1.5 for p+ul/ol). Removes the
  empty-list height jitter: a freshly toggled ul/ol with no items adds
  no margin of its own, so the editor only grows when items appear.
- Editor wrapper bumped from leading-6 to leading-7 and min-height from
  1.5rem to 1.75rem to match the rendered side.
- Per-element styles converted to Tailwind utilities via @apply:
  list-disc/decimal pl-6 space-y-1 marker:text-muted-foreground on
  ul/ol; my-1 [&_p]:inline on li; bordered/italic blockquote with
  child-margin owl rules; rounded code-block with border/bg/shadow;
  inline code with rounded bg-muted; primary underlined links.
- Headings (h1/h2/h3) gain proper sizing — only reachable via paste
  since heading input rules are still disabled in StarterKit, but
  pasted markdown round-trips correctly now.

Behavior change
- Shift+Enter now splits into a real new paragraph (splitBlock) instead
  of inserting a hard break (<br>). Plain Enter still submits. This
  matches user intuition (Slack/iMessage style) and keeps WYSIWYG —
  previously the soft-break composer rendering looked tighter than the
  paragraph spacing the message would receive on send. The hardBreak
  node is still enabled in StarterKit so pasted markdown containing
  explicit hard breaks round-trips.

Verified: pnpm typecheck, pnpm lint, pnpm build all green.
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-08 16:58:00 -07:00
parent bfafdd46b2
commit aabfe1d55d
2 changed files with 104 additions and 51 deletions
@@ -112,7 +112,9 @@ export function useRichTextEditor({
{
extensions: [
StarterKit.configure({
// Use hard breaks (Shift+Enter) — Enter submits the message.
// Hard-break node is kept enabled so pasted markdown that contains
// explicit hard breaks still round-trips, but Shift+Enter is rebound
// below (`smartShiftEnter`) to split into a real new paragraph.
hardBreak: {
keepMarks: true,
},
@@ -258,8 +260,12 @@ export function useRichTextEditor({
// Non-empty → split the paragraph within the blockquote.
return ed.chain().splitBlock().focus().run();
}
// Default: hard break (StarterKit handles it).
return false;
// Default: split into a real new paragraph. WYSIWYG parity
// with the rendered timeline — two paragraphs always have
// margin between them. Previously inserted a hard break
// (<br>), which produced a single \n on send and rendered
// visibly tighter than the post-send paragraph spacing.
return ed.chain().splitBlock().focus().run();
},
ArrowDown: ({ editor: ed }) => {
// Empty last list item + Down → exit list to paragraph below.
@@ -320,7 +326,7 @@ export function useRichTextEditor({
editorProps: {
attributes: {
class:
"min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-hidden prose-sm max-w-none",
"min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-7 md:leading-7 shadow-none focus-visible:ring-0 caret-foreground outline-hidden max-w-none",
"data-testid": "message-input",
},
// ArrowUp in an empty composer → edit your last message (Slack
+94 -47
View File
@@ -221,13 +221,64 @@
}
/* ── Tiptap rich-text composer ──────────────────────────────────────── */
/*
* Composer styles mirror the rendered Markdown timeline (see
* desktop/src/shared/ui/markdown.tsx, default variant) so the editor is
* visually WYSIWYG with the sent message. The owl rules `& > * + *` drive
* inter-block spacing, leaving an isolated single block (e.g. an empty
* <ul> when a list is freshly toggled) with zero added margin — so the
* composer height stays static until additional items are added.
*/
.rich-text-composer .tiptap {
outline: none;
min-height: 1.5rem; /* single line height */
font-size: 0.875rem;
line-height: 1.5rem;
@apply text-sm leading-7 outline-none;
min-height: 1.75rem; /* one line at leading-7 (28px) */
}
/* Inter-block spacing — matches markdown.tsx default variant. */
.rich-text-composer .tiptap > * + * {
@apply mt-3;
}
.rich-text-composer .tiptap > *:first-child {
margin-top: 0;
}
.rich-text-composer .tiptap > *:last-child {
margin-bottom: 0;
}
/* Headings push and pull. */
.rich-text-composer .tiptap > * + h1,
.rich-text-composer .tiptap > * + h2,
.rich-text-composer .tiptap > * + h3 {
@apply mt-3.5;
}
.rich-text-composer .tiptap > h1 + *,
.rich-text-composer .tiptap > h2 + *,
.rich-text-composer .tiptap > h3 + * {
@apply mt-0.5;
}
/* Blockquote, code block, table, and hr breathe more. */
.rich-text-composer .tiptap > * + blockquote,
.rich-text-composer .tiptap > blockquote + * {
@apply mt-3.5;
}
.rich-text-composer .tiptap > * + pre,
.rich-text-composer .tiptap > pre + * {
@apply mt-3.5;
}
.rich-text-composer .tiptap > * + hr,
.rich-text-composer .tiptap > hr + * {
@apply mt-4;
}
/* Lists immediately after a paragraph hug a little tighter. */
.rich-text-composer .tiptap > p + ul,
.rich-text-composer .tiptap > p + ol {
@apply mt-1.5;
}
/* Paragraph placeholder. Paragraph itself has no margin — the owl rule
* above provides spacing between sibling blocks. */
.rich-text-composer .tiptap p {
margin: 0;
}
@@ -254,81 +305,77 @@
color: hsl(var(--foreground));
}
/* Inline marks. */
.rich-text-composer .tiptap strong {
font-weight: 600;
@apply font-semibold;
}
.rich-text-composer .tiptap em {
font-style: italic;
@apply italic;
}
.rich-text-composer .tiptap s {
text-decoration: line-through;
}
.rich-text-composer .tiptap a {
@apply font-medium text-primary underline underline-offset-4 cursor-pointer;
}
/* Inline code. */
.rich-text-composer .tiptap code {
border-radius: 0.375rem;
background: hsl(var(--muted));
padding: 0.125rem 0.375rem;
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
@apply rounded-md bg-muted px-1.5 py-0.5 font-mono text-[13px] text-foreground;
}
/* Code block. */
.rich-text-composer .tiptap pre {
border-radius: 0.75rem;
border: 1px solid hsl(var(--border) / 0.7);
background: hsl(var(--muted) / 0.6);
padding: 0.375rem 0.75rem;
overflow-x: auto;
margin: 0.25rem 0;
@apply max-h-[400px] overflow-x-auto overflow-y-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 shadow-xs;
}
.rich-text-composer .tiptap pre code {
background: none;
padding: 0;
border-radius: 0;
font-size: 0.8125rem;
line-height: 1.5rem;
@apply bg-transparent p-0 rounded-none text-[13px] leading-6;
}
/* Blockquote. */
.rich-text-composer .tiptap blockquote {
border-left: 2px solid hsl(var(--border));
padding-left: 1rem;
font-style: italic;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0;
@apply border-l-2 border-border pl-4 italic text-muted-foreground;
}
.rich-text-composer .tiptap blockquote > *:first-child {
margin-top: 0;
}
.rich-text-composer .tiptap blockquote > * + * {
@apply mt-2;
}
/* Lists. The owl rule (& > * + *) above handles spacing relative to
* surrounding blocks; per-list margin is intentionally not set, so
* toggling an empty list does not change the editor height. */
.rich-text-composer .tiptap ul {
list-style-type: disc;
padding-left: 1.5rem;
margin: 0.25rem 0;
@apply list-disc pl-6 space-y-1 marker:text-muted-foreground;
margin: 0;
}
.rich-text-composer .tiptap ol {
list-style-type: decimal;
padding-left: 1.5rem;
margin: 0.25rem 0;
@apply list-decimal pl-6 space-y-1 marker:text-muted-foreground;
margin: 0;
}
.rich-text-composer .tiptap li {
margin: 0.125rem 0;
@apply my-1;
}
.rich-text-composer .tiptap li p {
display: inline;
}
.rich-text-composer .tiptap a {
color: hsl(var(--primary));
text-decoration: underline;
text-underline-offset: 4px;
cursor: pointer;
/* Headings (only reachable via paste, since heading input rules are
* disabled in StarterKit — but pasted markdown round-trips). */
.rich-text-composer .tiptap h1 {
@apply text-xl font-semibold leading-8 tracking-tight;
}
.rich-text-composer .tiptap h2 {
@apply text-lg font-semibold leading-7 tracking-tight;
}
.rich-text-composer .tiptap h3 {
@apply text-base font-semibold leading-6 tracking-tight;
}
/* Horizontal rule. */
.rich-text-composer .tiptap hr {
border-color: hsl(var(--border) / 0.8);
margin: 0.5rem 0;
@apply border-border/80;
}
.rich-text-composer .tiptap .mention-highlight {