Skip to content

feat: adapt low-contrast inline colors to surface#4069

Merged
FredrikWallstrom merged 3 commits into
mainfrom
feat/contrast-aware-rendering
May 12, 2026
Merged

feat: adapt low-contrast inline colors to surface#4069
FredrikWallstrom merged 3 commits into
mainfrom
feat/contrast-aware-rendering

Conversation

@FredrikWallstrom

@FredrikWallstrom FredrikWallstrom commented May 8, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Markdown gains an optional adaptColorContrast mode to auto-adjust inline text colors for better WCAG contrast.
    • Email viewer now applies color-contrast adaptation during render for improved readability.
  • Documentation

    • Added an example demonstrating markdown color-contrast adaptation.
  • Tests

    • Added comprehensive unit and end-to-end tests covering color parsing, contrast calculations, and adaptive behavior.

Review Change Stack

Fixes Lundalogik/crm-client#621
Fixes Lundalogik/crm-client#966

When externally-authored HTML lands inside CRM (imported email bodies, in particular), the sender's inline color: declarations can collide with the host theme — black-on-dark, white-on-white, etc. — leaving content unreadable.

This adds a small adaptColorContrast utility that walks a rendered subtree, computes WCAG contrast for each element with an inline color, and removes declarations below a 3:1 ratio so the surface's themed text color inherits through. Brand colors that already meet contrast pass through untouched.

Three atomic commits:

  1. feat(util): add adaptColorContrast utility — pure DOM walk + WCAG luminance / contrast / sRGB compositing, plus spec and e2e tests. Lives in src/util/ since it's not specific to any one component.
  2. feat(markdown): add opt-in adaptColorContrast prop — new adaptColorContrast boolean prop on <limel-markdown>, default false. Off by default keeps the component a neutral renderer; consumers that mix authored content with themed surfaces (the activity feed) opt in.
  3. feat(email-viewer): adapt color contrast in rendered body — runs unconditionally after each render in <limel-email-viewer>, since it always renders externally-authored content.

Replaces the closed PR #4060 (which had the same logic but ran unconditionally on every <limel-markdown> render) and supersedes the limbb wrapper approach in Lundalogik/lime-crm-building-blocks#1423 (closed). Direction agreed offline with @Kiarokh — the algorithm lives in lime-elements, opt-in for <limel-markdown>, default-on for <limel-email-viewer>.

Review:

  • Commits are atomic
  • Commits have the correct type for the changes made
  • Commits with breaking changes are marked as such

