Foundation for the compose-editor rich formatting epic. Eight sibling tickets (#71–#78) stack on this branch — do not force-push it.
Contract implemented (org.libremail.richtext, pure JVM)
RichStyle is now a sealed interface: Bold, Italic, Underline, Strikethrough, FontFamily(css), FontSize(pt), FontColor(argb), Highlight(argb).
RichTextContent gains alignments: List<RichAlignment> (RichAlign START/CENTER/END, whole-line ranges), images: List<RichImage> (start/end/contentId/name, range covers the visible [image: name] token — see imageToken(name)), and baseStyle: RichBaseStyle? (fontCss, fontSizePt).
Serializer (RichTextHtml.toHtml, split into its own file): parameterized styles merge into one <span style="font-family:…;font-size:Npt;color:#rrggbb;background-color:#rrggbb"> per constant-styling run; alignment is text-align on <p>/<li> (consecutive-line <p> merging splits at alignment boundaries, and an all-blank group emits one <br> per line so blank lines round-trip); images replace their token with <img src="cid:…" alt="name"> keeping surrounding style/link wrappers; baseStyle is a single outermost <div style="…"> (emitted even for empty text so a pre-set base font survives).
Parser (RichTextHtmlParser.kt): faithful inverse of everything toHtml emits; also accepts <s>/<del>/<strike>, strong/em, px font sizes (pt = px·3/4, rounded), #rgb colors, left/start/right/end alignment synonyms. Unknown tags/CSS are ignored without dropping the text run. Open styles live on a general stack (span tags can carry several styles). Output is canonical: same-value spans and adjacent same-align line runs are maximally merged.
hasFormatting() covers every channel (span, link, block marker, alignment, image, non-null baseStyle). Exhaustive round-trip tests assert fromHtml(toHtml(x)) == xandhasFormatting() on both sides for every new style/channel — the guard for ComposeViewModel.normalizedHtml(), which drops any HTML that parses back unformatted.
Editor mapping (ui/compose/RichTextEditor.kt): parameterized spans carry identity via the STYLE_TAG = "libremail:style" string annotation (so FontColor can never be confused with link paint), images via IMAGE_TAG over the token range, alignment via ParagraphStyle(textAlign) ranges (sorted, overlap-dropped defensively). toAnnotatedString gains a font-resolver hook (css) -> FontFamily? (default resolves nothing); baseStyle lives in separate remember state seeded with the field and recombined in emit(), and is applied to the field's textStyle. RichTextBodyField's signature stays source-compatible (resolveFont defaulted; both existing call sites unchanged).
RichTextEditing: toggleStyle toggles the exact value off and replaces a different value of the same kind over the range; isStyled is exact-value; new styleAt<T>() answers "the single value of kind T over the selection (or caret)" for the pickers; toggleBlock now remaps and preserves alignments/images/baseStyle. Alignment/image ops are deliberately not implemented (#76/#77 own them). applyStyle(value, style, linkColor, resolveFont) is internal for one-line sibling wiring.
Newui/compose/format/ColorSwatchRow.kt (+ ColorSwatch): swatch row with selection ring, per-swatch content descriptions, and a leading "no color" entry for the #74/#75 dialogs.
Zero visible UI change: no new toolbar buttons; existing toolbar, signatures, drafts, and all pre-existing tests unchanged and green.
Deviations / decisions
ColorSwatch lives in its own file (format/ColorSwatch.kt) — detekt MatchingDeclarationName; package and names as contracted.
The parser class is RichTextHtmlParser (was HtmlToRichParser, internal — same reason).
text-align is emitted as left/center/right (email-client compatibility); start/end are parsed as synonyms.
Colors serialize as #rrggbb, so only opaque ARGB values round-trip (documented on RichStyle).
Known follow-ups for sibling tickets (found in review, out of foundation scope)
#78 (font memory): once baseStyle is non-null, the whole body serializes inside the <div> wrapper, so ComposeViewModel.swapHtmlSignature's endsWith(oldBlock) check fails and a From-account switch takes the existing rebuild-from-plaintext fallback (formatting lost, as already documented for edited HTML). Wiring baseStyle must make that swap wrapper-aware.
#76 (alignment ops): alignment on block-quote lines is not emitted (contract covers <p>/<li> only), and ops should canonicalize ranges to whole lines — toggleBlock remaps a range that started at a line start to start after the inserted marker, which re-parses expanded to the full line.
#77 (images): the editor's legacy TextFieldValue path drops annotations on IME edits (pre-existing — same for bold/links today); image insertion needs to keep the token and its RichImage record atomic under typing, and spans partially overlapping a token are canonicalized on serialize.
Verification
:app:assembleDebug + :app:testDebugUnitTest + :app:lintDebug + :app:ktlintCheck + :app:detekt + :app:compileDebugAndroidTestKotlin all green; emulator E2E left to CI.
Foundation for the compose-editor rich formatting epic. Eight sibling tickets (#71–#78) stack on this branch — **do not force-push it**.
## Contract implemented (`org.libremail.richtext`, pure JVM)
- **`RichStyle`** is now a sealed interface: `Bold`, `Italic`, `Underline`, `Strikethrough`, `FontFamily(css)`, `FontSize(pt)`, `FontColor(argb)`, `Highlight(argb)`.
- **`RichTextContent`** gains `alignments: List<RichAlignment>` (`RichAlign START/CENTER/END`, whole-line ranges), `images: List<RichImage>` (`start/end/contentId/name`, range covers the visible `[image: name]` token — see `imageToken(name)`), and `baseStyle: RichBaseStyle?` (`fontCss`, `fontSizePt`).
- **Serializer** (`RichTextHtml.toHtml`, split into its own file): parameterized styles merge into one `<span style="font-family:…;font-size:Npt;color:#rrggbb;background-color:#rrggbb">` per constant-styling run; alignment is `text-align` on `<p>`/`<li>` (consecutive-line `<p>` merging splits at alignment boundaries, and an all-blank group emits one `<br>` per line so blank lines round-trip); images replace their token with `<img src="cid:…" alt="name">` keeping surrounding style/link wrappers; `baseStyle` is a single outermost `<div style="…">` (emitted even for empty text so a pre-set base font survives).
- **Parser** (`RichTextHtmlParser.kt`): faithful inverse of everything `toHtml` emits; also accepts `<s>/<del>/<strike>`, `strong`/`em`, `px` font sizes (pt = px·3/4, rounded), `#rgb` colors, `left/start`/`right/end` alignment synonyms. Unknown tags/CSS are ignored **without** dropping the text run. Open styles live on a general stack (span tags can carry several styles). Output is canonical: same-value spans and adjacent same-align line runs are maximally merged.
- **`hasFormatting()`** covers every channel (span, link, block marker, alignment, image, non-null baseStyle). Exhaustive round-trip tests assert `fromHtml(toHtml(x)) == x` **and** `hasFormatting()` on both sides for every new style/channel — the guard for `ComposeViewModel.normalizedHtml()`, which drops any HTML that parses back unformatted.
- **Editor mapping** (`ui/compose/RichTextEditor.kt`): parameterized spans carry identity via the `STYLE_TAG = "libremail:style"` string annotation (so `FontColor` can never be confused with link paint), images via `IMAGE_TAG` over the token range, alignment via `ParagraphStyle(textAlign)` ranges (sorted, overlap-dropped defensively). `toAnnotatedString` gains a font-resolver hook `(css) -> FontFamily?` (default resolves nothing); `baseStyle` lives in separate `remember` state seeded with the field and recombined in `emit()`, and is applied to the field's `textStyle`. `RichTextBodyField`'s signature stays source-compatible (`resolveFont` defaulted; both existing call sites unchanged).
- **`RichTextEditing`**: `toggleStyle` toggles the exact value off and *replaces* a different value of the same kind over the range; `isStyled` is exact-value; new `styleAt<T>()` answers "the single value of kind T over the selection (or caret)" for the pickers; `toggleBlock` now remaps and preserves alignments/images/baseStyle. Alignment/image *ops* are deliberately not implemented (#76/#77 own them). `applyStyle(value, style, linkColor, resolveFont)` is `internal` for one-line sibling wiring.
- **New** `ui/compose/format/ColorSwatchRow.kt` (+ `ColorSwatch`): swatch row with selection ring, per-swatch content descriptions, and a leading "no color" entry for the #74/#75 dialogs.
- **Zero visible UI change**: no new toolbar buttons; existing toolbar, signatures, drafts, and all pre-existing tests unchanged and green.
## Deviations / decisions
- `ColorSwatch` lives in its own file (`format/ColorSwatch.kt`) — detekt `MatchingDeclarationName`; package and names as contracted.
- The parser class is `RichTextHtmlParser` (was `HtmlToRichParser`, internal — same reason).
- `text-align` is emitted as `left/center/right` (email-client compatibility); `start`/`end` are parsed as synonyms.
- Colors serialize as `#rrggbb`, so only opaque ARGB values round-trip (documented on `RichStyle`).
## Known follow-ups for sibling tickets (found in review, out of foundation scope)
- **#78 (font memory):** once `baseStyle` is non-null, the whole body serializes inside the `<div>` wrapper, so `ComposeViewModel.swapHtmlSignature`'s `endsWith(oldBlock)` check fails and a From-account switch takes the existing rebuild-from-plaintext fallback (formatting lost, as already documented for edited HTML). Wiring baseStyle must make that swap wrapper-aware.
- **#76 (alignment ops):** alignment on block-quote lines is not emitted (contract covers `<p>`/`<li>` only), and ops should canonicalize ranges to whole lines — `toggleBlock` remaps a range that started at a line start to start after the inserted marker, which re-parses expanded to the full line.
- **#77 (images):** the editor's legacy `TextFieldValue` path drops annotations on IME edits (pre-existing — same for bold/links today); image insertion needs to keep the token and its `RichImage` record atomic under typing, and spans partially overlapping a token are canonicalized on serialize.
## Verification
`:app:assembleDebug` + `:app:testDebugUnitTest` + `:app:lintDebug` + `:app:ktlintCheck` + `:app:detekt` + `:app:compileDebugAndroidTestKotlin` all green; emulator E2E left to CI.
Closes #70
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Foundation for the compose-editor rich formatting epic. Eight sibling tickets (#71–#78) stack on this branch — do not force-push it.
Contract implemented (
org.libremail.richtext, pure JVM)RichStyleis now a sealed interface:Bold,Italic,Underline,Strikethrough,FontFamily(css),FontSize(pt),FontColor(argb),Highlight(argb).RichTextContentgainsalignments: List<RichAlignment>(RichAlign START/CENTER/END, whole-line ranges),images: List<RichImage>(start/end/contentId/name, range covers the visible[image: name]token — seeimageToken(name)), andbaseStyle: RichBaseStyle?(fontCss,fontSizePt).RichTextHtml.toHtml, split into its own file): parameterized styles merge into one<span style="font-family:…;font-size:Npt;color:#rrggbb;background-color:#rrggbb">per constant-styling run; alignment istext-alignon<p>/<li>(consecutive-line<p>merging splits at alignment boundaries, and an all-blank group emits one<br>per line so blank lines round-trip); images replace their token with<img src="cid:…" alt="name">keeping surrounding style/link wrappers;baseStyleis a single outermost<div style="…">(emitted even for empty text so a pre-set base font survives).RichTextHtmlParser.kt): faithful inverse of everythingtoHtmlemits; also accepts<s>/<del>/<strike>,strong/em,pxfont sizes (pt = px·3/4, rounded),#rgbcolors,left/start/right/endalignment synonyms. Unknown tags/CSS are ignored without dropping the text run. Open styles live on a general stack (span tags can carry several styles). Output is canonical: same-value spans and adjacent same-align line runs are maximally merged.hasFormatting()covers every channel (span, link, block marker, alignment, image, non-null baseStyle). Exhaustive round-trip tests assertfromHtml(toHtml(x)) == xandhasFormatting()on both sides for every new style/channel — the guard forComposeViewModel.normalizedHtml(), which drops any HTML that parses back unformatted.ui/compose/RichTextEditor.kt): parameterized spans carry identity via theSTYLE_TAG = "libremail:style"string annotation (soFontColorcan never be confused with link paint), images viaIMAGE_TAGover the token range, alignment viaParagraphStyle(textAlign)ranges (sorted, overlap-dropped defensively).toAnnotatedStringgains a font-resolver hook(css) -> FontFamily?(default resolves nothing);baseStylelives in separaterememberstate seeded with the field and recombined inemit(), and is applied to the field'stextStyle.RichTextBodyField's signature stays source-compatible (resolveFontdefaulted; both existing call sites unchanged).RichTextEditing:toggleStyletoggles the exact value off and replaces a different value of the same kind over the range;isStyledis exact-value; newstyleAt<T>()answers "the single value of kind T over the selection (or caret)" for the pickers;toggleBlocknow remaps and preserves alignments/images/baseStyle. Alignment/image ops are deliberately not implemented (#76/#77 own them).applyStyle(value, style, linkColor, resolveFont)isinternalfor one-line sibling wiring.ui/compose/format/ColorSwatchRow.kt(+ColorSwatch): swatch row with selection ring, per-swatch content descriptions, and a leading "no color" entry for the #74/#75 dialogs.Deviations / decisions
ColorSwatchlives in its own file (format/ColorSwatch.kt) — detektMatchingDeclarationName; package and names as contracted.RichTextHtmlParser(wasHtmlToRichParser, internal — same reason).text-alignis emitted asleft/center/right(email-client compatibility);start/endare parsed as synonyms.#rrggbb, so only opaque ARGB values round-trip (documented onRichStyle).Known follow-ups for sibling tickets (found in review, out of foundation scope)
baseStyleis non-null, the whole body serializes inside the<div>wrapper, soComposeViewModel.swapHtmlSignature'sendsWith(oldBlock)check fails and a From-account switch takes the existing rebuild-from-plaintext fallback (formatting lost, as already documented for edited HTML). Wiring baseStyle must make that swap wrapper-aware.<p>/<li>only), and ops should canonicalize ranges to whole lines —toggleBlockremaps a range that started at a line start to start after the inserted marker, which re-parses expanded to the full line.TextFieldValuepath drops annotations on IME edits (pre-existing — same for bold/links today); image insertion needs to keep the token and itsRichImagerecord atomic under typing, and spans partially overlapping a token are canonicalized on serialize.Verification
:app:assembleDebug+:app:testDebugUnitTest+:app:lintDebug+:app:ktlintCheck+:app:detekt+:app:compileDebugAndroidTestKotlinall green; emulator E2E left to CI.Closes #70
🤖 Generated with Claude Code