palette: give the spectral mid and the envelope trace their own roles

Decouples the keyboard strip's mid stop from accent/secondary, which the
deep-teal darkening had inverted. Adds overlay/trace (#816AA6), the first
value to clear 3:1 against the waveform. Replaces the frozen-premise test.
This commit is contained in:
2026-07-31 13:27:20 -04:00
parent 04e4f875af
commit 91f71f92bd
6 changed files with 153 additions and 88 deletions
+24 -5
View File
@@ -420,10 +420,26 @@ against the WCAG tests — §2.1):
| `accent/tertiary` | pastel purple | `~194,170,232` (`#C2AAE8`) | categorical role B (tertiary zones, a distinct affordance class) | | `accent/tertiary` | pastel purple | `~194,170,232` (`#C2AAE8`) | categorical role B (tertiary zones, a distinct affordance class) |
| `accent/hot` | brighter primary tint | `~200,236,178` (`#C8ECB2`) | hover / live / drag feedback (a lighter pastel-lime) | | `accent/hot` | brighter primary tint | `~200,236,178` (`#C8ECB2`) | hover / live / drag feedback (a lighter pastel-lime) |
**Locked values, where they diverge from the starting table (2026-07-31).** `accent/secondary`
darkened to `#38A8A0` (same hue ~176°, same saturation, lightness 0.68 → 0.44), which put its
binding limiter on the AA 4.5:1 text-on-fill pair (4.91:1 on `bg/base`) rather than the 3:1
indicator floor (3.94:1 on `bg/cell`). Two roles were added past the original three, since
"exactly three accents" is no longer a constraint: `overlay/trace` `#816AA6` (a muted violet for
marks drawn *over* an accent fill — see §the two-neighbour problem below), and the spectral
ramp's mid stop, which keeps the original pastel teal `#84D6D0` as its own constant rather than
aliasing `accent/secondary`.
**The two-neighbour problem.** A mark drawn *over* an accent fill has to read against the fill
*and* the surface behind it. For the envelope trace those are the lime waveform (relative
luminance 0.648) and `bg/base` (0.024) — 9.41:1 apart, so `sqrt(9.41)` ≈ **3.07:1 is the most
any single value can clear against both**. `overlay/trace` sits at that optimum; it has no
headroom in either direction, and no value at all can clear 3:1 against the lime *and* on
`bg/cell`. Such a role is confined to the band it was picked for.
These are light-ish, low-saturation tints. On the **REAPER-grey surfaces** (`bg/cell` These are light-ish, low-saturation tints. On the **REAPER-grey surfaces** (`bg/cell`
`#3a3a3a`) they still clear **AA-large (3:1)** and the **state-indicator** floor — but the `#3a3a3a`) they still clear **AA-large (3:1)** and the **state-indicator** floor — but the
margin is **much smaller than it was on near-black** (roughly ~6:17:1 on grey vs. ~15:1 margin is **much smaller than it was on near-black** (~7.6:1 primary / ~5.5:1 tertiary /
on `#12121x`). This is a real DS-2-revision tension: **the greyer background pulls the ~3.9:1 the darkened secondary on grey, vs. ~15:1 on `#12121x`). This is a real DS-2-revision tension: **the greyer background pulls the
pastels toward the floor** at the same time the "keep it soft" rule pulls them away from pastels toward the floor** at the same time the "keep it soft" rule pulls them away from
saturation. Approach from the **soft side** (§2.1) — keep them as pastel as possible while saturation. Approach from the **soft side** (§2.1) — keep them as pastel as possible while
still clearing the floor on **grey, not near-black** — but **if any pastel used as a state still clearing the floor on **grey, not near-black** — but **if any pastel used as a state
@@ -470,9 +486,12 @@ the rest of the UI. Concretely: the ramp runs **pastel-lime (primary, low) → p
(secondary, mid) → pastel-purple (tertiary, high)** as a three-stop gradient through the (secondary, mid) → pastel-purple (tertiary, high)** as a three-stop gradient through the
accent constants (or a slightly wider pastel arc that passes *through* those three anchor accent constants (or a slightly wider pastel arc that passes *through* those three anchor
points), keeping every stop in the pastel band. This ties the spectrum to the palette: points), keeping every stop in the pastel band. This ties the spectrum to the palette:
the same three hues that mean "live / category A / category B" elsewhere are the endpoints the same hues that mean "live / category A / category B" elsewhere anchor the spectrum here,
and midpoint of the spectrum here, so the strip reads as an extension of the accent system, so the strip reads as an extension of the accent system, not a separate neon flourish.
not a separate neon flourish. The **active** zone still lifts to `accent/primary` + **The mid stop is its own constant, not an alias of `accent/secondary`** (decoupled
2026-07-31): the ramp is a luminance progression while the accents are categorical roles, and
darkening secondary for a categorical reason inverted lo→mid→hi. A monotonicity test now guards
the ordering. The **active** zone still lifts to `accent/primary` +
its bloom, so "which zone is live" stays unambiguous over the categorical spectral bands. its bloom, so "which zone is live" stays unambiguous over the categorical spectral bands.
- **Feel:** premium, almost visualizer-grade — but soft and cohesive, a pastel spectrum - **Feel:** premium, almost visualizer-grade — but soft and cohesive, a pastel spectrum
+24 -13
View File
@@ -24,23 +24,34 @@ L7 sub-pass, 2026-07-27):
- **DS-2 — palette is role-based, not hardcoded hue**, in one constants block - **DS-2 — palette is role-based, not hardcoded hue**, in one constants block
(`theme`): `bg/base`, `bg/panel`, `bg/cell`, `line/hairline`, `text/primary`, (`theme`): `bg/base`, `bg/panel`, `bg/cell`, `line/hairline`, `text/primary`,
`text/dim`, `accent/primary`, `accent/secondary`, `accent/tertiary`, `text/dim`, `accent/primary`, `accent/secondary`, `accent/tertiary`,
`accent/hot`, `warn`. Neutral ladder sits in REAPER's mid-grey theme family `accent/hot`, `overlay/trace`, `warn`. Neutral ladder sits in REAPER's mid-grey
(`bg/base``#2b2b2b`, `bg/panel``#333333`, `bg/cell` `#3a3a3a`, theme family (`bg/base``#2b2b2b`, `bg/panel``#333333`, `bg/cell`
`line/hairline``#4a4a4a`, `text/primary``#dcdcdc`, `text/dim` `#3a3a3a`, `line/hairline``#4a4a4a`, `text/primary``#dcdcdc`, `text/dim`
`~#a0a0a0`+), elevation-ladder discipline (base < panel < cell by a few %, `~#a0a0a0`+), elevation-ladder discipline (base < panel < cell by a few %,
micro-gradient + inner highlight/shadow carry elevation, not hard borders). micro-gradient + inner highlight/shadow carry elevation, not hard borders). The
Three pastel accents carry categorical meaning: **primary (pastel lime) = accents carry categorical meaning: **primary (pastel lime) =
live/active/selected**, secondary (pastel teal) + tertiary (pastel purple) = live/active/selected**, secondary (deep teal) + tertiary (pastel purple) =
supporting categorical distinctions (kinds, not intensity). `warn` (red/amber) supporting categorical distinctions (kinds, not intensity). `warn` (red/amber)
is reserved **only** for byte-deleting or clip states (prune, delete). is reserved **only** for byte-deleting or clip states (prune, delete).
**The role count is not fixed at three** — a shade that works better in one spot
earns its own named role (Daniel, 2026-07-31). What does not relax: draw sites
ask by role. A literal color outside `theme`'s constants block is the violation,
not the role count.
- **WCAG-floor discipline (tight pairs to re-verify on any palette change):** - **WCAG-floor discipline (tight pairs to re-verify on any palette change):**
`text/dim` on `bg/panel`/`bg/cell` is the classic mid-grey-on-mid-grey floor `text/dim` on `bg/panel`/`bg/cell` is the classic mid-grey-on-mid-grey floor
failure — must clear AA 4.5:1 body text. The three pastels as state failure — must clear AA 4.5:1 body text. The accents as state indicators/active
indicators/active fills on `bg/cell` have a shrunken contrast cushion fills on `bg/cell` have a shrunken contrast cushion (primary ~7.6:1, tertiary
(~6:17:1, still clears 3:1 but re-check on any hue nudge). Body text on a ~5.5:1, secondary only ~3.9:1 since it was darkened — all still clear 3:1, but
pastel fill is a tight AA 4.5:1 pair. Take the most pastel value that still re-check every one on any hue nudge). Body text on an accent fill is a tight AA
clears the floor, approached from the soft side, never re-saturated toward 4.5:1 pair, and for `accent/secondary` it is the *binding* limiter (~4.9:1), not
neon "to be safe." the indicator floor. Take the most pastel value that still clears the floor,
approached from the soft side, never re-saturated toward neon "to be safe."
- **A mark drawn OVER an accent fill answers to two neighbours at once.**
`overlay/trace` (the envelope trace on the waveform) must clear 3:1 against both
`accent/primary` and `bg/base`, which sit 9.41:1 apart — so `sqrt(9.41)` ≈ 3.07:1
is the ceiling for *any* single value, and the role sits at that optimum with no
headroom either way. It is therefore confined to the waveform band: no value can
clear 3:1 on `bg/cell` and against the lime simultaneously.
- **"Speed is the selling point" — a design constraint on this geometry, not - **"Speed is the selling point" — a design constraint on this geometry, not
just the draw layer.** Sub-frame hover/press/drag feedback must repaint just the draw layer.** Sub-frame hover/press/drag feedback must repaint
immediately on the input message. **No decorative animation** — no immediately on the input message. **No decorative animation** — no
@@ -68,7 +79,7 @@ L7 sub-pass, 2026-07-27):
- `tab_strip` — REAPER-free scrollable tab-strip layout + hit-test for the named-banks strip. - `tab_strip` — REAPER-free scrollable tab-strip layout + hit-test for the named-banks strip.
- `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button. - `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button.
- `drag_out` — pure OS drag-out module: gesture-boundary decision and path-list assembly. The `InstrumentDrop` gesture signals that the shell should execute an instrument-drop rather than a file-copy drag. - `drag_out` — pure OS drag-out module: gesture-boundary decision and path-list assembly. The `InstrumentDrop` gesture signals that the shell should execute an instrument-drop rather than a file-copy drag.
- `theme` — pure palette module: role→color mapping, REAPER-grey neutral ladder + three-accent pastel system, WCAG contrast-floor helpers. - `theme` — pure palette module: role→color mapping, REAPER-grey neutral ladder + the pastel accent system, the keyboard strip's spectral ramp (its own three stops, deliberately not aliases of the accents), WCAG contrast-floor helpers.
- `component_geometry` — pure button/slider/list-row geometry + hover hit-test helpers. - `component_geometry` — pure button/slider/list-row geometry + hover hit-test helpers.
- `action_bar` — pure task-grouped action-bar layout/hit-test: clusters (Capture / Placement / Maintenance / Tagging / Switching). - `action_bar` — pure task-grouped action-bar layout/hit-test: clusters (Capture / Placement / Maintenance / Tagging / Switching).
- `footer_bar` — pure footer layout/hit-test: `[Arrange|Design]` mode-toggle geometry, Tail button, and Prune placement. - `footer_bar` — pure footer layout/hit-test: `[Arrange|Design]` mode-toggle geometry, Tail button, and Prune placement.
+21 -13
View File
@@ -34,23 +34,30 @@ constexpr KitColor kDirHairline {74, 74, 74, 255}; // #4a4a4a — subtle ste
constexpr KitColor kDirTextPrimary{220, 220, 220, 255}; // #dcdcdc constexpr KitColor kDirTextPrimary{220, 220, 220, 255}; // #dcdcdc
constexpr KitColor kDirTextDim {168, 168, 168, 255}; // #a8a8a8 (lifted from #a0a0a0) constexpr KitColor kDirTextDim {168, 168, 168, 255}; // #a8a8a8 (lifted from #a0a0a0)
// Three-accent pastel system: primary = pastel lime (the live/active/selected signal); // Accent system: primary = pastel lime (the live/active/selected signal); secondary = deep
// secondary = pastel teal, tertiary = pastel purple (CATEGORICAL distinctions — a KIND, never // teal, tertiary = pastel purple (CATEGORICAL distinctions — a KIND, never intensity).
// intensity). accent/hot is a brighter tint OF the primary for hover/live/drag. On bg/cell the // accent/hot is a brighter tint OF the primary for hover/live/drag. Secondary is the one
// pastels clear the 3:1 indicator floor comfortably at these values (primary ~7.6, secondary // accent off the pastel side: its binding limiter is the AA 4.5:1 text-on-fill pair (a bg/base
// ~6.8, tertiary ~5.5), so no per-hue nudge was needed. warn is reserved for byte-deleting // label on a secondary fill), NOT the 3:1 indicator floor, so it cannot go darker than this.
// states only. // warn is reserved for byte-deleting states only.
constexpr KitColor kDirAccentPrimary {176, 224, 152, 255}; // #B0E098 — pastel lime constexpr KitColor kDirAccentPrimary {176, 224, 152, 255}; // #B0E098 — pastel lime
constexpr KitColor kDirAccentSecondary{132, 214, 208, 255}; // #84D6D0 — pastel teal constexpr KitColor kDirAccentSecondary{56, 168, 160, 255}; // #38A8A0 — deep teal
constexpr KitColor kDirAccentTertiary {194, 170, 232, 255}; // #C2AAE8 — pastel purple constexpr KitColor kDirAccentTertiary {194, 170, 232, 255}; // #C2AAE8 — pastel purple
constexpr KitColor kDirAccentHot {200, 236, 178, 255}; // #C8ECB2 — lighter pastel lime constexpr KitColor kDirAccentHot {200, 236, 178, 255}; // #C8ECB2 — lighter pastel lime
constexpr KitColor kDirWarn {235, 120, 90, 255}; // #eb785a — destructive only constexpr KitColor kDirWarn {235, 120, 90, 255}; // #eb785a — destructive only
// Spectral ramp: pastel lime (low) -> pastel teal (mid) -> pastel purple (high). Endpoints and // The envelope trace over the waveform. Two neighbours pin it at once — the lime accent it
// midpoint ARE the three accent constants (single source), so the keyboard strip reads as an // crosses and bg/base behind it — which bound ANY single value to 3.07:1 against both; this is
// extension of the accent system. // 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 kDirSpectralLo = kDirAccentPrimary;
constexpr KitColor kDirSpectralMid = kDirAccentSecondary; constexpr KitColor kDirSpectralMid{132, 214, 208, 255}; // #84D6D0 — pastel teal
constexpr KitColor kDirSpectralHi = kDirAccentTertiary; constexpr KitColor kDirSpectralHi = kDirAccentTertiary;
// --- state transform helpers ------------------------------------------------- // --- state transform helpers -------------------------------------------------
@@ -108,6 +115,7 @@ KitColor roleColor(Role role) {
case Role::AccentSecondary: return kDirAccentSecondary; case Role::AccentSecondary: return kDirAccentSecondary;
case Role::AccentTertiary: return kDirAccentTertiary; case Role::AccentTertiary: return kDirAccentTertiary;
case Role::AccentHot: return kDirAccentHot; case Role::AccentHot: return kDirAccentHot;
case Role::OverlayTrace: return kDirOverlayTrace;
case Role::Warn: return kDirWarn; case Role::Warn: return kDirWarn;
} }
return kDirBgBase; // unreachable; keeps non-void control flow total return kDirBgBase; // unreachable; keeps non-void control flow total
@@ -144,8 +152,8 @@ KitColor roleColorState(Role role, InteractionState state) {
KitColor spectralColor(double t) { KitColor spectralColor(double t) {
if (t < 0.0) t = 0.0; if (t < 0.0) t = 0.0;
if (t > 1.0) t = 1.0; if (t > 1.0) t = 1.0;
// Interpolate each half separately so the midpoint IS the secondary accent (a single // Interpolate each half separately so the mid stop is actually hit — a single Lo->Hi lerp
// Lo->Hi lerp would skip it and drift the ramp off the accent family). // would skip it and drift the ramp off the pastel arc.
if (t <= 0.5) { if (t <= 0.5) {
return mix(kDirSpectralLo, kDirSpectralMid, t / 0.5); return mix(kDirSpectralLo, kDirSpectralMid, t / 0.5);
} }
+10 -8
View File
@@ -1,14 +1,14 @@
#pragma once #pragma once
// theme — the palette + type-scale core of the shared drawing kit: a ROLE-based color model // theme — the palette + type-scale core of the shared drawing kit: a ROLE-based color model
// (bg/base, bg/panel, bg/cell, line/hairline, text/primary, text/dim, accent/primary, // (bg/base, bg/panel, bg/cell, line/hairline, text/primary, text/dim, accent/primary,
// accent/secondary, accent/tertiary, accent/hot, warn), an interaction-state model // accent/secondary, accent/tertiary, accent/hot, overlay/trace, warn), an interaction-state
// (rest/hover/active/pressed/dragging/focus/disabled), and the WCAG contrast math that lets a // model (rest/hover/active/pressed/dragging/focus/disabled), and the WCAG contrast math that
// unit test prove every text-on-surface pair clears its floor. // lets a unit test prove every text-on-surface pair clears its floor.
// //
// Every role color is produced by roleColor() from ONE direction constants block (theme.cpp) — // Every role color is produced by roleColor() from ONE direction constants block (theme.cpp) —
// the single point of change; no shell hardcodes a color, it asks by role. The spectral hue ramp // the single point of change; no shell hardcodes a color, it asks by role. The spectral hue ramp
// (spectralColor) lives here too so the keyboard strip derives its per-note hue from the same // (spectralColor) lives here too so the keyboard strip derives its per-note hue from the same
// source, anchored on the three accents (primary -> secondary -> tertiary). // source.
#include <cstdint> #include <cstdint>
@@ -36,9 +36,11 @@ enum class Role {
TextPrimary, // labels, values TextPrimary, // labels, values
TextDim, // secondary / units TextDim, // secondary / units
AccentPrimary, // live / active / selected — where the punch lives (pastel lime) AccentPrimary, // live / active / selected — where the punch lives (pastel lime)
AccentSecondary,// categorical role A (pastel teal) — a distinct KIND, never intensity AccentSecondary,// categorical role A (deep teal) — a distinct KIND, never intensity
AccentTertiary,// categorical role B (pastel purple) — a distinct KIND, never intensity AccentTertiary,// categorical role B (pastel purple) — a distinct KIND, never intensity
AccentHot, // hover / live / drag feedback (a brighter tint OF the primary accent) AccentHot, // hover / live / drag feedback (a brighter tint OF the primary accent)
OverlayTrace, // a line/handle traced OVER an accent fill — reads against the fill AND the
// surface behind it, which no accent role can do (they are all too light)
Warn, // clip / destructive (prune, delete) — reserved for byte-deleting states Warn, // clip / destructive (prune, delete) — reserved for byte-deleting states
}; };
@@ -70,9 +72,9 @@ KitColor roleColor(Role role);
KitColor roleColorState(Role role, InteractionState state); KitColor roleColorState(Role role, InteractionState state);
// Spectral hue ramp for the keyboard strip: maps normalized position t in [0, 1] (low note -> // Spectral hue ramp for the keyboard strip: maps normalized position t in [0, 1] (low note ->
// high note) through accent/primary (low) -> accent/secondary (mid) -> accent/tertiary (high), // high note) through a pastel lime -> teal -> purple arc, so the strip reads as part of the
// so the strip reads as an extension of the accent system rather than a separate flourish. // palette rather than a separate flourish. The arc is its own three stops — a categorical
// t is clamped to [0, 1]. // accent move must not be able to reorder it. t is clamped to [0, 1].
KitColor spectralColor(double t); KitColor spectralColor(double t);
// --- WCAG contrast (the "punch" rule, made testable) -------------------------- // --- WCAG contrast (the "punch" rule, made testable) --------------------------
+6 -10
View File
@@ -25,11 +25,9 @@ using audio::computeEnvelope;
namespace { namespace {
// Marker roles — semantic, drawn through the kit's palette: start AND loop start/end both // Marker roles — semantic, drawn through the kit's palette: start AND loop start/end both
// = teal (secondary). Loop markers moved off tertiary/purple so the loop span + bars no // = teal (secondary). Markers are 2px bars and a 20%-alpha span fill, not the 1px trace, so
// longer share a role with the envelope overlay trace, which owns tertiary exclusively in // they live with 1.92:1 against the waveform; the trace, which cannot, has its own role.
// this region (measured RGB was identical: #C2AAE8 for both). Secondary-vs-primary contrast // Do not collapse the two back onto one role — they overlap in this rect.
// here measures 1.12:1, below tertiary's 1.37:1 — accepted deliberately: markers are bars
// and a span fill, not the 1px trace that pairing was moved off of.
constexpr Role kRoleStartMarker = Role::AccentSecondary; constexpr Role kRoleStartMarker = Role::AccentSecondary;
constexpr Role kRoleLoopMarker = Role::AccentSecondary; constexpr Role kRoleLoopMarker = Role::AccentSecondary;
} // namespace } // namespace
@@ -114,10 +112,8 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame); const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds); const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
// Trace the polyline in the categorical TERTIARY accent (purple): the waveform behind it is // Clip x to the wave rect. Knots are handles, not line vertices.
// drawn in the primary lime, and the secondary teal this used to use sits too close to that const LICE_pixel line = toLice(roleColor(Role::OverlayTrace));
// hue to separate from it. Clip x to the wave rect. Knots are handles, not line vertices.
const LICE_pixel line = toLice(roleColor(Role::AccentTertiary));
const EnvVertex* prev = nullptr; const EnvVertex* prev = nullptr;
for (const EnvVertex& v : poly) { for (const EnvVertex& v : poly) {
if (v.knot) continue; if (v.knot) continue;
@@ -132,7 +128,7 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea
// accent-hot when this node is the grabbed one. Every vertex is guaranteed in-bounds; the // accent-hot when this node is the grabbed one. Every vertex is guaranteed in-bounds; the
// handle is additionally clamped inside the band so one on an edge node never overhangs // handle is additionally clamped inside the band so one on an edge node never overhangs
// into the neighbouring bands. // into the neighbouring bands.
const LICE_pixel handle = toLice(roleColor(Role::AccentTertiary)); const LICE_pixel handle = toLice(roleColor(Role::OverlayTrace));
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
for (const EnvVertex& v : poly) { for (const EnvVertex& v : poly) {
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseEnd) continue; if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseEnd) continue;
+66 -37
View File
@@ -121,40 +121,55 @@ static void testSecondaryTertiaryAreDistinguishable() {
CHECK(delta >= 60); CHECK(delta >= 60);
} }
// The instrument's envelope overlay is traced OVER the waveform, which draws in the primary // The envelope overlay is traced OVER the waveform, which draws in the primary accent — an
// accent — an accent-on-accent pair no floor covers, since neither is a surface. It moved from // accent-on-accent pair no surface floor covers, since neither is a surface. overlay/trace
// the secondary to the tertiary for exactly this reason, so the pair is pinned two ways: the // exists so that pair can clear the real 3:1 indicator floor; every categorical accent is light
// tertiary must separate from the primary MORE than the secondary did (the measurable half of // enough to sit under 2:1 against the lime, so pointing the trace back at one fails here.
// the move), and the separation is a hue one, since two pastels sit close in luminance by static void testOverlayTraceClearsIndicatorFloorOnTheWaveform() {
// construction. Whether the result reads clearly is a perceptual call, not this test's. CHECK(contrastRatio(roleColor(Role::OverlayTrace), roleColor(Role::AccentPrimary))
static void testOverlayAccentSeparatesFromTheWaveformAccent() { >= textFloor(TextClass::Large));
const KitColor wave = roleColor(Role::AccentPrimary); }
const KitColor overlay = roleColor(Role::AccentTertiary);
const KitColor prior = roleColor(Role::AccentSecondary); // The trace's own surface floor. bg/base is the only surface it draws on (the waveform band
CHECK(contrastRatio(overlay, wave) > contrastRatio(prior, wave)); // fills with it), and that confinement is forced, not convenient: clearing 3:1 against the lime
// Pin the MEASURED value, not just the relational improvement: today's pair sits at // caps the trace's luminance at 0.183, while clearing 3:1 on bg/cell would demand 0.227. The two
// ~1.37:1 — BELOW the 3:1 indicator floor every other assertion in this file uses. That // are mutually exclusive, so this role must not be reused outside that band.
// is a known, held deviation (Daniel's palette call, tracked separately) rather than a static void testOverlayTraceClearsFloorOnItsDrawSurface() {
// silently accepted one; a regression toward the old ~1.12:1 secondary pairing must fail. CHECK(contrastRatio(roleColor(Role::OverlayTrace), roleColor(Role::BgBase))
CHECK(contrastRatio(overlay, wave) >= 1.35); >= textFloor(TextClass::Large));
// Hue divergence against the waveform: the waveform's green dominates its red, the
// overlay's red dominates its green — opposite balances, not two shades of one.
CHECK(wave.g > wave.r);
CHECK(overlay.r > overlay.g);
const int delta = std::abs(int(wave.r) - int(overlay.r)) +
std::abs(int(wave.g) - int(overlay.g)) +
std::abs(int(wave.b) - int(overlay.b));
CHECK(delta >= 60);
} }
// editor_paint_waveform.cpp draws the loop span/markers and the envelope overlay trace into // editor_paint_waveform.cpp draws the loop span/markers and the envelope overlay trace into
// the SAME overlay rect. Loop markers are secondary (this track's reassignment, off tertiary, // the SAME overlay rect, so they must read as two things. Luminance cannot carry that — each is
// which the envelope overlay now owns exclusively there); a future palette edit that collapses // pinned near its own floor — so the separation is a hue one: teal leans green over red, the
// the two back to one role must fail here rather than silently recreating the finding. // trace violet leans red over green. A palette edit collapsing them onto one role fails here.
static void testLoopMarkerAndEnvelopeOverlayRolesAreDistinct() { static void testLoopMarkerAndOverlayTraceAreDistinct() {
const KitColor loopMarker = roleColor(Role::AccentSecondary); const KitColor loopMarker = roleColor(Role::AccentSecondary);
const KitColor envelopeOverlay = roleColor(Role::AccentTertiary); const KitColor trace = roleColor(Role::OverlayTrace);
CHECK(!(loopMarker == envelopeOverlay)); CHECK(!(loopMarker == trace));
CHECK(loopMarker.g > loopMarker.r);
CHECK(trace.r > trace.g);
const int delta = std::abs(int(loopMarker.r) - int(trace.r)) +
std::abs(int(loopMarker.g) - int(trace.g)) +
std::abs(int(loopMarker.b) - int(trace.b));
CHECK(delta >= 60);
}
// AccentSecondary was darkened from a pastel teal (#84D6D0) to a deep teal (#38A8A0, same
// hue/saturation, lower lightness). Locks the re-measured numbers so a future palette nudge
// that erodes past either the 3:1 indicator floor or the tighter AA 4.5:1 text-on-fill floor
// fails the build. The text-on-fill pair (bg/base label on a secondary fill) is the binding
// limiter — it clears with far less headroom (~4.9:1) than the indicator floor on bg/cell
// (~3.9:1), which is why the value could not go darker than this.
static void testAccentSecondaryDarkerTealClearsFloors() {
const KitColor sec = roleColor(Role::AccentSecondary);
CHECK(contrastRatio(sec, roleColor(Role::BgBase)) >= textFloor(TextClass::Large));
CHECK(contrastRatio(sec, roleColor(Role::BgPanel)) >= textFloor(TextClass::Large));
CHECK(contrastRatio(sec, roleColor(Role::BgCell)) >= textFloor(TextClass::Large));
// Tighter pins at the measured values: a regression that darkens secondary further would
// clear these before it clears the nominal 3:1/4.5:1 floors above, catching the drift early.
CHECK(contrastRatio(sec, roleColor(Role::BgCell)) >= 3.9);
CHECK(contrastRatio(roleColor(Role::BgBase), sec) >= 4.9);
} }
static void testWarnClearsStateFloorOnBackground() { static void testWarnClearsStateFloorOnBackground() {
@@ -244,15 +259,15 @@ static void testDisabledDropsAlphaAndDesaturates() {
// --- Direction C spectral ramp ------------------------------------------------ // --- Direction C spectral ramp ------------------------------------------------
static void testSpectralIsPastelRampAnchoredOnAccents() { static void testSpectralIsPastelRampAnchoredOnAccents() {
// DS-2 revised Direction C: the spectral ramp is a PASTEL sweep anchored on the three // DS-2 revised Direction C: the spectral ramp is a PASTEL sweep — lime (low) -> teal (mid)
// accents — lime (low) -> teal (mid) -> purple (high) — NOT the old neon cool->hot. // -> purple (high) — NOT the old neon cool->hot.
const KitColor lo = spectralColor(0.0); const KitColor lo = spectralColor(0.0);
const KitColor mid = spectralColor(0.5); const KitColor mid = spectralColor(0.5);
const KitColor hi = spectralColor(1.0); const KitColor hi = spectralColor(1.0);
// The three stops ARE the three accent constants (single source — the strip belongs to // The endpoints still ARE the primary/tertiary accent constants. The mid stop is NOT the
// the accent system). This is the load-bearing identity of the pastel ramp. // secondary accent and must not be re-aliased to it — see the monotonicity test below.
CHECK(lo == roleColor(Role::AccentPrimary)); // low = pastel lime CHECK(lo == roleColor(Role::AccentPrimary)); // low = pastel lime
CHECK(mid == roleColor(Role::AccentSecondary)); // mid = pastel teal CHECK(!(mid == roleColor(Role::AccentSecondary)));
CHECK(hi == roleColor(Role::AccentTertiary)); // high = pastel purple CHECK(hi == roleColor(Role::AccentTertiary)); // high = pastel purple
// Low is lime (green-dominant); high is purple (red+blue over green) — distinct hues. // Low is lime (green-dominant); high is purple (red+blue over green) — distinct hues.
CHECK(lo.g > lo.r && lo.g > lo.b); CHECK(lo.g > lo.r && lo.g > lo.b);
@@ -265,6 +280,17 @@ static void testSpectralIsPastelRampAnchoredOnAccents() {
CHECK(spectralColor(2.0) == hi); CHECK(spectralColor(2.0) == hi);
} }
// The strip's stops must fall in ONE direction, or position along it stops meaning anything.
// Nothing caught this when the mid stop aliased a categorical accent that was later darkened
// below the high stop, inverting lo->mid->hi; that is the break this guards.
static void testSpectralRampLuminanceIsMonotonic() {
const double lo = relativeLuminance(spectralColor(0.0));
const double mid = relativeLuminance(spectralColor(0.5));
const double hi = relativeLuminance(spectralColor(1.0));
CHECK(lo > mid);
CHECK(mid > hi);
}
int main() { int main() {
testContrastKnownAnchors(); testContrastKnownAnchors();
testTextPrimaryClearsBodyFloorOnSurfaces(); testTextPrimaryClearsBodyFloorOnSurfaces();
@@ -273,8 +299,10 @@ int main() {
testTextOnPastelFillClearsBodyFloor(); testTextOnPastelFillClearsBodyFloor();
testTextOnHoverSurfaceClearsFloor(); testTextOnHoverSurfaceClearsFloor();
testSecondaryTertiaryAreDistinguishable(); testSecondaryTertiaryAreDistinguishable();
testOverlayAccentSeparatesFromTheWaveformAccent(); testOverlayTraceClearsIndicatorFloorOnTheWaveform();
testLoopMarkerAndEnvelopeOverlayRolesAreDistinct(); testOverlayTraceClearsFloorOnItsDrawSurface();
testLoopMarkerAndOverlayTraceAreDistinct();
testAccentSecondaryDarkerTealClearsFloors();
testWarnClearsStateFloorOnBackground(); testWarnClearsStateFloorOnBackground();
testLabelOnActiveSurfaceClearsFloor(); testLabelOnActiveSurfaceClearsFloor();
testRolesAreDistinctAndElevationMonotonic(); testRolesAreDistinctAndElevationMonotonic();
@@ -283,6 +311,7 @@ int main() {
testActiveIsPrimaryAccent(); testActiveIsPrimaryAccent();
testDisabledDropsAlphaAndDesaturates(); testDisabledDropsAlphaAndDesaturates();
testSpectralIsPastelRampAnchoredOnAccents(); testSpectralIsPastelRampAnchoredOnAccents();
testSpectralRampLuminanceIsMonotonic();
if (g_fail == 0) std::printf("theme: all tests passed\n"); if (g_fail == 0) std::printf("theme: all tests passed\n");
else std::printf("theme: %d CHECK(s) FAILED\n", g_fail); else std::printf("theme: %d CHECK(s) FAILED\n", g_fail);