diff --git a/docs/product/visual-design-language.md b/docs/product/visual-design-language.md index 821574a..cde545a 100644 --- a/docs/product/visual-design-language.md +++ b/docs/product/visual-design-language.md @@ -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/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` ≈ `#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:1–7:1 on grey vs. ~15:1 -on `#12121x`). This is a real DS-2-revision tension: **the greyer background pulls the +margin is **much smaller than it was on near-black** (~7.6:1 primary / ~5.5:1 tertiary / +~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 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 @@ -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 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: -the same three hues that mean "live / category A / category B" elsewhere are the endpoints -and midpoint of the spectrum here, so the strip reads as an extension of the accent system, -not a separate neon flourish. The **active** zone still lifts to `accent/primary` + +the same hues that mean "live / category A / category B" elsewhere anchor the spectrum here, +so the strip reads as an extension of the accent system, not a separate neon flourish. +**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. - **Feel:** premium, almost visualizer-grade — but soft and cohesive, a pastel spectrum diff --git a/src/core/ui/CLAUDE.md b/src/core/ui/CLAUDE.md index d6869a8..274bb99 100644 --- a/src/core/ui/CLAUDE.md +++ b/src/core/ui/CLAUDE.md @@ -24,23 +24,34 @@ L7 sub-pass, 2026-07-27): - **DS-2 — palette is role-based, not hardcoded hue**, in one constants block (`theme`): `bg/base`, `bg/panel`, `bg/cell`, `line/hairline`, `text/primary`, `text/dim`, `accent/primary`, `accent/secondary`, `accent/tertiary`, - `accent/hot`, `warn`. Neutral ladder sits in REAPER's mid-grey theme family - (`bg/base` ≈ `#2b2b2b`, `bg/panel` ≈ `#333333`, `bg/cell` ≈ `#3a3a3a`, - `line/hairline` ≈ `#4a4a4a`, `text/primary` ≈ `#dcdcdc`, `text/dim` ≈ + `accent/hot`, `overlay/trace`, `warn`. Neutral ladder sits in REAPER's mid-grey + theme family (`bg/base` ≈ `#2b2b2b`, `bg/panel` ≈ `#333333`, `bg/cell` ≈ + `#3a3a3a`, `line/hairline` ≈ `#4a4a4a`, `text/primary` ≈ `#dcdcdc`, `text/dim` ≈ `~#a0a0a0`+), elevation-ladder discipline (base < panel < cell by a few %, - micro-gradient + inner highlight/shadow carry elevation, not hard borders). - Three pastel accents carry categorical meaning: **primary (pastel lime) = - live/active/selected**, secondary (pastel teal) + tertiary (pastel purple) = + micro-gradient + inner highlight/shadow carry elevation, not hard borders). The + accents carry categorical meaning: **primary (pastel lime) = + live/active/selected**, secondary (deep teal) + tertiary (pastel purple) = supporting categorical distinctions (kinds, not intensity). `warn` (red/amber) 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):** `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 - indicators/active fills on `bg/cell` have a shrunken contrast cushion - (~6:1–7:1, still clears 3:1 but re-check on any hue nudge). Body text on a - pastel fill is a tight AA 4.5:1 pair. Take the most pastel value that still - clears the floor, approached from the soft side, never re-saturated toward - neon "to be safe." + failure — must clear AA 4.5:1 body text. The accents as state indicators/active + fills on `bg/cell` have a shrunken contrast cushion (primary ~7.6:1, tertiary + ~5.5:1, secondary only ~3.9:1 since it was darkened — all still clear 3:1, but + re-check every one on any hue nudge). Body text on an accent fill is a tight AA + 4.5:1 pair, and for `accent/secondary` it is the *binding* limiter (~4.9:1), not + 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 just the draw layer.** Sub-frame hover/press/drag feedback must repaint 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. - `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. -- `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. - `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. diff --git a/src/core/ui/theme.cpp b/src/core/ui/theme.cpp index 97d95f9..7de97b2 100644 --- a/src/core/ui/theme.cpp +++ b/src/core/ui/theme.cpp @@ -34,24 +34,31 @@ constexpr KitColor kDirHairline {74, 74, 74, 255}; // #4a4a4a — subtle ste constexpr KitColor kDirTextPrimary{220, 220, 220, 255}; // #dcdcdc constexpr KitColor kDirTextDim {168, 168, 168, 255}; // #a8a8a8 (lifted from #a0a0a0) -// Three-accent pastel system: primary = pastel lime (the live/active/selected signal); -// secondary = pastel teal, tertiary = pastel purple (CATEGORICAL distinctions — a KIND, never -// intensity). accent/hot is a brighter tint OF the primary for hover/live/drag. On bg/cell the -// pastels clear the 3:1 indicator floor comfortably at these values (primary ~7.6, secondary -// ~6.8, tertiary ~5.5), so no per-hue nudge was needed. warn is reserved for byte-deleting -// states only. +// 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{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 kDirAccentHot {200, 236, 178, 255}; // #C8ECB2 — lighter pastel lime constexpr KitColor kDirWarn {235, 120, 90, 255}; // #eb785a — destructive only -// Spectral ramp: pastel lime (low) -> pastel teal (mid) -> pastel purple (high). Endpoints and -// midpoint ARE the three accent constants (single source), so the keyboard strip reads as an -// extension of the accent system. -constexpr KitColor kDirSpectralLo = kDirAccentPrimary; -constexpr KitColor kDirSpectralMid = kDirAccentSecondary; -constexpr KitColor kDirSpectralHi = kDirAccentTertiary; +// 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 ------------------------------------------------- @@ -108,6 +115,7 @@ KitColor roleColor(Role role) { 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 @@ -144,8 +152,8 @@ KitColor roleColorState(Role role, InteractionState state) { KitColor spectralColor(double t) { if (t < 0.0) t = 0.0; if (t > 1.0) t = 1.0; - // Interpolate each half separately so the midpoint IS the secondary accent (a single - // Lo->Hi lerp would skip it and drift the ramp off the accent family). + // 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); } diff --git a/src/core/ui/theme.h b/src/core/ui/theme.h index caaeb2b..6678fc9 100644 --- a/src/core/ui/theme.h +++ b/src/core/ui/theme.h @@ -1,14 +1,14 @@ #pragma once // 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, -// accent/secondary, accent/tertiary, accent/hot, warn), an interaction-state model -// (rest/hover/active/pressed/dragging/focus/disabled), and the WCAG contrast math that lets a -// unit test prove every text-on-surface pair clears its floor. +// accent/secondary, accent/tertiary, accent/hot, overlay/trace, warn), an interaction-state +// model (rest/hover/active/pressed/dragging/focus/disabled), and the WCAG contrast math that +// 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) — // 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 -// source, anchored on the three accents (primary -> secondary -> tertiary). +// source. #include @@ -36,9 +36,11 @@ enum class Role { TextPrimary, // labels, values TextDim, // secondary / units 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 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 }; @@ -70,9 +72,9 @@ KitColor roleColor(Role role); KitColor roleColorState(Role role, InteractionState state); // 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), -// so the strip reads as an extension of the accent system rather than a separate flourish. -// t is clamped to [0, 1]. +// high note) through a pastel lime -> teal -> purple arc, so the strip reads as part of the +// palette rather than a separate flourish. The arc is its own three stops — a categorical +// accent move must not be able to reorder it. t is clamped to [0, 1]. KitColor spectralColor(double t); // --- WCAG contrast (the "punch" rule, made testable) -------------------------- diff --git a/src/shell/instrument/editor_paint_waveform.cpp b/src/shell/instrument/editor_paint_waveform.cpp index 6af07fe..855f68e 100644 --- a/src/shell/instrument/editor_paint_waveform.cpp +++ b/src/shell/instrument/editor_paint_waveform.cpp @@ -25,11 +25,9 @@ using audio::computeEnvelope; namespace { // 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 -// longer share a role with the envelope overlay trace, which owns tertiary exclusively in -// this region (measured RGB was identical: #C2AAE8 for both). Secondary-vs-primary contrast -// 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. +// = teal (secondary). Markers are 2px bars and a 20%-alpha span fill, not the 1px trace, so +// they live with 1.92:1 against the waveform; the trace, which cannot, has its own role. +// Do not collapse the two back onto one role — they overlap in this rect. constexpr Role kRoleStartMarker = Role::AccentSecondary; constexpr Role kRoleLoopMarker = Role::AccentSecondary; } // namespace @@ -114,10 +112,8 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame); const std::vector poly = buildEnvelopePolyline(env, waveArea, totalSeconds); - // Trace the polyline in the categorical TERTIARY accent (purple): the waveform behind it is - // drawn in the primary lime, and the secondary teal this used to use sits too close to that - // 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)); + // Clip x to the wave rect. Knots are handles, not line vertices. + const LICE_pixel line = toLice(roleColor(Role::OverlayTrace)); const EnvVertex* prev = nullptr; for (const EnvVertex& v : poly) { 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 // handle is additionally clamped inside the band so one on an edge node never overhangs // 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)); for (const EnvVertex& v : poly) { if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseEnd) continue; diff --git a/tests/test_theme.cpp b/tests/test_theme.cpp index 5810310..09d4dea 100644 --- a/tests/test_theme.cpp +++ b/tests/test_theme.cpp @@ -121,40 +121,55 @@ static void testSecondaryTertiaryAreDistinguishable() { CHECK(delta >= 60); } -// The instrument's envelope overlay is traced OVER the waveform, which draws in the primary -// accent — an accent-on-accent pair no floor covers, since neither is a surface. It moved from -// the secondary to the tertiary for exactly this reason, so the pair is pinned two ways: the -// tertiary must separate from the primary MORE than the secondary did (the measurable half of -// the move), and the separation is a hue one, since two pastels sit close in luminance by -// construction. Whether the result reads clearly is a perceptual call, not this test's. -static void testOverlayAccentSeparatesFromTheWaveformAccent() { - const KitColor wave = roleColor(Role::AccentPrimary); - const KitColor overlay = roleColor(Role::AccentTertiary); - const KitColor prior = roleColor(Role::AccentSecondary); - CHECK(contrastRatio(overlay, wave) > contrastRatio(prior, wave)); - // Pin the MEASURED value, not just the relational improvement: today's pair sits at - // ~1.37:1 — BELOW the 3:1 indicator floor every other assertion in this file uses. That - // is a known, held deviation (Daniel's palette call, tracked separately) rather than a - // silently accepted one; a regression toward the old ~1.12:1 secondary pairing must fail. - CHECK(contrastRatio(overlay, wave) >= 1.35); - // 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); +// The envelope overlay is traced OVER the waveform, which draws in the primary accent — an +// accent-on-accent pair no surface floor covers, since neither is a surface. overlay/trace +// exists so that pair can clear the real 3:1 indicator floor; every categorical accent is light +// enough to sit under 2:1 against the lime, so pointing the trace back at one fails here. +static void testOverlayTraceClearsIndicatorFloorOnTheWaveform() { + CHECK(contrastRatio(roleColor(Role::OverlayTrace), roleColor(Role::AccentPrimary)) + >= textFloor(TextClass::Large)); +} + +// The trace's own surface floor. bg/base is the only surface it draws on (the waveform band +// fills with it), and that confinement is forced, not convenient: clearing 3:1 against the lime +// caps the trace's luminance at 0.183, while clearing 3:1 on bg/cell would demand 0.227. The two +// are mutually exclusive, so this role must not be reused outside that band. +static void testOverlayTraceClearsFloorOnItsDrawSurface() { + CHECK(contrastRatio(roleColor(Role::OverlayTrace), roleColor(Role::BgBase)) + >= textFloor(TextClass::Large)); } // 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, -// which the envelope overlay now owns exclusively there); a future palette edit that collapses -// the two back to one role must fail here rather than silently recreating the finding. -static void testLoopMarkerAndEnvelopeOverlayRolesAreDistinct() { +// the SAME overlay rect, so they must read as two things. Luminance cannot carry that — each is +// pinned near its own floor — so the separation is a hue one: teal leans green over red, the +// trace violet leans red over green. A palette edit collapsing them onto one role fails here. +static void testLoopMarkerAndOverlayTraceAreDistinct() { const KitColor loopMarker = roleColor(Role::AccentSecondary); - const KitColor envelopeOverlay = roleColor(Role::AccentTertiary); - CHECK(!(loopMarker == envelopeOverlay)); + const KitColor trace = roleColor(Role::OverlayTrace); + 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() { @@ -244,15 +259,15 @@ static void testDisabledDropsAlphaAndDesaturates() { // --- Direction C spectral ramp ------------------------------------------------ static void testSpectralIsPastelRampAnchoredOnAccents() { - // DS-2 revised Direction C: the spectral ramp is a PASTEL sweep anchored on the three - // accents — lime (low) -> teal (mid) -> purple (high) — NOT the old neon cool->hot. + // DS-2 revised Direction C: the spectral ramp is a PASTEL sweep — lime (low) -> teal (mid) + // -> purple (high) — NOT the old neon cool->hot. const KitColor lo = spectralColor(0.0); const KitColor mid = spectralColor(0.5); const KitColor hi = spectralColor(1.0); - // The three stops ARE the three accent constants (single source — the strip belongs to - // the accent system). This is the load-bearing identity of the pastel ramp. + // The endpoints still ARE the primary/tertiary accent constants. The mid stop is NOT the + // secondary accent and must not be re-aliased to it — see the monotonicity test below. 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 // Low is lime (green-dominant); high is purple (red+blue over green) — distinct hues. CHECK(lo.g > lo.r && lo.g > lo.b); @@ -265,6 +280,17 @@ static void testSpectralIsPastelRampAnchoredOnAccents() { 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() { testContrastKnownAnchors(); testTextPrimaryClearsBodyFloorOnSurfaces(); @@ -273,8 +299,10 @@ int main() { testTextOnPastelFillClearsBodyFloor(); testTextOnHoverSurfaceClearsFloor(); testSecondaryTertiaryAreDistinguishable(); - testOverlayAccentSeparatesFromTheWaveformAccent(); - testLoopMarkerAndEnvelopeOverlayRolesAreDistinct(); + testOverlayTraceClearsIndicatorFloorOnTheWaveform(); + testOverlayTraceClearsFloorOnItsDrawSurface(); + testLoopMarkerAndOverlayTraceAreDistinct(); + testAccentSecondaryDarkerTealClearsFloors(); testWarnClearsStateFloorOnBackground(); testLabelOnActiveSurfaceClearsFloor(); testRolesAreDistinctAndElevationMonotonic(); @@ -283,6 +311,7 @@ int main() { testActiveIsPrimaryAccent(); testDisabledDropsAlphaAndDesaturates(); testSpectralIsPastelRampAnchoredOnAccents(); + testSpectralRampLuminanceIsMonotonic(); if (g_fail == 0) std::printf("theme: all tests passed\n"); else std::printf("theme: %d CHECK(s) FAILED\n", g_fail);