aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md7
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md64
-rw-r--r--src/htmlbuilder.cpp103
-rw-r--r--src/htmlbuilder.h40
-rw-r--r--src/messageview.cpp16
-rw-r--r--src/messageview.h8
-rw-r--r--tests/test_htmlbuilder.cpp121
7 files changed, 340 insertions, 19 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94ae6c9..ef2b5be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,13 @@ point at which they are stable.
### Fixed
+- **The message pane follows the desktop theme.** Its stylesheet hardcoded
+ light-theme greys and set no background at all, so plain-text mail rendered
+ as black on white inside a dark window. The colours now derive from the
+ palette, with the secondary ones blended from it rather than fixed, since a
+ grey chosen to read as subtle on white is nearly invisible on near-black. A
+ message that brings its own HTML still brings its own colours: that styling
+ is deliberately left alone.
- **A message whose HTML body carries a `Content-Id` renders**, instead of
opening blank with the app reporting no HTML part. A content id makes a part
referenceable, not undisplayable, and setting one on the body is legal and
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 2bcdd0a..faf510a 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -60,6 +60,7 @@ taking that too literally.
| 9 | No in-app view of configured shortcuts | discoverability | S | **done** |
| 10 | Reaching an account's inbox takes two steps | workflow | S | **postponed** (partly done) |
| 11 | Icon, `.desktop` file, SlackBuild | packaging | M | **done** |
+| 12 | Message pane is light-theme only | presentation | S | **done** |
| 13 | No visual feedback that an action stuck | feedback | S | **done** |
| 14 | Tag column unreadable, tags need another home | presentation | M | **done** |
| 15 | Attachments are parsed but unreachable from the UI | information | M | **done** |
@@ -433,10 +434,12 @@ unchanged. Regression test in `test_config`.
**Caution:** do not hardcode colors. The app should follow the desktop palette;
a hand-picked grey that looks right on a light theme is unreadable on a dark
-one. Use `QPalette` roles. This applies to `HtmlBuilder`'s CSS too, which
-currently hardcodes `#bbb`, `#555`, `#000`, `#666`, `#ddd` and no background,
-and will look wrong under a dark theme. That is arguably its own item; see
-item 12 below if it gets split out.
+one. Use `QPalette` roles. This applied to `HtmlBuilder`'s CSS too, which was
+split out as item 12 and **done on 2026-08-07**: its colours now derive from
+the palette, with the secondary ones blended rather than fixed. The same rule
+governs whatever this item adds to the thread list, and item 12's test, which
+asserts that no colour appears that the palette did not supply, is the pattern
+to copy.
### Refined by the user, 2026-08-04
@@ -623,6 +626,57 @@ Packaging, independent of everything above, and can proceed in parallel.
---
+## 12. Message pane is light-theme only
+
+**Split from item 5**, which recorded the rule against hardcoded colours.
+Listed in the deferred table until it was picked up on 2026-08-07.
+
+**Observed:** the user runs a dark desktop (`color-scheme: prefer-dark`), and
+plain-text mail rendered as black on white inside a dark window.
+
+**Cause (verified in code):** `kStyle` in `src/htmlbuilder.cpp` hardcoded
+`#bbb`, `#555`, `#000`, `#666`, `#ddd` and `#4a6f8a`, and set **no background
+at all**, so the web view's own default showed through whatever the desktop
+was.
+
+**Approach as built.** A `HtmlBuilder::Palette` struct passed into
+`build`/`buildThread`, derived from a `QPalette` by `paletteFrom()`. Passed in
+rather than read from `qApp` inside the builder, so the stylesheet can be
+tested against a known palette with no running application.
+
+- **`Base` and `Text`, not `Window` and `WindowText`.** The pane is a content
+ surface like a text edit, and on many themes `Base` differs from `Window`.
+- **The derived colours are blends, not fixed greys.** This is the part that
+ makes it work both ways round: a `#555` chosen to read as "subtle" on white
+ is nearly invisible on `#2b2b2b`. `dim` and `border` are mixes of text and
+ background, so they land at the right contrast whichever way the theme goes.
+- The quote colour keeps its hue, since "this is quoted" is carried by being a
+ different colour rather than a dimmer one, but it is pulled toward the
+ background so it stays readable rather than glowing on dark.
+
+Measured on the user's actual theme: background `#2b2b2b`, text `#dedede`, dim
+`#969696`, border `#585858`, quote `#6490b0`.
+
+**Scope, and it is asserted in a test so it cannot drift.** A message that
+brings its own HTML brings its own colours, and those are left alone.
+Rewriting a sender's styling would break layouts that depend on it, and a
+newsletter that sets a white background is entitled to stay white. This item
+themes the plain-text render and the chrome around messages, nothing else. So
+HTML-heavy mail will still look light, correctly.
+
+**`MessageView` passes its own widget palette**, not the application's: a style
+sheet or a themed parent can give the pane different colours from `qApp`. It
+also re-renders on `QEvent::PaletteChange`, because the document's colours are
+baked into its stylesheet at build time and it does not restyle itself the way
+a widget does; without that, switching the desktop theme would leave the open
+thread on the old palette until the next selection.
+
+**Verification.** The load-bearing test asserts the **negative**: no hex colour
+appears in the `<style>` block that the palette did not supply. A test that
+only checks the palette's colours are present passes with a leftover literal
+still in place, and a single leftover literal is the entire defect. Confirmed
+by mutation: putting one hardcoded colour back fails it.
+
## 13. No visual feedback that an action stuck
**Observed:** selecting a thread and hitting Delete changed nothing on screen.
@@ -2497,7 +2551,7 @@ sequence, appended as they arise.
| # | Item | Why here |
|---|------|----------|
-| 12 | `HtmlBuilder` CSS is light-theme only | Split from item 5. Hardcoded greys and no background color; a dark desktop theme will render message bodies badly. Fix likely means passing palette-derived colors into the CSS, which affects `HtmlBuilder`'s tests. |
+| 12 | `HtmlBuilder` CSS is light-theme only | **Done 2026-08-07**, and moved to the main status table. Kept listed here so the split from item 5 stays traceable. |
## Adding to this document
diff --git a/src/htmlbuilder.cpp b/src/htmlbuilder.cpp
index ccb9171..c8f039f 100644
--- a/src/htmlbuilder.cpp
+++ b/src/htmlbuilder.cpp
@@ -18,25 +18,92 @@
#include "htmlbuilder.h"
+#include <QCoreApplication>
+#include <QGuiApplication>
#include <QRegularExpression>
namespace {
-const char *kStyle = R"CSS(
-body { font-family: sans-serif; font-size: 10pt; margin: 12px; }
+/// The stylesheet, with every colour supplied by the caller.
+///
+/// %1 background, %2 text, %3 quote, %4 border, %5 dim.
+const char *kStyleTemplate = R"CSS(
+body { font-family: sans-serif; font-size: 10pt; margin: 12px;
+ background: %1; color: %2; }
pre.plain { white-space: pre-wrap; word-wrap: break-word;
font-family: monospace; margin: 0; }
-span.quote { color: #4a6f8a; }
-.message { border-top: 1px solid #bbb; padding: 10px 0; }
+span.quote { color: %3; }
+.message { border-top: 1px solid %4; padding: 10px 0; }
.message:first-child { border-top: none; }
-.msg-header { font-size: 9pt; color: #555; margin-bottom: 8px; }
-.msg-header .who { font-weight: bold; color: #000; }
-.stub { font-size: 9pt; color: #666; padding: 4px 0;
- border-top: 1px solid #ddd; }
+.msg-header { font-size: 9pt; color: %5; margin-bottom: 8px; }
+.msg-header .who { font-weight: bold; color: %2; }
+.stub { font-size: 9pt; color: %5; padding: 4px 0;
+ border-top: 1px solid %4; }
)CSS";
+/// Mixes two colours, `weight` being how much of `a` survives.
+///
+/// Blending is what makes the derived colours theme-correct. A fixed grey is
+/// only "subtle" against the background it was chosen for: #555 reads as a
+/// quiet label on white and nearly vanishes on near-black.
+QColor blend(const QColor &a, const QColor &b, qreal weight)
+{
+ const qreal inverse = 1.0 - weight;
+ return QColor::fromRgbF(a.redF() * weight + b.redF() * inverse,
+ a.greenF() * weight + b.greenF() * inverse,
+ a.blueF() * weight + b.blueF() * inverse);
+}
+
} // namespace
+HtmlBuilder::Palette HtmlBuilder::paletteFrom(const QPalette &palette)
+{
+ // Base and Text, not Window and WindowText: the pane is a content surface
+ // like a text edit, and on many themes Base differs from Window.
+ const QColor background = palette.color(QPalette::Base);
+ const QColor text = palette.color(QPalette::Text);
+
+ Palette result;
+ result.background = background;
+ result.text = text;
+
+ // Both derived from the pair, so they land at the right contrast whichever
+ // way round the theme is.
+ result.dim = blend(text, background, 0.6);
+ result.border = blend(text, background, 0.25);
+
+ // The quote colour keeps its hue, since "this is quoted" is carried by the
+ // colour being different rather than by it being dimmer, but it is pulled
+ // toward the background so it stays readable on a dark theme instead of
+ // glowing.
+ const QColor quoteHue(0x4a, 0x6f, 0x8a);
+ result.quote = background.lightnessF() < 0.5
+ ? blend(quoteHue.lighter(160), background, 0.75)
+ : blend(quoteHue, background, 0.85);
+
+ return result;
+}
+
+HtmlBuilder::Palette HtmlBuilder::defaultPalette()
+{
+ if (const QGuiApplication *app =
+ qobject_cast<QGuiApplication *>(QCoreApplication::instance()))
+ return paletteFrom(app->palette());
+
+ // No GUI application: only reachable from a test that did not pass a
+ // palette. A default-constructed QPalette is light, which matches what
+ // this code did before it was themed at all.
+ return paletteFrom(QPalette());
+}
+
+QString HtmlBuilder::styleSheet(const Palette &palette)
+{
+ return QString::fromUtf8(kStyleTemplate)
+ .arg(palette.background.name(), palette.text.name(),
+ palette.quote.name(), palette.border.name(),
+ palette.dim.name());
+}
+
QString HtmlBuilder::renderPlain(const QString &text)
{
QString out;
@@ -61,12 +128,12 @@ QString HtmlBuilder::renderPlain(const QString &text)
return out;
}
-QString HtmlBuilder::document(const QString &bodyHtml)
+QString HtmlBuilder::document(const QString &bodyHtml, const Palette &palette)
{
return QStringLiteral(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
"<style>%1</style></head><body>%2</body></html>")
- .arg(QString::fromUtf8(kStyle), bodyHtml);
+ .arg(styleSheet(palette), bodyHtml);
}
QString HtmlBuilder::namespaceCids(const QString &html, const QString &prefix)
@@ -218,14 +285,26 @@ QString HtmlBuilder::renderStub(const ParsedMessage &message)
QString HtmlBuilder::build(const ParsedMessage &message, Mode mode)
{
+ return build(message, mode, defaultPalette());
+}
+
+QString HtmlBuilder::build(const ParsedMessage &message, Mode mode,
+ const Palette &palette)
+{
ThreadRenderItem item;
item.message = message;
item.expanded = true;
- return document(renderBody(item, mode));
+ return document(renderBody(item, mode), palette);
}
QString HtmlBuilder::buildThread(const QList<ThreadRenderItem> &items, Mode mode)
{
+ return buildThread(items, mode, defaultPalette());
+}
+
+QString HtmlBuilder::buildThread(const QList<ThreadRenderItem> &items, Mode mode,
+ const Palette &palette)
+{
QString body;
for (int i = 0; i < items.size(); ++i) {
@@ -246,5 +325,5 @@ QString HtmlBuilder::buildThread(const QList<ThreadRenderItem> &items, Mode mode
renderBody(item, mode));
}
- return document(body);
+ return document(body, palette);
}
diff --git a/src/htmlbuilder.h b/src/htmlbuilder.h
index 6e306dd..75fd1f8 100644
--- a/src/htmlbuilder.h
+++ b/src/htmlbuilder.h
@@ -18,7 +18,9 @@
#pragma once
+#include <QColor>
#include <QList>
+#include <QPalette>
#include "mimeparser.h"
@@ -60,11 +62,46 @@ public:
ForcePlain, ///< Always render the plain part, escaped.
};
+ /// The colours the document's own stylesheet uses.
+ ///
+ /// Passed in rather than read from qApp inside the builder, so the CSS can
+ /// be tested against a known palette without a running application, and so
+ /// nothing here depends on widget state.
+ ///
+ /// **Scope.** These style the chrome around messages and the plain-text
+ /// render. A message that brings its own HTML brings its own colours, and
+ /// those are deliberately left alone: rewriting a sender's styling would
+ /// break layouts that depend on it, and a newsletter that sets a white
+ /// background is entitled to stay white.
+ struct Palette {
+ QColor background; ///< The pane itself.
+ QColor text; ///< Body text.
+ QColor dim; ///< Headers and stubs: present but secondary.
+ QColor border; ///< Rules between messages.
+ QColor quote; ///< Quoted lines in plain text.
+ };
+
+ /// Derives the document palette from a widget palette.
+ ///
+ /// The dim and border colours are blends rather than fixed greys, which is
+ /// what makes this work on a dark theme: a hardcoded #555 that reads as
+ /// "subtle" on white is nearly invisible on near-black.
+ static Palette paletteFrom(const QPalette &palette);
+
+ /// The palette used when a caller supplies none: the running application's.
+ /// Falls back to a light default with no QApplication, which only happens
+ /// in a test that did not ask for a palette.
+ static Palette defaultPalette();
+
/// Single message, used for the error card and for tests.
static QString build(const ParsedMessage &message, Mode mode);
+ static QString build(const ParsedMessage &message, Mode mode,
+ const Palette &palette);
/// The whole thread, oldest first.
static QString buildThread(const QList<ThreadRenderItem> &items, Mode mode);
+ static QString buildThread(const QList<ThreadRenderItem> &items, Mode mode,
+ const Palette &palette);
/// Rewrites cid: URLs in an HTML body to their namespaced form.
static QString namespaceCids(const QString &html, const QString &prefix);
@@ -73,5 +110,6 @@ private:
static QString renderPlain(const QString &text);
static QString renderBody(const ThreadRenderItem &item, Mode mode);
static QString renderStub(const ParsedMessage &message);
- static QString document(const QString &bodyHtml);
+ static QString document(const QString &bodyHtml, const Palette &palette);
+ static QString styleSheet(const Palette &palette);
};
diff --git a/src/messageview.cpp b/src/messageview.cpp
index d7167bf..10b21a9 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -378,12 +378,26 @@ void MessageView::showDetailsDialog()
dialog.exec();
}
+void MessageView::changeEvent(QEvent *event)
+{
+ QWidget::changeEvent(event);
+
+ // Only when there is something to re-render: rendering an empty item list
+ // would replace a deliberately blank pane with an empty document.
+ if (event->type() == QEvent::PaletteChange && !m_items.isEmpty())
+ render();
+}
+
void MessageView::render()
{
const HtmlBuilder::Mode mode =
m_preferHtml ? HtmlBuilder::PreferHtml : HtmlBuilder::ForcePlain;
- setDocument(HtmlBuilder::buildThread(m_items, mode));
+ // This widget's palette, not the application's: a style sheet or a themed
+ // parent can give the pane different colours from qApp, and the document
+ // has to match the frame it sits in rather than the app default.
+ setDocument(HtmlBuilder::buildThread(m_items, mode,
+ HtmlBuilder::paletteFrom(palette())));
rebuildAttachmentBar();
// Blocking is discovered during load, so check shortly afterwards.
diff --git a/src/messageview.h b/src/messageview.h
index 1be17f8..c55f5c5 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -105,6 +105,14 @@ protected:
/// rather than one widget.
bool eventFilter(QObject *watched, QEvent *event) override;
+ /// Re-renders when the desktop theme changes.
+ ///
+ /// The document's colours are baked into its stylesheet at build time, so
+ /// unlike a widget it does not restyle itself: switching the desktop from
+ /// light to dark would otherwise leave the open thread on the old palette
+ /// until the next selection.
+ void changeEvent(QEvent *event) override;
+
private:
void render();
void updateHeader();
diff --git a/tests/test_htmlbuilder.cpp b/tests/test_htmlbuilder.cpp
index 04bd294..7fa45a2 100644
--- a/tests/test_htmlbuilder.cpp
+++ b/tests/test_htmlbuilder.cpp
@@ -16,6 +16,8 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
+#include <QPalette>
+#include <QRegularExpression>
#include <QSet>
#include <QtTest>
#include "cidschemehandler.h"
@@ -42,6 +44,12 @@ private slots:
void namespacesMultipleCidRefsOnOneLine();
void namespacesWhitespaceAroundEquals();
void namespacedKeyRejectsPrefixContainingSeparator();
+
+ // Theming.
+ void aDarkPaletteProducesADarkDocument();
+ void everyColourComesFromThePalette();
+ void theBodyAlwaysGetsABackground();
+ void aSendersOwnHtmlIsNotRecoloured();
};
void TestHtmlBuilder::escapesPlainText()
@@ -276,5 +284,118 @@ void TestHtmlBuilder::namespacedKeyRejectsPrefixContainingSeparator()
QVERIFY(html.contains(QStringLiteral("cid:%1").arg(expectedKey)));
}
+/// A palette with unmistakable colours, so a hardcoded value cannot pass by
+/// coincidentally resembling a real theme.
+static QPalette makeTestPalette(const QColor &window, const QColor &text)
+{
+ QPalette palette;
+ palette.setColor(QPalette::Base, window);
+ palette.setColor(QPalette::Window, window);
+ palette.setColor(QPalette::Text, text);
+ palette.setColor(QPalette::WindowText, text);
+ return palette;
+}
+
+void TestHtmlBuilder::aDarkPaletteProducesADarkDocument()
+{
+ // The whole point of the item: the CSS was hardcoded light, so a user on a
+ // dark desktop read plain-text mail as black on white inside a dark window.
+ ParsedMessage msg;
+ msg.plainBody = QStringLiteral("hello");
+
+ const HtmlBuilder::Palette dark = HtmlBuilder::paletteFrom(
+ makeTestPalette(QColor(0x12, 0x34, 0x56), QColor(0xab, 0xcd, 0xef)));
+
+ const QString html =
+ HtmlBuilder::build(msg, HtmlBuilder::ForcePlain, dark);
+
+ QVERIFY2(html.contains(QStringLiteral("#123456")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("#abcdef")), qPrintable(html));
+}
+
+void TestHtmlBuilder::everyColourComesFromThePalette()
+{
+ // A single leftover literal is the whole defect, and it survives a test
+ // that only checks the palette colours are present. Assert the negative:
+ // no hex colour appears that the palette did not put there.
+ ParsedMessage msg;
+ msg.plainBody = QStringLiteral("> quoted\nplain");
+
+ const HtmlBuilder::Palette dark = HtmlBuilder::paletteFrom(
+ makeTestPalette(QColor(0x12, 0x34, 0x56), QColor(0xab, 0xcd, 0xef)));
+ const QString html =
+ HtmlBuilder::build(msg, HtmlBuilder::ForcePlain, dark);
+
+ // Only the <style> block: a sender's own HTML is not ours to police, and
+ // the body of this message carries no colours anyway.
+ const qsizetype start = html.indexOf(QStringLiteral("<style>"));
+ const qsizetype end = html.indexOf(QStringLiteral("</style>"));
+ QVERIFY(start >= 0 && end > start);
+ const QString style = html.mid(start, end - start);
+
+ static const QRegularExpression hex(QStringLiteral("#[0-9a-fA-F]{3,8}\\b"));
+ auto it = hex.globalMatch(style);
+ QSet<QString> found;
+ while (it.hasNext())
+ found.insert(it.next().captured(0).toLower());
+
+ // Every colour in the stylesheet must be one the palette supplied. The
+ // derived ones (a border, a dimmed label) are blends of those, so they are
+ // listed by the builder rather than being free-floating literals.
+ const QSet<QString> allowed = {
+ dark.background.name().toLower(), dark.text.name().toLower(),
+ dark.dim.name().toLower(), dark.border.name().toLower(),
+ dark.quote.name().toLower(),
+ };
+
+ for (const QString &colour : found) {
+ QVERIFY2(allowed.contains(colour),
+ qPrintable(QStringLiteral("stylesheet carries '%1', which the "
+ "palette did not supply: a "
+ "hardcoded colour survives")
+ .arg(colour)));
+ }
+}
+
+void TestHtmlBuilder::theBodyAlwaysGetsABackground()
+{
+ // The original CSS set no background at all, which is why the pane was
+ // white: the web view's default showed through regardless of the desktop.
+ ParsedMessage msg;
+ msg.plainBody = QStringLiteral("hello");
+
+ const HtmlBuilder::Palette dark = HtmlBuilder::paletteFrom(
+ makeTestPalette(QColor(0x12, 0x34, 0x56), QColor(0xab, 0xcd, 0xef)));
+ const QString html =
+ HtmlBuilder::build(msg, HtmlBuilder::ForcePlain, dark);
+
+ const qsizetype start = html.indexOf(QStringLiteral("<style>"));
+ const qsizetype end = html.indexOf(QStringLiteral("</style>"));
+ const QString style = html.mid(start, end - start);
+
+ static const QRegularExpression bodyRule(
+ QStringLiteral("body\\s*\\{[^}]*background[^}]*\\}"));
+ QVERIFY2(bodyRule.match(style).hasMatch(),
+ qPrintable(QStringLiteral("body has no background rule:\n") + style));
+}
+
+void TestHtmlBuilder::aSendersOwnHtmlIsNotRecoloured()
+{
+ // Scope, asserted so it does not drift: an HTML message brings its own
+ // styling and this change must not start rewriting it. A newsletter that
+ // sets its own white background stays white, and that is correct.
+ ParsedMessage msg;
+ msg.htmlBody = QStringLiteral(
+ "<div style=\"background:#ffffff;color:#000000\">hi</div>");
+
+ const HtmlBuilder::Palette dark = HtmlBuilder::paletteFrom(
+ makeTestPalette(QColor(0x12, 0x34, 0x56), QColor(0xab, 0xcd, 0xef)));
+ const QString html =
+ HtmlBuilder::build(msg, HtmlBuilder::PreferHtml, dark);
+
+ QVERIFY(html.contains(QStringLiteral("background:#ffffff")));
+ QVERIFY(html.contains(QStringLiteral("color:#000000")));
+}
+
QTEST_MAIN(TestHtmlBuilder)
#include "test_htmlbuilder.moc"