WIP [UI] Theme-aware onboarding banner with frosted light mode

Banner colours now derive from palette tokens at ~60fps (tick-driven,
equality-guarded setters) so scheme switches and live accent-picker
previews apply instantly:

- dark stages: byte-for-byte the original treatment (near-black stage
  from window hue, Highlight accent, white centre halo, vignette 0.62)
- light stages: pastel accent-hue wash instead of a neutral grey copy,
  brightness-lifted accent for additive glow legibility, deep-Highlight
  halo (uGlowColor) instead of white blowout, gentler vignette
  (uVignetteMin 0.88) so corners don't go muddy
- black logo silhouette variant selected on light stages
- theme picker preseeded with brand green (brand_colors.h single source)

WIP notes for next session:
- real-pixel wizard screenshot check still pending (headless capture
  exists: Xvfb :77 + isolated XDG_DATA_HOME; shader vs fallback pixel
  analysis not finished)
- user plans separately: promote Fusion to default theme, Default -> system
This commit is contained in:
Lukas Brübach 2026-09-08 16:28:31 +02:00
parent 803fa21753
commit e08d7a2503
10 changed files with 231 additions and 16 deletions

View file

@ -1,7 +1,10 @@
#include "shader_banner_widget.h"
#include "../../theme_manager.h"
#include "banner_shader_config.h"
#include "brand_colors.h"
#include <QApplication>
#include <QPainter>
#include <QQmlContext>
#include <QQmlEngine>
@ -11,11 +14,68 @@
namespace
{
// Near-black base palette -- the background is dark and quiet so the green
// accent stands out.
constexpr QRgb kColorA = 0x1A1A20;
constexpr QRgb kColorB = 0x0E0E12;
constexpr QRgb kAccent = 0x8BDD6B;
// Curated near-black stage -- used only when the active palette resolves no
// usable window colour. Matches the banner's original design (dark and quiet
// so the accent stands out) and satisfies design-plans §2.1's "identity
// survives a bare palette".
constexpr QRgb kFallbackColorA = 0x1A1A20;
constexpr QRgb kFallbackColorB = 0x0E0E12;
struct SuggestedColors
{
QColor colorA;
QColor colorB;
QColor accent;
QColor glowColor;
qreal vignetteMin = 0.62;
bool lightStage = false;
};
SuggestedColors suggestedBannerColors()
{
const QPalette &pal = qApp->palette();
const QColor window = pal.color(QPalette::Active, QPalette::Window);
const QColor highlight = pal.color(QPalette::Active, QPalette::Highlight);
if (!window.isValid() || !highlight.isValid()) {
return {
QColor(kFallbackColorA), QColor(kFallbackColorB), kCockatriceBrandGreen, QColor(Qt::white), 0.62, false};
}
// Dress the stage for the scheme so the banner never fights the
// surrounding window in either mode. Dark palettes keep the original
// quiet near-black stage (lightness 29 → 16) with the theme's window
// hue; light palettes get a pastel "frosted accent" treatment built from
// the Highlight hue instead of a plain near-white copy: a gentle mint
// wash that clearly belongs to the theme.
const qreal luma = 0.299 * window.red() + 0.587 * window.green() + 0.114 * window.blue();
const bool lightStage = luma > 115.0;
if (lightStage) {
const int hue = highlight.hslHue();
// Achromatic accents (grey) get a neutral near-white stage instead.
const int stageSat = hue < 0 ? 0 : 35;
const int hueSafe = hue < 0 ? 0 : hue;
auto pastel = [hueSafe, stageSat](int lightness) { return QColor::fromHsl(hueSafe, stageSat, lightness); };
// Brightness-lifted accent for additive glows: Highlight on a light
// stage must be mid-bright to read (the shipped light Highlight is a
// deep green that washes out additively against white).
const int accentLightness = qBound(120, highlight.lightness() + 70, 165);
const int accentSaturation = hue < 0 ? 0 : qMax(highlight.hslSaturation(), 140);
const QColor liftedAccent = hue < 0 ? highlight : QColor::fromHsl(hueSafe, accentSaturation, accentLightness);
// The centre glow uses the deep Highlight itself -- a coloured halo
// behind the dark logo instead of a white blowout.
return {pastel(247), pastel(231), liftedAccent, highlight, 0.88, true};
}
// Dark stage: force the window hue down to the banner's curated darkness,
// scaling saturation away so chromatic palettes tint it without going
// muddy. White glow and the original strong vignette stay untouched.
auto stage = [&window](int lightness) {
const int hue = window.hslHue();
const int saturation = hue < 0 ? 0 : qBound(0, qRound(window.hslSaturation() * (lightness / 40.0)), 255);
return QColor::fromHsl(hue, saturation, lightness);
};
return {stage(29), stage(16), QColor(highlight), QColor(Qt::white), 0.62, false};
}
} // namespace
class GradientFallbackWidget : public QWidget
@ -23,15 +83,27 @@ class GradientFallbackWidget : public QWidget
public:
using QWidget::QWidget;
void setColors(const QColor &a, const QColor &b)
{
if (a != colorA || b != colorB) {
colorA = a;
colorB = b;
}
}
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
QLinearGradient gradient(0, 0, width(), height());
gradient.setColorAt(0.0, QColor(kColorA));
gradient.setColorAt(1.0, QColor(kColorB));
gradient.setColorAt(0.0, colorA);
gradient.setColorAt(1.0, colorB);
painter.fillRect(rect(), gradient);
}
private:
QColor colorA{QColor(kFallbackColorA)};
QColor colorB{QColor(kFallbackColorB)};
};
BannerHost::BannerHost(QWidget *parent) : QWidget(parent)
@ -62,6 +134,9 @@ BannerHost::BannerHost(QWidget *parent) : QWidget(parent)
connect(&clock, &QTimer::timeout, this, &BannerHost::tick);
clock.setInterval(16); // ~60fps; the shader itself is cheap, this is just a wall clock
connect(themeManager, &ThemeManager::themeChanged, this, &BannerHost::applyThemeColors);
applyThemeColors();
applyMotifPreset(currentMotif);
updateAspect();
}
@ -123,9 +198,9 @@ void BannerHost::applyMotifPreset(Motif motif)
const Preset p = presetFor(motif);
config->setColorA(QColor(kColorA));
config->setColorB(QColor(kColorB));
config->setAccent(QColor(kAccent));
config->setColorA(bannerColorA);
config->setColorB(bannerColorB);
config->setAccent(bannerAccent);
config->setLogoVisible(motif == Motif::Welcome);
if (isFirstApply) {
@ -183,8 +258,33 @@ void BannerHost::hideEvent(QHideEvent *event)
clock.stop();
}
void BannerHost::applyThemeColors()
{
const SuggestedColors colors = suggestedBannerColors();
bannerColorA = colors.colorA;
bannerColorB = colors.colorB;
bannerAccent = colors.accent;
if (usingFallback) {
fallback->setColors(bannerColorA, bannerColorB);
fallback->update();
} else if (config) {
config->setColorA(bannerColorA);
config->setColorB(bannerColorB);
config->setAccent(bannerAccent);
config->setGlowColor(colors.glowColor);
config->setVignetteMin(colors.vignetteMin);
config->setLogoDark(colors.lightStage);
}
}
void BannerHost::tick()
{
// Palette previews (e.g. accent drags in the wizard's QuickSetupPanel)
// apply qApp->palette() without firing themeChanged, so re-derive here;
// BannerShaderConfig's setters are equality-guarded, so this is a no-op
// unless the colours actually changed.
applyThemeColors();
if (config) {
qreal t = elapsed.elapsed() / 1000.0;
config->setTime(t);