// theme — pure implementation. See theme.h. #include "core/ui/theme.h" #include #include namespace reasampler::ui { namespace { // =========================================================================== // THE ONE DIRECTION CONSTANTS BLOCK. Every role color below is one of these constants; // roleColor() is a pure switch over them — this is the single point of change for the // visual direction. Values are locked against each WCAG floor (proven by test_theme.cpp): // text/dim is lifted to the lightest grey that still clears AA 4.5:1 body on the greyest // surface it draws on; each pastel accent is the softest tint that still clears the 3:1 // indicator floor on bg/cell ("punch from the soft side"). // =========================================================================== // REAPER-theme mid-grey elevation stack, matching Daniel's REAPER theme so the dock reads as // part of REAPER: base = window chrome grey, panel/cell one step lighter each. Elevation-ladder // discipline: base < panel < cell by a few %, micro-gradient + inner highlight/shadow carry // elevation, not hard borders. constexpr KitColor kDirBgBase {43, 43, 43, 255}; // #2b2b2b — REAPER chrome grey constexpr KitColor kDirBgPanel {51, 51, 51, 255}; // #333333 — one step lighter constexpr KitColor kDirBgCell {58, 58, 58, 255}; // #3a3a3a — REAPER track bg constexpr KitColor kDirHairline {74, 74, 74, 255}; // #4a4a4a — subtle step above cell // Text: REAPER body light-grey primary (#dcdcdc, clears ~8:1 on bg/cell) + a dimmer grey // secondary. Mid-grey-on-mid-grey is the classic AA failure: the spec-start #a0a0a0 lands // ~4.35:1 on bg/cell, under the AA 4.5 body floor — lifted to #a8a8a8 (~4.78:1), the lightest // grey that still reads dim while clearing the floor. Locked by test_theme.cpp. constexpr KitColor kDirTextPrimary{220, 220, 220, 255}; // #dcdcdc constexpr KitColor kDirTextDim {168, 168, 168, 255}; // #a8a8a8 (lifted from #a0a0a0) // Accent system: primary = pastel lime (the live/active/selected signal); secondary = deep // teal, tertiary = pastel purple (CATEGORICAL distinctions — a KIND, never intensity). // accent/hot is a brighter tint OF the primary for hover/live/drag. Secondary is the one // accent off the pastel side: its binding limiter is the AA 4.5:1 text-on-fill pair (a bg/base // label on a secondary fill), NOT the 3:1 indicator floor, so it cannot go darker than this. // warn is reserved for byte-deleting states only. constexpr KitColor kDirAccentPrimary {176, 224, 152, 255}; // #B0E098 — pastel lime constexpr KitColor kDirAccentSecondary{56, 168, 160, 255}; // #38A8A0 — deep teal constexpr KitColor kDirAccentTertiary {194, 170, 232, 255}; // #C2AAE8 — pastel purple constexpr KitColor kDirAccentHot {200, 236, 178, 255}; // #C8ECB2 — lighter pastel lime constexpr KitColor kDirWarn {235, 120, 90, 255}; // #eb785a — destructive only // The envelope trace over the waveform. Two neighbours pin it at once — the lime accent it // crosses and bg/base behind it — which bound ANY single value to 3.07:1 against both; this is // that optimum, with no room in either direction. No categorical accent can carry it: every // one of them is light enough to fall under 2:1 against the lime. constexpr KitColor kDirOverlayTrace{129, 106, 166, 255}; // #816AA6 — muted violet // Spectral ramp: pastel lime (low) -> pastel teal (mid) -> pastel purple (high). The mid stop // is its OWN value rather than the secondary accent it once aliased — the ramp is a luminance // progression while the accents are categorical roles, and darkening secondary for a // categorical reason inverted lo->mid->hi. Do not re-alias it. constexpr KitColor kDirSpectralLo = kDirAccentPrimary; constexpr KitColor kDirSpectralMid{132, 214, 208, 255}; // #84D6D0 — pastel teal constexpr KitColor kDirSpectralHi = kDirAccentTertiary; // --- state transform helpers ------------------------------------------------- std::uint8_t clamp8(int v) { return static_cast(v < 0 ? 0 : (v > 255 ? 255 : v)); } // Linear blend from a toward b by t in [0, 1] (alpha carried from a — a state tint changes // hue/brightness, not opacity; disabled handles alpha separately). KitColor mix(const KitColor& a, const KitColor& b, double t) { return KitColor{ clamp8(static_cast(std::lround(a.r + (b.r - a.r) * t))), clamp8(static_cast(std::lround(a.g + (b.g - a.g) * t))), clamp8(static_cast(std::lround(a.b + (b.b - a.b) * t))), a.a, }; } KitColor scale(const KitColor& c, double factor) { return KitColor{ clamp8(static_cast(std::lround(c.r * factor))), clamp8(static_cast(std::lround(c.g * factor))), clamp8(static_cast(std::lround(c.b * factor))), c.a, }; } // Desaturate toward the color's own luminance-gray by amount in [0, 1]. KitColor desaturate(const KitColor& c, double amount) { const int gray = clamp8(static_cast( std::lround(0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b))); const KitColor g{static_cast(gray), static_cast(gray), static_cast(gray), c.a}; return mix(c, g, amount); } double linearizeChannel(std::uint8_t v) { const double s = v / 255.0; return s <= 0.03928 ? s / 12.92 : std::pow((s + 0.055) / 1.055, 2.4); } } // namespace KitColor roleColor(Role role) { switch (role) { case Role::BgBase: return kDirBgBase; case Role::BgPanel: return kDirBgPanel; case Role::BgCell: return kDirBgCell; case Role::LineHairline: return kDirHairline; case Role::TextPrimary: return kDirTextPrimary; case Role::TextDim: return kDirTextDim; case Role::AccentPrimary: return kDirAccentPrimary; case Role::AccentSecondary: return kDirAccentSecondary; case Role::AccentTertiary: return kDirAccentTertiary; case Role::AccentHot: return kDirAccentHot; case Role::OverlayTrace: return kDirOverlayTrace; case Role::Warn: return kDirWarn; } return kDirBgBase; // unreachable; keeps non-void control flow total } KitColor roleColorState(Role role, InteractionState state) { const KitColor base = roleColor(role); switch (state) { case InteractionState::Rest: return base; case InteractionState::Hover: // Lighten toward the hot accent (~10%) — the "alive" cue. return mix(base, roleColor(Role::AccentHot), 0.10); case InteractionState::Active: // "This is live" is always the primary hue — secondary/tertiary stay categorical. return roleColor(Role::AccentPrimary); case InteractionState::Pressed: return scale(base, 0.82); // the surface "pushes in" case InteractionState::Dragging: return mix(roleColor(Role::AccentPrimary), roleColor(Role::AccentHot), 0.30); case InteractionState::Focus: // Focus keeps the surface; the shell draws a text/primary ring on top, and the // fill nudges toward the primary accent so focus reads pre-ring. return mix(base, roleColor(Role::AccentPrimary), 0.08); case InteractionState::Disabled: { KitColor d = desaturate(base, 0.6); d.a = static_cast(std::lround(base.a * 0.4)); return d; } } return base; } KitColor spectralColor(double t) { if (t < 0.0) t = 0.0; if (t > 1.0) t = 1.0; // Interpolate each half separately so the mid stop is actually hit — a single Lo->Hi lerp // would skip it and drift the ramp off the pastel arc. if (t <= 0.5) { return mix(kDirSpectralLo, kDirSpectralMid, t / 0.5); } return mix(kDirSpectralMid, kDirSpectralHi, (t - 0.5) / 0.5); } double relativeLuminance(const KitColor& c) { return 0.2126 * linearizeChannel(c.r) + 0.7152 * linearizeChannel(c.g) + 0.0722 * linearizeChannel(c.b); } double contrastRatio(const KitColor& a, const KitColor& b) { const double la = relativeLuminance(a); const double lb = relativeLuminance(b); const double lighter = std::max(la, lb); const double darker = std::min(la, lb); return (lighter + 0.05) / (darker + 0.05); } double textFloor(TextClass cls) { return cls == TextClass::Body ? 4.5 : 3.0; } } // namespace reasampler::ui