aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-08-09-card-list.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-09 12:43:58 +0200
committerDanilo M. <danix@danix.xyz>2026-08-09 12:43:58 +0200
commit23d07f07562adbb349f7fe8b97e4f4e9ea6c6ebd (patch)
treebd160e61cbd78f96c1a06e73754ec945d3d4ae59 /docs/superpowers/plans/2026-08-09-card-list.md
parentd990f7c6775b4b921c6d6d8d539c7018279346be (diff)
downloadqtmaildir-23d07f07562adbb349f7fe8b97e4f4e9ea6c6ebd.tar.gz
qtmaildir-23d07f07562adbb349f7fe8b97e4f4e9ea6c6ebd.zip
docs: make the account colour the card's accent, not a chip
The plan left the account chip unspecified, because where it sits on a card was never decided and inventing a place would have been a guess. The user's answer replaces it rather than placing it: a coloured bar down the card's left edge, the reply spines inheriting that colour, and matching swatches in the account dropdown. It is a net simplification. The chip ate a third of line 2 on every card to repeat a name the user already knows, which is the table-of-records texture item 53 is about; the bar says the same thing in a few pixels and leaves line 2 to the subject. Three details that are easy to get wrong and are specified rather than left to the implementer: - The raw account colour is never drawn as a line. It is chosen to be a chip's FILL, with text drawn on top in whatever stays legible against it. A thin line on the pane's own background has a different job: followable down a long expansion without competing with the senders beside it. The accent blends toward QPalette::Base by the same 0.35 weight threadLineColour() already uses, keeping the hue and dropping the shout. The dropdown swatch does use the raw colour, being a filled patch rather than a line. - A reply resolves its THREAD's colour by walking to the root. AccountColourRole is empty on a message row, so a spine reading its own index would fall back to the neutral line under an accented root and break the continuous edge the design is built on. - Reply cards carry no bar of their own. Two vertical lines a few pixels apart in one gutter is what option B looked like, and the spine already carries the accent. colourFor() never failing is kept deliberately: an account with no colour= key gets a stable colour derived from its tag name, so adding an account and forgetting to colour it degrades to something usable rather than to nothing. kAccentWidth ships at 3px as a starting value. Whether five accounts are tellable apart at that width, on this user's screen and theme, is not decidable from a mockup or a test, so Task 10 gains a step that settles it against real cards, in both themes, with the guidance to widen the bar before touching the user's own colour choices.
Diffstat (limited to 'docs/superpowers/plans/2026-08-09-card-list.md')
-rw-r--r--docs/superpowers/plans/2026-08-09-card-list.md240
1 files changed, 225 insertions, 15 deletions
diff --git a/docs/superpowers/plans/2026-08-09-card-list.md b/docs/superpowers/plans/2026-08-09-card-list.md
index 582d8b9..4ea9a4b 100644
--- a/docs/superpowers/plans/2026-08-09-card-list.md
+++ b/docs/superpowers/plans/2026-08-09-card-list.md
@@ -679,6 +679,8 @@ private slots:
void expanderSitsOnTheSecondLine();
void expanderIsEmptyWithoutReplies();
void dateIsFlushRight();
+ void threadCardCarriesAnAccentBar();
+ void replyCardCarriesNoAccentBar();
};
namespace {
@@ -840,6 +842,37 @@ void TestCardLayout::dateIsFlushRight()
QVERIFY(card.senderRect.right() <= card.dateRect.left());
}
+void TestCardLayout::threadCardCarriesAnAccentBar()
+{
+ const QFont font;
+ const int h = CardLayout::heightFor(font);
+ const QRect rect(0, 0, 400, h);
+ const CardLayout card = CardLayout::compute(threadInput(), rect, font);
+
+ QCOMPARE(card.accentRect.left(), rect.left());
+ QCOMPARE(card.accentRect.width(), CardLayout::kAccentWidth);
+ // Full height, so a run of cards from one account reads as a continuous
+ // edge rather than as dashes.
+ QCOMPARE(card.accentRect.top(), rect.top());
+ QCOMPARE(card.accentRect.bottom(), rect.bottom());
+
+ // Nothing may be drawn on top of the colour.
+ QVERIFY(card.contentLeft >= card.accentRect.right());
+}
+
+void TestCardLayout::replyCardCarriesNoAccentBar()
+{
+ const QFont font;
+ const int h = CardLayout::heightFor(font);
+ const CardLayout reply =
+ CardLayout::compute(replyInput(1), QRect(0, 0, 400, h), font);
+
+ // A reply's account is its thread's, stated once at the head. The spine
+ // carries the accent instead, so the gutter never holds two lines.
+ QVERIFY(reply.accentRect.isEmpty());
+ QCOMPARE(reply.spines.size(), 1);
+}
+
QTEST_MAIN(TestCardLayout)
#include "test_cardlayout.moc"
```
@@ -889,7 +922,16 @@ struct CardLayout
int replyCount = 0; ///< 0 means no expander.
};
- /// Horizontal breathing room at the card's edges.
+ /// Width of the account accent bar down a thread card's left edge.
+ ///
+ /// A starting value, not a settled one. Five accounts is enough that two
+ /// colours distinct as chips can read alike as thin stripes, and that can
+ /// only be judged against real cards on the user's own screen and theme
+ /// (Task 10). Widen it there if the accounts are not tellable apart.
+ static constexpr int kAccentWidth = 3;
+
+ /// Horizontal breathing room at the card's edges, measured from the accent
+ /// bar rather than from the card, so text does not sit on the colour.
static constexpr int kPaddingX = 8;
/// Vertical breathing room above the first line and below the last.
@@ -916,6 +958,15 @@ struct CardLayout
/// Empty when the row has no replies.
QRect expanderRect;
+ /// The account accent bar down the card's left edge.
+ ///
+ /// Thread cards only. A reply's account is its thread's, stated once at the
+ /// head of the conversation, and a second vertical line in a reply's gutter
+ /// would sit a few pixels from the spine and compete with it. The spine
+ /// carries the accent instead, so an expansion is bounded by one colour
+ /// without ever drawing two lines. Empty on a reply.
+ QRect accentRect;
+
/// One full-height vertical line per depth level, outermost first.
QVector<QRect> spines;
@@ -979,16 +1030,24 @@ CardLayout CardLayout::compute(const Input &input, const QRect &rect,
out.totalHeight = rect.height();
+ // The accent bar sits flush against the card's left edge, on thread cards
+ // only, and everything else starts after it so no text sits on the colour.
+ if (!input.isMessage) {
+ out.accentRect =
+ QRect(rect.left(), rect.top(), kAccentWidth, rect.height());
+ }
+ const int textLeft = rect.left() + kAccentWidth;
+
// Indent, capped. qMin rather than a branch so depth 5 and depth 50 land
// in exactly the same place.
const int depth = qMin(input.depth, kMaxDepth);
const int indent = depth * kIndentStep;
- out.contentLeft = rect.left() + kPaddingX + indent;
+ out.contentLeft = textLeft + kPaddingX + indent;
// One spine per level actually indented, each running the card's full
// height so an expansion reads as one continuous block.
for (int level = 0; level < depth; ++level) {
- const int x = rect.left() + kPaddingX + level * kIndentStep
+ const int x = textLeft + kPaddingX + level * kIndentStep
+ kIndentStep / 2;
out.spines.append(QRect(x, rect.top(), 2, rect.height()));
}
@@ -1127,6 +1186,20 @@ public:
/// click, because a delegate gets no click of its own without an editor.
static QRect expanderRectFor(const QStyleOptionViewItem &option,
const QModelIndex &index);
+
+ /// An account's colour as a thin LINE rather than as a chip's fill.
+ ///
+ /// Never use the raw account colour for the accent bar or the spine. That
+ /// colour is chosen to be a background with legible text drawn on top
+ /// (TagColors::textColourOn picks black or white against it). The same
+ /// colour as a few pixels of line on the pane's own background is a
+ /// different problem: it has to be followable down a long expansion
+ /// WITHOUT competing with the senders beside it, which is the constraint
+ /// threadLineColour() states and meets by blending 0.35 toward the
+ /// palette's text. This blends the account colour toward the palette's
+ /// Base by the same weight, keeping the hue that identifies the account
+ /// and dropping the saturation that would shout.
+ static QColor accentLineColour(const QColor &accountColour);
};
```
@@ -1165,6 +1238,22 @@ QRect CardDelegate::expanderRectFor(const QStyleOptionViewItem &option,
.expanderRect;
}
+QColor CardDelegate::accentLineColour(const QColor &accountColour)
+{
+ if (!accountColour.isValid())
+ return ThreadListModel::threadLineColour();
+
+ // The same 0.35 weight threadLineColour() uses, toward Base rather than
+ // toward Text, so the two kinds of line sit at the same visual strength.
+ const QColor base = QGuiApplication::palette().color(QPalette::Base);
+ constexpr qreal kWeight = 0.35;
+ const qreal inverse = 1.0 - kWeight;
+ return QColor::fromRgbF(
+ accountColour.redF() * kWeight + base.redF() * inverse,
+ accountColour.greenF() * kWeight + base.greenF() * inverse,
+ accountColour.blueF() * kWeight + base.blueF() * inverse);
+}
+
QSize CardDelegate::sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const
{
@@ -1191,9 +1280,30 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
painter->save();
- // Spines, under everything else.
+ // The account's colour, for both the accent bar and the spines.
+ //
+ // A reply must resolve its THREAD's colour, not its own: AccountColourRole
+ // is empty on a message row, and a spine that fell back to the neutral
+ // line under an accented root would break the one continuous edge this
+ // design is built on. index.parent() is the thread for a depth-1 reply and
+ // the containing subtree for a deeper one, so walk to the root.
+ QModelIndex root = index;
+ while (root.parent().isValid())
+ root = root.parent();
+ const QColor accountColour =
+ root.data(ThreadListModel::AccountColourRole).value<QColor>();
+ const QColor lineColour = accentLineColour(accountColour);
+
+ // The accent bar, thread cards only. Drawn after the chrome so the
+ // selection highlight cannot cover it: which account a card belongs to
+ // must stay readable on the row the user is looking at.
+ if (!card.accentRect.isEmpty())
+ painter->fillRect(card.accentRect, lineColour);
+
+ // Spines, under everything else, in the same accent so an expanded thread
+ // is bounded by one colour from its root to its last reply.
for (const QRect &spine : card.spines)
- painter->fillRect(spine, ThreadListModel::threadLineColour());
+ painter->fillRect(spine, lineColour);
// Selection outranks the model's foreground, and the order matters: a read
// card carries a dimmed colour blended against the UNSELECTED background,
@@ -1288,7 +1398,8 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
}
```
-Add `#include <QRegularExpression>` to the includes.
+Add `#include <QRegularExpression>` and `#include <QGuiApplication>` to the
+includes.
- [ ] **Step 4: Delete `SubjectDelegate`**
@@ -1326,6 +1437,16 @@ A reply's Re: prefix is stripped here. Every reply repeating the thread's
subject is the visual signature of a table of records, which is the thing item
53 is about.
+The account chip becomes a bar down the card's left edge, and the reply spines
+inherit its colour, so an expanded thread is bounded by one accent from its root
+to its last reply without a second line in the gutter. Neither uses the raw
+account colour: that colour is chosen to be a chip's fill with legible text on
+top, and the same value as a thin line has to be followable down an expansion
+without competing with the senders, so it is blended toward the palette's Base
+by the weight threadLineColour() already uses. A reply resolves its THREAD's
+colour by walking to the root, since AccountColourRole is empty on a message
+row and a neutral spine under an accented root would break the continuous edge.
+
The build is red at this commit; the view and window still name the old
delegate."
```
@@ -1791,7 +1912,62 @@ cmake --build build && ./build/tests/test_mainwindow
Expected: PASS.
-- [ ] **Step 7: Commit**
+- [ ] **Step 7: Colour the account dropdown's entries**
+
+The accent bar on a card says nothing until something maps a colour to an
+account name, and the dropdown is where the user already goes to think about
+accounts. In `src/mainwindow.cpp:399`, the loop that fills `m_accountBox`:
+
+```cpp
+ m_accountBox->addItem(tr("All accounts"), QString());
+ for (const Account &account : m_config.accounts()) {
+ m_accountBox->addItem(account.key, account.key);
+ // The RAW account colour here, not the blended line colour: a swatch
+ // is a filled patch like a chip, not a thin line, so it wants the
+ // colour the account was actually given. Qt renders a DecorationRole
+ // colour as a swatch itself, with no delegate.
+ m_accountBox->setItemData(
+ m_accountBox->count() - 1,
+ m_tagColors.colourFor(TagColors::tagForAccountKey(account.key)),
+ Qt::DecorationRole);
+ }
+```
+
+`colourFor` never fails: an account with no `color=` key gets a stable colour
+derived from its tag name, which is deliberate. Adding an account and
+forgetting to colour it degrades to something usable rather than to nothing.
+
+Check the member's name first: `grep -n "TagColors m_\|m_tagColors"
+src/mainwindow.h`.
+
+- [ ] **Step 8: Test it**
+
+```cpp
+void TestMainWindow::accountEntriesCarryTheirColour()
+{
+ MainWindow window;
+ auto *box = window.findChild<QComboBox *>(QStringLiteral("accountBox"));
+ QVERIFY(box);
+
+ // "All accounts" is not an account and carries no swatch.
+ QVERIFY(!box->itemData(0, Qt::DecorationRole).isValid());
+
+ // Every real account does. Skipped rather than failed when the test
+ // environment has no accounts configured, since this reads real config.
+ if (box->count() < 2)
+ QSKIP("no accounts configured in this environment");
+ for (int i = 1; i < box->count(); ++i) {
+ const QVariant swatch = box->itemData(i, Qt::DecorationRole);
+ QVERIFY(swatch.isValid());
+ QVERIFY(swatch.value<QColor>().isValid());
+ }
+}
+```
+
+`m_accountBox` needs `setObjectName(QStringLiteral("accountBox"))` if it does
+not have one.
+
+- [ ] **Step 9: Commit**
```bash
git add src/mainwindow.cpp src/mainwindow.h tests/test_mainwindow.cpp
@@ -1802,7 +1978,13 @@ the column header was decorative and nothing implemented click-to-sort, so
removing the header with the grid lost nothing.
Stored in uistate.conf, never in the hand-edited config, and range-guarded on
-read: a stale file can hold anything, which is the lesson item 58 recorded."
+read: a stale file can hold anything, which is the lesson item 58 recorded.
+
+The account dropdown's entries now carry their account's colour as a swatch,
+which is what makes the accent bar on a card mean anything: a colour down a
+card's edge says nothing until something maps it to a name. Raw colour here
+rather than the blended line colour, since a swatch is a filled patch like a
+chip rather than a thin line."
```
---
@@ -1856,6 +2038,10 @@ Under `## [Unreleased]`, in `### Changed`:
carrying only the tags the thread itself does not have.
- Threads can be listed newest or oldest first, from a new control beside the
query bar. The choice is remembered.
+- An account's colour now runs down the left edge of its threads, and down the
+ spine of their replies, replacing the account chip that used to sit in front
+ of every subject. The account dropdown shows the same colours, so which
+ colour means which account is readable in one place.
```
In `### Fixed`:
@@ -1922,6 +2108,24 @@ the original bug was the desktop's own font being configured Bold in qt6ct.
- The sort dropdown reverses the list, and the choice survives a restart.
- The list does not scroll sideways on any click, at any window width.
+- [ ] **Step 2b: Settle the accent width, which cannot be settled from a mockup**
+
+`kAccentWidth` ships at 3px as a starting value. On a query spanning all five
+accounts, check:
+
+- **Are the five tellable apart?** Two colours distinct as chips can read alike
+ as thin stripes. If not, widen `kAccentWidth` before reaching for different
+ colours: the config's colours are the user's own choice and changing them is
+ their call, not the delegate's.
+- **Does the spine still read as structure rather than as decoration?** It is
+ now accent-coloured, and the blend was chosen to match `threadLineColour()`'s
+ strength. If a bright account makes its expansion shout, raise the blend
+ weight rather than special-casing that colour.
+- **Does the accent survive selection?** It is painted after the chrome
+ deliberately, so it should stay visible on the highlighted row. Confirm.
+- **Check both themes.** The blend goes toward `QPalette::Base`, so it inverts
+ with the theme; a value that looks right on dark can vanish on light.
+
- [ ] **Step 3: Check the theme you do not use**
Switch the desktop between light and dark and confirm the spine, the chips and
@@ -1943,13 +2147,19 @@ reply line 3 (1), the spine and indent cap (4), the expander (5, 6), sorting
green), the new roles (1, 2), testing (each task), keyboard navigation (7), and
returning to a whole thread (nothing to build, it is the root card).
-**Known gap, deliberately left.** The spec says `CardDelegate` should draw the
-account chip, and no task does. It is an addition to Task 5's `paint`, on line
-1 in front of the sender, using `AccountLabelRole` and `AccountColourRole`
-exactly as `SubjectDelegate` did. It is left out because the layout for it was
-not specified and inventing one here would be a guess; add it as Task 5b after
-seeing the cards on screen, or leave the chip out if the account is already
-clear from the sender.
+**The account chip gap is closed.** An earlier draft of this plan left the
+account chip unhandled, since its placement on a card was never specified. The
+user's answer was better than a chip: a coloured bar down the card's left edge,
+with the reply spines inheriting the same colour, and matching swatches in the
+account dropdown. That is in Task 4 (geometry), Task 5 (painting and the blend)
+and Task 8 (the dropdown). The chip itself is gone rather than relocated, which
+is a net simplification: it used to eat a third of line 2 to repeat a name the
+user already knows.
+
+**One judgement is deliberately deferred to Task 10.** `kAccentWidth` ships at
+3px, and whether five accounts are tellable apart at that width on the user's
+own screen and theme cannot be decided from a mockup or a test. Step 2b of Task
+10 is where it gets settled.
**Ordering risk.** Tasks 2, 5 and 6 leave the build red between commits. That
is deliberate, since splitting a column removal across three files cannot be