summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md8
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md46
-rw-r--r--src/mainwindow.cpp5
-rw-r--r--tests/test_mainwindow.cpp53
4 files changed, 111 insertions, 1 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index da1dcba..7f42995 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,14 @@ point at which they are stable.
## [Unreleased]
+### Fixed
+
+- **Archive and Mark all read no longer share an icon.** Both used the same one
+ in 0.12.0, which was harmless while the toolbar showed text beside every icon
+ and ambiguous once it follows a desktop set to icon-only. Archive now uses a
+ distinct icon, and a test compares every action's icon against every other so
+ the next duplicate fails the build rather than shipping.
+
## [0.12.0] - 2026-08-09
Syncing gets narrower and more honest: a sync fetches only the accounts you
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 ba0ade5..f559bf1 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
@@ -116,6 +116,7 @@ taking that too literally.
| 56 | No action carries an icon, so the toolbar reserves space for nothing | presentation | S | **done** |
| 57 | "Flag" would read better as "Important" or "Starred" | presentation | XS | **done** |
| 58 | `message_zoom` documents a 0.5 to 3.0 range and enforces none of it | correctness | XS | open |
+| 59 | Archive and Mark all read shipped with the same icon | presentation | XS | **done** |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -3576,6 +3577,51 @@ numbers.
`messageZoomDefaultsAndValidates` exists and passes today, so it is asserting
only on the parse and not on the range.
+## 59. Archive and Mark all read shipped with the same icon
+
+**Observed (user, 2026-08-09), against 0.12.0:** "Archive" and "Mark all read"
+share the same icon, and in an icon-only setup they are not distinguishable.
+
+**Cause.** Introduced by item 56, in this session. The `themeIcons` table gave
+`archive` the name `mail-mark-read` (it predates item 56, from when only eight
+actions had icons and `mark_all_read` had none), and item 56 then assigned
+`mail-mark-read` to `mark_all_read` as well without checking the table for
+duplicates. Twenty-four entries were added or reviewed by hand and this one
+overlap was not noticed.
+
+It only became visible because of the other half of item 56. While the toolbar
+forced `TextBesideIcon` the label disambiguated the two buttons; once it follows
+a desktop set to icon-only, the icon **is** the whole control, and two buttons
+whose consequences differ (`archive` removes `inbox` from the selection,
+`mark_all_read` removes `unread` from the entire view) looked identical.
+
+**Fix.** `archive` now uses `mail-archive`, which is also the more accurate
+name: `mail-mark-read` describes read state, which is what `mark_all_read`
+does, not what archiving does.
+
+**The verification is the point of this entry.** A test for the reported pair
+would have been worthless, since the defect is the class and not the instance: a
+hand-written table of twenty-four names has more plausible duplicates in it.
+`noTwoActionsShareAnIcon` compares every action against every other and names
+any pair that matches. Two details matter:
+
+- It compares `QIcon::cacheKey()`, not the theme name, which the window does not
+ keep. Two *different* names that resolve to the same art on some theme are
+ equally ambiguous on screen, and that is what the user actually sees.
+- It carries a guard requiring every action to have an icon before comparing.
+ On a theme that resolves nothing, every icon is null, the loop body never
+ runs, and the assertion would pass having compared nothing.
+
+Mutation-checked by introducing a *different* collision (`zoom_out` pointed at
+`zoom-in`); the test named that pair rather than the one it was written for.
+
+**Also worth recording: the icon-name probe endorsed the wrong thing.** Item 56
+verified that every name resolves to non-null art, and that check passes
+happily for two names resolving to the *same* art. Resolving and being
+distinguishable are separate properties, and only the first was tested. The
+candidate replacement was therefore checked by rendering both icons at 24px and
+comparing the images, not by asking whether the name existed.
+
## Deferred, unsized, or split out
Items noted while triaging but not part of the original list. Same numbering
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index a176d71..cb02652 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -917,7 +917,10 @@ void MainWindow::buildMenus()
// still degrades to text through the null check below.
const QHash<QString, QString> themeIcons = {
{ QStringLiteral("sync"), QStringLiteral("mail-receive") },
- { QStringLiteral("archive"), QStringLiteral("mail-mark-read") },
+ // NOT mail-mark-read, which mark_all_read below uses. The two shared it
+ // in 0.12.0, and with the toolbar icon-only the icon is the whole
+ // control: two buttons with different consequences looked identical.
+ { QStringLiteral("archive"), QStringLiteral("mail-archive") },
{ QStringLiteral("delete"), QStringLiteral("edit-delete") },
{ QStringLiteral("undo"), QStringLiteral("edit-undo") },
{ QStringLiteral("spam"), QStringLiteral("mail-mark-junk") },
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 84a3a7d..fc7409c 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -121,6 +121,7 @@ private slots:
void theImportantActionIsLabelledImportant();
void theImportantActionStillWritesTheFlaggedTag();
void theToolbarUsesTheConfiguredIconSize();
+ void noTwoActionsShareAnIcon();
};
void TestMainWindow::everyKnownActionIsRegistered()
@@ -2542,6 +2543,58 @@ void TestMainWindow::theToolbarUsesTheConfiguredIconSize()
QCOMPARE(toolBar->iconSize(), QSize(40, 40));
}
+void TestMainWindow::noTwoActionsShareAnIcon()
+{
+ // Reported by the user against the icons shipped in 0.12.0: Archive and
+ // Mark all read both used `mail-mark-read`. With the toolbar following a
+ // desktop set to icon-only, the icon is the entire control, so two buttons
+ // with different consequences were indistinguishable.
+ //
+ // Asserted over every action rather than that one pair, because the defect
+ // is the class and not the instance: the icon table is hand-written and
+ // twenty-four entries long, so the next duplicate is a plausible typo.
+ //
+ // Compared by cacheKey() rather than by the theme NAME, which this window
+ // does not keep. Two distinct names that resolve to the same art on a given
+ // theme are just as ambiguous on screen, and that is what the user sees.
+ const Config config;
+ MainWindow window(config);
+
+ QHash<qint64, QString> owners;
+ QStringList collisions;
+ int withIcons = 0;
+
+ for (const QString &name : KeyMap::knownActions()) {
+ auto *action = window.findChild<QAction *>(name);
+ QVERIFY2(action, qPrintable(QStringLiteral("no action named %1").arg(name)));
+ if (action->icon().isNull())
+ continue;
+
+ ++withIcons;
+ const qint64 key = action->icon().cacheKey();
+ const auto existing = owners.constFind(key);
+ if (existing != owners.constEnd()) {
+ collisions.append(QStringLiteral("%1 and %2")
+ .arg(existing.value(), name));
+ } else {
+ owners.insert(key, name);
+ }
+ }
+
+ // The guard. On a theme that resolves nothing every icon is null, the loop
+ // body never runs, and the assertion below would pass having compared
+ // nothing at all.
+ QVERIFY2(withIcons >= KeyMap::knownActions().size(),
+ qPrintable(QStringLiteral("only %1 of %2 actions had an icon to "
+ "compare")
+ .arg(withIcons)
+ .arg(KeyMap::knownActions().size())));
+
+ QVERIFY2(collisions.isEmpty(),
+ qPrintable(QStringLiteral("actions sharing one icon: %1")
+ .arg(collisions.join(QStringLiteral("; ")))));
+}
+
// Constructing a MainWindow needs a QApplication and a platform plugin. The
// test has no display under ctest, so it runs offscreen unless the caller
// asked for something else.