aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md2
-rw-r--r--src/messageview.cpp77
-rw-r--r--src/messageview.h7
-rw-r--r--tests/test_messageview.cpp76
4 files changed, 146 insertions, 16 deletions
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 ba0293f..e9d04d4 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
@@ -220,7 +220,7 @@ taking that too literally.
| 148 | Ctrl+W does not close the composer | discoverability | XS | open, 2026-08-23, from the notes. Verified: nothing binds `Ctrl+W` anywhere, and the composer has no close action of its own. Belongs with item 21's table rather than bound in isolation |
| 149 | A reply's cursor lands on the attribution line, not on blank space | defect | XS | **done** 2026-08-24, unreleased. `quote_position` names where the QUOTE goes, so the cursor follows the reply and not the buffer: `End` under Above, `Start` under Below, where a shared `Start` had put it on the `On ... wrote:` line. The existing `theQuotePositionDecidesWhereTheQuoteLands` passed throughout, because the quote WAS in the right place |
| 150 | The receive-only ribbon stays up after the message that raised it is gone | defect | S | **done** 2026-08-24, unreleased. One line in `MessageView::clear()`, beside the blocked-content bar, the stale notice and the attachment bar it already reset by hand. Only `setReceiveOnlyAccount()` hid the ribbon, which every SELECTION change reaches, so a row-to-row move was never the reproducer: it survived the FOUR routes that blank the pane without one (`clear_pane`, `clear_selection`, a new query, a multi-row selection). The first test written for it passed against the defect for exactly that reason |
-| 151 | The message-pane bars blend into the UI and carry no severity | presentation | S | open, 2026-08-24, from the notes. The user's design: a yellow ground for warnings (the item 150 ribbon), a blue one for actions (remote content blocked), with the action's button right-aligned. Both are plain `QLabel`s today. Colour alone is not enough per CLAUDE.md's palette rule, and the pane is theme-aware, so the two grounds need light and dark values rather than one literal |
+| 151 | The message-pane bars blend into the UI and carry no severity | presentation | S | **done** 2026-08-24, unreleased. Two severities as the user asked: yellow for a warning that only explains (the receive-only ribbon), blue for one offering an action (remote content blocked, stale thread), each with its own light and dark set read off `QPalette::Base` as `HtmlBuilder` does. The blocked row had to become a WIDGET first: it was a bare `QHBoxLayout`, which has nothing to paint a ground on, and its six `hide()` sites then had to move to the wrapper or a painted empty strip would show. Both action bars put the button right of a stretch |
| 152 | Signatures are not managed at all | v2 | ? | open, 2026-08-24, from the notes, asked for as a brainstorm rather than a build. Nothing in `[compose]` or `[account.*]` mentions a signature, so this is unspecified: per-account text, where it sits relative to the quote, and whether the HTML part gets its own form are all open. Needs the user to say what they picture before it can be sized |
Sizes are rough: XS under an hour, S a sitting, M a session.
diff --git a/src/messageview.cpp b/src/messageview.cpp
index aa887ae..22df6ce 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -370,10 +370,17 @@ MessageView::MessageView(QWidget *parent)
connect(m_loadRemoteButton, &QPushButton::clicked,
this, &MessageView::loadRemoteContent);
- auto *blockedRow = new QHBoxLayout;
+ // A WIDGET rather than a bare layout, because a layout has nothing to
+ // paint a ground on and this bar now carries one.
+ m_blockedBar = new QWidget(this);
+ m_blockedBar->setObjectName(QStringLiteral("blockedContentBar"));
+ auto *blockedRow = new QHBoxLayout(m_blockedBar);
blockedRow->addWidget(m_blockedLabel);
- blockedRow->addWidget(m_loadRemoteButton);
+ // The stretch BEFORE the button, so the thing to act on sits at the right
+ // edge where the eye ends up after reading the sentence.
blockedRow->addStretch();
+ blockedRow->addWidget(m_loadRemoteButton);
+ m_blockedBar->hide();
// The stale-thread notice, deliberately the same shape as the row above:
// a sentence and a button, above the message, leaving it readable. The
@@ -410,10 +417,9 @@ MessageView::MessageView(QWidget *parent)
emit staleThreadRecoveryRequested(threadId, messageId);
});
auto *staleRow = new QHBoxLayout(m_staleBar);
- staleRow->setContentsMargins(0, 0, 0, 0);
staleRow->addWidget(m_staleLabel);
- staleRow->addWidget(m_staleButton);
staleRow->addStretch();
+ staleRow->addWidget(m_staleButton);
m_staleBar->hide();
// Receive-only ribbon (item 123). Hidden until a message from an account
@@ -452,13 +458,15 @@ MessageView::MessageView(QWidget *parent)
auto *layout = new QVBoxLayout(this);
layout->addLayout(headerRow);
- layout->addLayout(blockedRow);
+ layout->addWidget(m_blockedBar);
layout->addWidget(m_receiveOnlyRibbon);
layout->addWidget(m_staleBar);
layout->addWidget(m_view, 1);
layout->addWidget(m_attachmentBar);
layout->addWidget(m_tagStrip);
+ applyNoticeBarStyles();
+
clear();
}
@@ -500,8 +508,7 @@ void MessageView::showPlaceholder(
m_headerLabel->clear();
m_detailsButton->hide();
- m_blockedLabel->hide();
- m_loadRemoteButton->hide();
+ m_blockedBar->hide();
rebuildAttachmentBar();
// Set before the document loads, not after: acceptNavigationRequest reads
@@ -517,6 +524,48 @@ void MessageView::showPlaceholder(
HtmlBuilder::brandPaletteFrom(palette())));
}
+void MessageView::applyNoticeBarStyles()
+{
+ // QPalette::Base, the same surface HtmlBuilder reads, so a bar and the
+ // message under it never disagree about which way round the theme is.
+ const bool dark = palette().color(QPalette::Base).lightnessF() < 0.5;
+
+ // Yellow for a warning, blue for an action, as the user asked. The dark
+ // values are not the light ones dimmed: the same nominal tint behaves
+ // differently against near-black, so each set carries its own ground,
+ // border and text, and every ground states its text colour rather than
+ // inheriting one that may be near-white on a pale tint.
+ const QString warningGround = dark ? QStringLiteral("#3a2f0b")
+ : QStringLiteral("#fdf6d8");
+ const QString warningBorder = dark ? QStringLiteral("#6b5a15")
+ : QStringLiteral("#e3d08a");
+ const QString warningText = dark ? QStringLiteral("#f0e2a8")
+ : QStringLiteral("#4a3c05");
+
+ const QString actionGround = dark ? QStringLiteral("#0e2740")
+ : QStringLiteral("#e3f0fb");
+ const QString actionBorder = dark ? QStringLiteral("#1d4a70")
+ : QStringLiteral("#a8cbe8");
+ const QString actionText = dark ? QStringLiteral("#cfe4f7")
+ : QStringLiteral("#0d3355");
+
+ const QString sheet = QStringLiteral(
+ "QWidget#%1 { background: %2; border: 1px solid %3; "
+ "border-radius: 4px; } QWidget#%1 QLabel { color: %4; }");
+
+ m_receiveOnlyRibbon->setStyleSheet(
+ QStringLiteral("QLabel#receiveOnlyRibbon { background: %1; "
+ "border: 1px solid %2; border-radius: 4px; "
+ "color: %3; padding: 6px 8px; }")
+ .arg(warningGround, warningBorder, warningText));
+
+ for (QWidget *bar : { m_blockedBar, m_staleBar }) {
+ bar->setStyleSheet(
+ sheet.arg(bar->objectName(), actionGround, actionBorder,
+ actionText));
+ }
+}
+
void MessageView::clear()
{
m_items.clear();
@@ -531,8 +580,7 @@ void MessageView::clear()
setDocument(QString());
m_headerLabel->clear();
- m_blockedLabel->hide();
- m_loadRemoteButton->hide();
+ m_blockedBar->hide();
// The stale notice describes the message that WAS rendered, so it goes with
// it, for the same reason as the blocked-content bar above. Left behind, it
@@ -604,8 +652,7 @@ void MessageView::showError(const QString &text, const QString &filePath)
m_interceptor->resetForNewMessage();
m_headerLabel->setText(tr("<b>Cannot display message</b>"));
- m_blockedLabel->hide();
- m_loadRemoteButton->hide();
+ m_blockedBar->hide();
const QString html = QStringLiteral(
"<html><body><p>%1</p><p><code>%2</code></p></body></html>")
@@ -1073,8 +1120,9 @@ void MessageView::render()
QTimer::singleShot(300, this, [this]() {
const bool blocked = m_interceptor->blockedAnything()
&& !m_interceptor->allowRemote();
- m_blockedLabel->setVisible(blocked);
- m_loadRemoteButton->setVisible(blocked);
+ // The BAR, not its children: the wrapper carries the ground, so
+ // hiding only the label and button would leave a painted empty strip.
+ m_blockedBar->setVisible(blocked);
});
}
@@ -1379,7 +1427,6 @@ void MessageView::loadRemoteContent()
{
// Applies to this thread only and is cleared by the next showThread().
m_interceptor->setAllowRemote(true);
- m_blockedLabel->hide();
- m_loadRemoteButton->hide();
+ m_blockedBar->hide();
render();
}
diff --git a/src/messageview.h b/src/messageview.h
index 044bded..29101d6 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -334,6 +334,12 @@ private:
/// thread with fifteen of them.
void rebuildAttachmentBar();
+ /// Paints the three notice bars: yellow for a warning that
+ /// explains a limitation, blue for one offering an action.
+ /// Reads QPalette::Base, as HtmlBuilder does, so the bars and
+ /// the message agree about the theme.
+ void applyNoticeBarStyles();
+
/// The list of attachments, with a save button each and a "save all".
void showAttachmentDialog();
@@ -399,6 +405,7 @@ private:
CidSchemeHandler *m_cidHandler = nullptr;
QLabel *m_headerLabel = nullptr;
+ QWidget *m_blockedBar = nullptr;
QLabel *m_blockedLabel = nullptr;
QLabel *m_receiveOnlyRibbon = nullptr;
QPushButton *m_loadRemoteButton = nullptr;
diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp
index c0c9bc2..f9268ef 100644
--- a/tests/test_messageview.cpp
+++ b/tests/test_messageview.cpp
@@ -16,9 +16,11 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
+#include <QHBoxLayout>
#include <QLabel>
#include <QMenu>
#include <QPushButton>
+#include <QRegularExpression>
#include <QSet>
#include <QSignalSpy>
#include <QWebEnginePage>
@@ -66,6 +68,7 @@ private slots:
void aPlainLinkOpensExternally();
void aTargetBlankLinkOpensExternally();
void theLinkMenuDropsTheOpenInWindowActions();
+ void theNoticeBarsCarryTheirSeverityAndFollowTheTheme();
private:
QWebEngineView *webViewOf(MessageView *view) const
@@ -1165,5 +1168,78 @@ void TestMessageView::theLinkMenuDropsTheOpenInWindowActions()
}
}
+void TestMessageView::theNoticeBarsCarryTheirSeverityAndFollowTheTheme()
+{
+ // The bars are the pane's only out-of-band messages, and they read as part
+ // of the page until they carry a ground of their own. Two severities: a
+ // warning that explains a limitation and offers nothing to do about it,
+ // and an action the user can take.
+ MessageView view;
+
+ auto *warning =
+ view.findChild<QWidget *>(QStringLiteral("receiveOnlyRibbon"));
+ auto *blocked =
+ view.findChild<QWidget *>(QStringLiteral("blockedContentBar"));
+ auto *stale = view.findChild<QWidget *>(QStringLiteral("staleThreadBar"));
+ QVERIFY2(warning && blocked && stale, "a notice bar is missing");
+
+ // Asserting on the stylesheet STRING rather than on a rendered pixel, for
+ // the reason CLAUDE.md records about rendering probes: an unshown widget
+ // under the offscreen platform renders nothing, so a pixel test here would
+ // pass whatever the code does.
+ const QString warningSheet = warning->styleSheet();
+ const QString blockedSheet = blocked->styleSheet();
+ const QString staleSheet = stale->styleSheet();
+
+ for (const QString &sheet : { warningSheet, blockedSheet, staleSheet }) {
+ QVERIFY2(sheet.contains(QStringLiteral("background")),
+ qPrintable(QStringLiteral("a bar paints no ground: %1")
+ .arg(sheet)));
+ }
+
+ // The two severities must not look alike, which is the whole point: a
+ // warning and an action reading identically is the state being fixed.
+ QVERIFY2(warningSheet != blockedSheet,
+ "the warning and the action bar share one appearance");
+
+ // Both action bars are the same severity and must agree on their COLOURS.
+ // Not on the whole sheet: each names its own widget, so the strings differ
+ // by that alone. Compare what carries the severity instead.
+ const QRegularExpression hex(QStringLiteral("#[0-9a-fA-F]{6}"));
+ const auto coloursOf = [&hex](const QString &sheet) {
+ QStringList found;
+ auto it = hex.globalMatch(sheet);
+ while (it.hasNext())
+ found << it.next().captured(0).toLower();
+ return found;
+ };
+ QVERIFY2(!coloursOf(blockedSheet).isEmpty(),
+ "the action bar names no colours at all");
+ QCOMPARE(coloursOf(blockedSheet), coloursOf(staleSheet));
+
+ // And the warning genuinely differs in colour, not merely in widget name.
+ QVERIFY2(coloursOf(warningSheet) != coloursOf(blockedSheet),
+ "the warning and the action bar use the same colours");
+
+ // The action bars put their button on the RIGHT. addStretch() at the end
+ // of the row left-aligns it, which is what both did.
+ for (QWidget *bar : { blocked, stale }) {
+ auto *row = qobject_cast<QHBoxLayout *>(bar->layout());
+ QVERIFY2(row, "an action bar has no horizontal row");
+ int lastWidget = -1;
+ int lastStretch = -1;
+ for (int i = 0; i < row->count(); ++i) {
+ if (row->itemAt(i)->widget())
+ lastWidget = i;
+ else if (row->itemAt(i)->spacerItem())
+ lastStretch = i;
+ }
+ QVERIFY2(lastStretch >= 0 && lastWidget >= 0,
+ "an action bar has no stretch, so nothing is aligned");
+ QVERIFY2(lastStretch < lastWidget,
+ "the stretch comes after the button, left-aligning it");
+ }
+}
+
QTEST_MAIN(TestMessageView)
#include "test_messageview.moc"