Test plan:

  • Render an .eml whose body has inline color: black text in CRM dark mode via the file viewer. Verify the text is readable (inherits the surface's themed color) rather than rendering black-on-dark.
  • Render an .eml whose body has inline color: white text in CRM light mode. Verify the text falls back to a readable color rather than being invisible.
  • Render an .eml whose body uses brand colors (red, blue, green) on a normal CRM surface in both light and dark mode. Brand colors should still be visible.
  • In a surface that uses <limel-markdown> with adapt-color-contrast set, confirm the same behaviour for inline-colored markdown content.
  • In any other surface that uses <limel-markdown> without adapt-color-contrast, confirm rendering is unchanged from main.

Browsers tested:

Windows:

  • Chrome
  • Edge
  • Firefox

Linux:

  • Chrome
  • Firefox

macOS:

  • Chrome
  • Firefox
  • Safari

Mobile:

  • Chrome on Android
  • iOS

@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds a WCAG-based adaptColorContrast utility, integrates it into limel-markdown (new reflected prop and post-render invocation) and limel-email-viewer (post-render ref call), plus example, unit, and e2e tests.

Changes

Color Contrast Adaptation Feature

Layer / File(s) Summary
Color Utility Implementation
src/util/adapt-color-contrast.ts
Core functions for parsing rgb/rgba/transparent, alpha compositing, sRGB relative luminance, contrast ratio calculation, ancestor/background resolution (including shadow roots), and DOM-walking logic that strips low-contrast inline color styles.
Unit Tests
src/util/adapt-color-contrast.spec.ts
Jest tests for parseColor, relativeLuminance, contrastRatio, and compositeOver covering parsing, luminance extremes/monotonicity, contrast symmetry/thresholds, and alpha compositing.
End-to-End Tests
src/util/adapt-color-contrast.e2e.ts
DOM-based tests mounting a host element to verify no-op behavior, color: inherit preservation, low-contrast stripping and style cleanup, non-color style preservation, brand-color retention, dark/light scenarios, fully transparent handling, ancestor background traversal, and background-image skipping.
Markdown Component Integration
src/components/markdown/markdown.tsx
Adds reflected adaptColorContrast prop (default false), imports adaptColorContrast, invokes it after morph/hydration inside textChanged(), and adds a @Watch handler to re-run adaptation when the prop changes.
Email Viewer Integration
src/components/email-viewer/email-viewer.tsx
Stores a bodyElement ref for the rendered HTML body <div> and calls adaptColorContrast(bodyElement) in componentDidRender() when connected.
Example Component
src/components/markdown/examples/markdown-adapt-color-contrast.tsx
New example component showing side-by-side limel-markdown renders with and without adaptColorContrast={true} on a forced white surface.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Suggested labels

accessibility, visual design

Suggested reviewers

  • Kiarokh
  • adrianschmidt
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: adapt low-contrast inline colors to surface' directly and clearly summarizes the main change: introducing a feature that adapts low-contrast inline colors to match the surface/background, which is the core objective across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/contrast-aware-rendering

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented May 8, 2026

Copy link
Copy Markdown

Documentation has been published to https://lundalogik.github.io/lime-elements/versions/PR-4069/

@FredrikWallstrom FredrikWallstrom force-pushed the feat/contrast-aware-rendering branch from 99bdc60 to 645cdac Compare May 8, 2026 12:22
@FredrikWallstrom FredrikWallstrom requested a review from a team as a code owner May 8, 2026 12:22
Comment on lines +95 to +109
/**
* Adapt rendered inline `color:` declarations to the surrounding
* surface. After each render the component walks the rendered DOM and
* removes any inline `color` whose contrast against the resolved
* background falls below WCAG 3:1, letting the surface's themed text
* color inherit through. Brand colors that already meet contrast are
* left alone.
*
* Default `false` so the component remains a neutral renderer; turn
* this on for surfaces that render externally-authored content
* (e.g. imported email bodies) where the host application's theme
* drives the surrounding text color.
*/
@Prop({ reflect: true })
public adaptColorContrast = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing cleanup after every render sounds rather inefficient to me. Why can't it be done as pre-processing by the component providing the bad input in the first place? 🙂

@FredrikWallstrom FredrikWallstrom May 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Believe me, me and @Kiarokh has been discussing this back and forth 😄

See https://github.com/Lundalogik/lime-crm-building-blocks/pull/1423, and #4066

Pre-processing the "real field value" doesn't feel right too me since we will mutate users content which will then be lost on round-trips. I rather see a visual solution.

We would love to hear your thoughts how to solve this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. I'm going to dig into your arguments, and see if there are really no better solutions. Please give me over the weekend before you merge this. This component unfortunately has a history of being the go to place for people to try to cram patches to fix their broken email HTML, and I really want that to stop. But if this is truly the best solution in this particular case, I will approve it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I've looked at you earlier discussion. It seems like this is a fairly reasonable trade-off.

Claude helped me summarise your conversations, and traced back the first "email band-aid" to be my commit 43e89bf (feat(markdown): support certain whitelisted inline css styles, fix for Lundalogik/limepkg-email#590). It told me the fact that you put the email-shaped feature in there in the first place is worth naming. Otherwise the precedent argument lands as "stop doing what I started." 😄

But yes, "stop doing what I started" is exactly what I'm saying 😆

So, consider this approved on a conceptual level. I'm not setting an approved-stamp, since I have neither reviewed nor tested the code.

Thank you for holding off on merging this until I had a chance to look into it a bit 🙏

@FredrikWallstrom FredrikWallstrom force-pushed the feat/contrast-aware-rendering branch 2 times, most recently from 5aa3831 to 74defa2 Compare May 8, 2026 13:10

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/email-viewer/email-viewer.tsx`:
- Around line 94-100: componentDidRender currently calls
adaptColorContrast(this.bodyElement) even if this.bodyElement may be detached;
update componentDidRender (and optionally where renderBodyHtml sets the ref) to
guard by checking that this.bodyElement exists and is connected
(this.bodyElement && this.bodyElement.isConnected) before calling
adaptColorContrast, and ensure renderBodyHtml's ref clears this.bodyElement when
the body div is not rendered so the reference cannot become stale.

In `@src/components/markdown/markdown.tsx`:
- Around line 96-110: The adaptColorContrast prop is not watched so toggling it
doesn't re-run the DOM processing; add a watcher for adaptColorContrast (similar
to the existing `@Watch` handlers for value, whitelist, removeEmptyParagraphs)
that calls the same DOM reprocessing used by textChanged() — e.g., invoke
adaptColorContrast(this.rootElement) (or reuse textChanged() / the
morphChildren-based re-render path) so true→false restores original inline
colors and false→true strips low-contrast colors; ensure the watcher mirrors
behavior used for whitelist/removeEmptyParagraphs and uses morphChildren to
re-inject original markup when needed.
- Around line 96-108: Update the JSDoc for the adaptColorContrast prop/method to
clarify it runs only after the component's textChanged() update cycle (i.e.,
after value/whitelist/removeEmptyParagraphs and the adaptColorContrast
invocation), not on arbitrary host re-paints or external theme changes; locate
the existing comment block above adaptColorContrast (and references to
textChanged()) and replace the sentence "After each render the component walks
the rendered DOM…" with a one-line note explicitly stating it runs only after
textChanged() cycles so consumers don't expect it to respond to host theme
repaints.

In `@src/util/adapt-color-contrast.ts`:
- Line 2: STRIPPED_COLOR_ATTR is declared but never read, so either remove the
constant and the code that sets this attribute on stripped elements to avoid DOM
bloat, or keep it and add a short comment above STRIPPED_COLOR_ATTR explaining
its intended purpose (e.g., for QA/debugging or future restore path) and why it
is left intentionally write-only; update the code that assigns the attribute to
reference the comment and ensure no other code relies on it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f4609123-5c7b-4eb9-89a8-b38ff463f917

📥 Commits

Reviewing files that changed from the base of the PR and between c3e14c2 and 5aa3831.

⛔ Files ignored due to path filters (1)
  • etc/lime-elements.api.md is excluded by !etc/lime-elements.api.md
📒 Files selected for processing (6)
  • src/components/email-viewer/email-viewer.tsx
  • src/components/markdown/examples/markdown-adapt-color-contrast.tsx
  • src/components/markdown/markdown.tsx
  • src/util/adapt-color-contrast.e2e.ts
  • src/util/adapt-color-contrast.spec.ts
  • src/util/adapt-color-contrast.ts

Comment thread src/components/email-viewer/email-viewer.tsx
Comment thread src/components/markdown/markdown.tsx
Comment thread src/components/markdown/markdown.tsx
Comment thread src/util/adapt-color-contrast.ts Outdated
@FredrikWallstrom FredrikWallstrom force-pushed the feat/contrast-aware-rendering branch from 915746d to 812fb8f Compare May 8, 2026 13:31

public componentDidRender() {
if (this.bodyElement?.isConnected) {
adaptColorContrast(this.bodyElement);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this have to run on every re-render, or could we check so that it only runs for example when innerHTML is updated? (Or maybe it's never re-rendered apart from when the body changes? 🤔)

@FredrikWallstrom FredrikWallstrom May 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could skip to run this on every re-render by memoizing the produced innerHtml string. However, that means holding a full extra copy of the body HTML in memory, not just a pointer to the input.

Which approach do we think is best? Holding a copy of the innerHTML in-memory, or run adaption on each re-render?

(The reason we'd need to memoize is that applyRemoteImagesPolicy returns a fresh string on every call when remote images are on, so Stencil keeps re-setting innerHTML between renders, which re-parses the HTML and silently wipes our adaptation.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, let's keep it as it is :)

@FredrikWallstrom FredrikWallstrom force-pushed the feat/contrast-aware-rendering branch from 812fb8f to faae813 Compare May 12, 2026 09:14
@FredrikWallstrom FredrikWallstrom enabled auto-merge (rebase) May 12, 2026 09:15

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/util/adapt-color-contrast.ts`:
- Line 156: The alpha parsing in parseColor currently does const a = match[4]
=== undefined ? 1 : Number.parseFloat(match[4]) which can yield NaN or invalid
values; change it to explicitly parse and validate the alpha: if match[4] is
present, parse with Number.parseFloat into a variable, check
Number.isFinite(parsedA), then clamp it to the 0–1 range (e.g. Math.min(1,
Math.max(0, parsedA))); if it's not finite or invalid, fall back to a sensible
default (e.g. 1) rather than using NaN. This ensures downstream functions
compositeOver and contrastRatio receive a valid alpha.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2153e34f-25f4-48f8-9666-16fe2ab553c1

📥 Commits

Reviewing files that changed from the base of the PR and between 5aa3831 and faae813.

⛔ Files ignored due to path filters (1)
  • etc/lime-elements.api.md is excluded by !etc/lime-elements.api.md
📒 Files selected for processing (6)
  • src/components/email-viewer/email-viewer.tsx
  • src/components/markdown/examples/markdown-adapt-color-contrast.tsx
  • src/components/markdown/markdown.tsx
  • src/util/adapt-color-contrast.e2e.ts
  • src/util/adapt-color-contrast.spec.ts
  • src/util/adapt-color-contrast.ts

if (!match) {
return null;
}
const a = match[4] === undefined ? 1 : Number.parseFloat(match[4]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate parsed alpha to prevent NaN or invalid values.

The regex [\d.]+ for the alpha channel can match invalid strings such as "." (yields NaN) or "1.2.3" (parseFloat stops at the second dot, yielding 1.2). Since parseColor is exported and may be called by external consumers, invalid alpha values can propagate through compositeOver and contrastRatio, causing incorrect contrast decisions.

🛡️ Proposed fix
     const a = match[4] === undefined ? 1 : Number.parseFloat(match[4]);
+    if (Number.isNaN(a)) {
+        return null;
+    }
 
     return {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const a = match[4] === undefined ? 1 : Number.parseFloat(match[4]);
const a = match[4] === undefined ? 1 : Number.parseFloat(match[4]);
if (Number.isNaN(a)) {
return null;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/util/adapt-color-contrast.ts` at line 156, The alpha parsing in
parseColor currently does const a = match[4] === undefined ? 1 :
Number.parseFloat(match[4]) which can yield NaN or invalid values; change it to
explicitly parse and validate the alpha: if match[4] is present, parse with
Number.parseFloat into a variable, check Number.isFinite(parsedA), then clamp it
to the 0–1 range (e.g. Math.min(1, Math.max(0, parsedA))); if it's not finite or
invalid, fall back to a sensible default (e.g. 1) rather than using NaN. This
ensures downstream functions compositeOver and contrastRatio receive a valid
alpha.

@FredrikWallstrom FredrikWallstrom merged commit b934900 into main May 12, 2026
10 checks passed
@FredrikWallstrom FredrikWallstrom deleted the feat/contrast-aware-rendering branch May 12, 2026 09:23
@lime-opensource

Copy link
Copy Markdown
Collaborator

🎉 This PR is included in version 39.24.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants