Merge palette-darker-teal: deep-teal secondary, an owned spectral mid, and an overlay trace that clears the indicator floor

This commit is contained in:
2026-07-31 16:46:56 -04:00
10 changed files with 324 additions and 120 deletions
+29 -6
View File
@@ -416,14 +416,34 @@ against the WCAG tests — §2.1):
| Role | Pastel | Starting RGB (hex) | Job | | Role | Pastel | Starting RGB (hex) | Job |
|---|---|---|---| |---|---|---|---|
| `accent/primary` | pastel lime green | `~176,224,152` (`#B0E098`) | the live/active/selected signal — the eye-magnet | | `accent/primary` | pastel lime green | `~176,224,152` (`#B0E098`) | the live/active/selected signal — the eye-magnet |
| `accent/secondary` | pastel teal | `~132,214,208` (`#84D6D0`) | categorical role A (non-active zones, a second cluster) | | `accent/secondary` | pastel teal | `~132,214,208` (`#84D6D0`) *(superseded, see below)* | categorical role A (non-active zones, a second cluster) |
| `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). Its **binding
limiter** — the pair that stops it going darker — is the velocity-curve trace on a
*hover-lightened* `bg/cell` at **3.03:1** against a 3:1 floor, tighter than either the AA 4.5:1
text-on-fill pair (4.91:1 on `bg/base`) or the indicator floor on rest `bg/cell` (3.94:1). ONE
new Role was 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). The spectral ramp's mid stop also became its own value —
the original pastel teal `#84D6D0` — but as a private constant, not a `Role`.
**The two-neighbour problem** (rule + numbers owned by `src/core/ui/CLAUDE.md`; the design
consequence is recorded here). A mark drawn *over* an accent fill has to read against the fill
*and* the surface behind it, which caps *any* single value at ≈3.07:1 against both. Two
consequences shape the design rather than the palette: such a role is confined to the band it
was picked for, and a **state** of that mark cannot be expressed as a hotter color — every
value that clears the ceiling sits within 1.05:1 of every other, so the grabbed envelope handle
signals by **size and a punched-out core** instead. That deliberately inverts the kit's
"brighter = hotter" convention in this one band, because over a light fill a brighter tint is a
*lower*-contrast tint (`accent/hot` measures 1.15:1 against the lime).
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 +490,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
+46 -14
View File
@@ -24,23 +24,47 @@ 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 (4.91:1 for `accent/secondary`). **The *binding* limiter on how dark
neon "to be safe." `accent/secondary` may go is neither of those: it is the velocity-curve trace on
a HOVER-lightened `bg/cell` at 3.03:1 against a 3:1 floor** — 1.2% headroom, the
tightest pair in the tree, and invisible to any rest-surface check. Take the most
pastel value that still clears the floor, approached from the soft side, never
re-saturated toward neon "to be safe."
- **A state surface is a DIFFERENT surface.** Hover lightens toward `accent/hot`,
pressed darkens, disabled desaturates — each changes the contrast of everything
drawn on it. Enumerate the state surfaces a role is drawn over, not just the rest
ones; the limiter above is exactly the pair that enumeration missed.
- **A mark drawn OVER an accent fill answers to two neighbours at once.** THE home
for this rule. `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. Three consequences, all load-bearing:
the role is confined to the waveform band (no value clears 3:1 on `bg/cell` and
against the lime at once); inside the 20%-alpha loop-span fill it is under floor
at 2.25:1, and against `line/hairline`'s zero line at 1.92:1 — both KNOWN,
ACCEPTED, and asserted as such, because no trace value fixes either; and any two
values that BOTH clear the ceiling sit within 1.05:1 of each other, so a state of
this mark (grabbed, hot, selected) **cannot** be carried by color and must be
carried by size, fill-vs-outline, or shape.
- **"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 +92,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, WCAG contrast-floor helpers + `compositeOver` (the effective color of a translucent fill, so alpha overlays are testable). Only the ramp's MID stop is its own constant; lo/hi are still aliases of `accent/primary`/`accent/tertiary`, so a categorical accent move CAN still reorder the ramp — `testSpectralRampLuminanceIsMonotonic` is the build-time catch, not the structure.
- `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.
@@ -82,7 +106,15 @@ L7 sub-pass, 2026-07-27):
- The WCAG contrast-floor pairs above are real math in `theme`'s tests, not a - The WCAG contrast-floor pairs above are real math in `theme`'s tests, not a
visual eyeball check — any new hue or role needs its own contrast-floor visual eyeball check — any new hue or role needs its own contrast-floor
assertion. assertion. Enumerate the draw sites by grep, not from memory: the enumeration
must cover state surfaces (hover/pressed/active), translucent composites, and
anything else drawn into the same rect, not just the rest surfaces.
- **A text color's floor is set by its FONT, not by its role.** WCAG's large-scale
class needs ≥24px, or ≥18.66px BOLD (`theme.h`'s thresholds); anything smaller is
body class and answers to 4.5:1. A draw site relying on the 3:1 floor must pin its
font metrics against those constants — `draw_kit.cpp`'s `Font::RegionTitle` is the
worked example. A prior revision wrote the threshold ~25% low and let 15px
semibold clear a floor it was not entitled to.
- `card_drag`'s precedence order must stay a pure decision helper mirroring - `card_drag`'s precedence order must stay a pure decision helper mirroring
`drag_out::decideGesture` — don't let a shell reimplement gesture precedence `drag_out::decideGesture` — don't let a shell reimplement gesture precedence
ad hoc; the cursor-cue mapping in the shell must stay a thin lookup over the ad hoc; the cursor-cue mapping in the shell must stay a thin lookup over the
+27 -15
View File
@@ -34,24 +34,29 @@ 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 — roles and their categorical meaning are core/ui/CLAUDE.md's. Warning only:
// secondary = pastel teal, tertiary = pastel purple (CATEGORICAL distinctions — a KIND, never // secondary CANNOT go darker. Its tightest pair is the velocity-curve trace on a HOVER-lightened
// intensity). accent/hot is a brighter tint OF the primary for hover/live/drag. On bg/cell the // bg/cell (3.03:1 against a 3:1 floor — the whole tree's thinnest margin), which no rest-surface
// pastels clear the 3:1 indicator floor comfortably at these values (primary ~7.6, secondary // check sees.
// ~6.8, tertiary ~5.5), so no per-hue nudge was needed. 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. Warning only (the two-neighbour rule is
// midpoint ARE the three accent constants (single source), so the keyboard strip reads as an // core/ui/CLAUDE.md's): this value has NO room in either direction, and it is confined to the
// extension of the accent system. // waveform band — inside the 20%-alpha loop-span fill it already sits under floor at 2.25:1,
constexpr KitColor kDirSpectralLo = kDirAccentPrimary; // which no single value can fix.
constexpr KitColor kDirSpectralMid = kDirAccentSecondary; constexpr KitColor kDirOverlayTrace{129, 106, 166, 255}; // #816AA6 — muted violet
constexpr KitColor kDirSpectralHi = kDirAccentTertiary;
// 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 ------------------------------------------------- // --- state transform helpers -------------------------------------------------
@@ -108,6 +113,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,14 +150,20 @@ 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);
} }
return mix(kDirSpectralMid, kDirSpectralHi, (t - 0.5) / 0.5); return mix(kDirSpectralMid, kDirSpectralHi, (t - 0.5) / 0.5);
} }
KitColor compositeOver(const KitColor& over, const KitColor& under, double alpha) {
if (alpha < 0.0) alpha = 0.0;
if (alpha > 1.0) alpha = 1.0;
return mix(under, over, alpha);
}
double relativeLuminance(const KitColor& c) { double relativeLuminance(const KitColor& c) {
return 0.2126 * linearizeChannel(c.r) + return 0.2126 * linearizeChannel(c.r) +
0.7152 * linearizeChannel(c.g) + 0.7152 * linearizeChannel(c.g) +
+30 -9
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
}; };
@@ -54,7 +56,14 @@ enum class InteractionState {
Disabled, Disabled,
}; };
// Text size classes for the WCAG floor: "Large" (>= ~18.66px, or >= ~14px bold) and UI-state // WCAG 2.1's large-scale thresholds, in px at 96dpi (18pt = 24px normal, 14pt = 18.66px bold).
// A previous revision wrote the BOLD threshold as the normal one and both ~25% low, which let
// 15px semibold self-classify as Large and clear a floor it was not entitled to — draw sites
// that want the Large floor static_assert their font against these.
inline constexpr double kLargeTextMinPx = 24.0;
inline constexpr double kLargeTextMinBoldPx = 18.66;
// Text size classes for the WCAG floor: "Large" (>= 24px, or >= ~18.66px BOLD) and UI-state
// indicators clear at 3:1; body text clears at 4.5:1 (WCAG 2.1 AA). // indicators clear at 3:1; body text clears at 4.5:1 (WCAG 2.1 AA).
enum class TextClass { enum class TextClass {
Body, // AA 4.5:1 Body, // AA 4.5:1
@@ -70,15 +79,27 @@ 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 MID stop is its own value; lo/hi remain aliases
// of accent/primary and accent/tertiary, so a categorical accent move can still reorder the
// ramp — testSpectralRampLuminanceIsMonotonic is the build-time catch, not the structure.
// t is clamped to [0, 1]. // 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) --------------------------
// The effective opaque color of `over` drawn at `alpha` on top of `under` — the composition the
// contrast math needs before it can judge a translucent fill. alpha is clamped to [0, 1]; the
// result carries `under`'s alpha byte.
KitColor compositeOver(const KitColor& over, const KitColor& under, double alpha);
// The waveform's loop-span fill alpha. Named HERE rather than at its draw site so the contrast
// test composes the same value the shell draws with (see compositeOver).
inline constexpr double kLoopSpanFillAlpha = 0.20;
// Relative luminance per WCAG 2.1 (sRGB linearization + 0.2126/0.7152/0.0722 weighting). Alpha // Relative luminance per WCAG 2.1 (sRGB linearization + 0.2126/0.7152/0.0722 weighting). Alpha
// is ignored — a translucent overlay's effective color is the caller's to compose first. // is ignored — a translucent overlay's effective color is the caller's to compose first
// (compositeOver).
double relativeLuminance(const KitColor& c); double relativeLuminance(const KitColor& c);
// WCAG contrast ratio between two colors, in [1, 21]. Symmetric. // WCAG contrast ratio between two colors, in [1, 21]. Symmetric.
+38 -20
View File
@@ -25,13 +25,21 @@ 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 translucent 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. The trace crossing
// here measures 1.12:1, below tertiary's 1.37:1 — accepted deliberately: markers are bars // the loop-span fill is a KNOWN, ACCEPTED under-floor pair (2.25:1 against a 3:1 floor), and no
// and a span fill, not the 1px trace that pairing was moved off of. // trace value fixes it — see the two-neighbour rule in core/ui/CLAUDE.md. If it is ever
// resolved, the FILL is what changes; do not nudge a color to chase it.
constexpr Role kRoleStartMarker = Role::AccentSecondary; constexpr Role kRoleStartMarker = Role::AccentSecondary;
constexpr Role kRoleLoopMarker = Role::AccentSecondary; constexpr Role kRoleLoopMarker = Role::AccentSecondary;
// Envelope-handle half-extents. Grabbed grows and hollows out; kNodeGrabRadius (envelope_edit)
// is the PICK radius and is unrelated — a handle may draw larger than it without widening any
// hit region.
constexpr int kEnvHandleRadius = 3;
constexpr int kEnvHandleGrabbedRadius = 5;
constexpr int kEnvHandleRingPx = 2;
} // namespace } // namespace
void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) { void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
@@ -86,7 +94,8 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
const int rx = frameToX(overlay, frames, m.loopEnd); const int rx = frameToX(overlay, frames, m.loopEnd);
if (rx > lx) { if (rx > lx) {
LICE_FillRect(bmp, lx, overlayRect.y, rx - lx, overlayRect.height, LICE_FillRect(bmp, lx, overlayRect.y, rx - lx, overlayRect.height,
toLice(roleColor(kRoleLoopMarker)), 0.20f, 0); toLice(roleColor(kRoleLoopMarker)),
static_cast<float>(kLoopSpanFillAlpha), 0);
} }
} }
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
@@ -114,10 +123,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;
@@ -128,24 +135,35 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea
} }
prev = &v; prev = &v;
} }
// Handles: a square per draggable stage node, a ROUND knot per curvable segment. Lit // Handles: a square per draggable stage node, a ROUND knot per curvable segment. Every
// accent-hot when this node is the grabbed one. Every vertex is guaranteed in-bounds; the // vertex is guaranteed in-bounds; the handle is additionally clamped inside the band so one
// handle is additionally clamped inside the band so one on an edge node never overhangs // on an edge node never overhangs into the neighbouring bands.
// into the neighbouring bands. //
const LICE_pixel handle = toLice(roleColor(Role::AccentTertiary)); // GRAB is signalled by SIZE + a punched-out core, NOT by a hotter hue — the one place the
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); // kit's "brighter = hotter" convention is deliberately inverted, because a brighter tint is
// a LOWER-contrast tint here: accent/hot sits at 1.15:1 against the lime it is drawn over.
// The two-neighbour ceiling (core/ui/CLAUDE.md) leaves at most 1.05:1 between ANY two values
// that both clear the floor over lime and bg/base, so no color can carry this state. The
// grabbed mark stays overlay/trace and reads by its 3.07:1 ring against the lime plus a
// 3.06:1 bg/base core inside it.
const LICE_pixel handle = toLice(roleColor(Role::OverlayTrace));
const LICE_pixel core = toLice(roleColor(Role::BgBase));
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;
const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node); const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node);
const int r = 3; const int r = grabbed ? kEnvHandleGrabbedRadius : kEnvHandleRadius;
const int ir = r - kEnvHandleRingPx; // core radius; > 0 only when grabbed
const int hx = (std::max)(area.x + r, (std::min)(area.right() - 1 - r, v.x)); const int hx = (std::max)(area.x + r, (std::min)(area.right() - 1 - r, v.x));
const int hy = (std::max)(area.y + r, (std::min)(area.bottom() - 1 - r, v.y)); const int hy = (std::max)(area.y + r, (std::min)(area.bottom() - 1 - r, v.y));
if (v.knot) { if (v.knot) {
LICE_FillCircle(bmp, static_cast<float>(hx), static_cast<float>(hy), LICE_FillCircle(bmp, static_cast<float>(hx), static_cast<float>(hy),
static_cast<float>(r), grabbed ? handleHot : handle, 1.0f, 0, true); static_cast<float>(r), handle, 1.0f, 0, true);
if (grabbed)
LICE_FillCircle(bmp, static_cast<float>(hx), static_cast<float>(hy),
static_cast<float>(ir), core, 1.0f, 0, true);
} else { } else {
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, handle, 1.0f, 0);
0); if (grabbed) LICE_FillRect(bmp, hx - ir, hy - ir, 2 * ir, 2 * ir, core, 1.0f, 0);
} }
} }
} }
+13
View File
@@ -40,7 +40,17 @@ namespace {
// this converts a KitColor's 8-bit alpha so a disabled surface composites at the right opacity. // this converts a KitColor's 8-bit alpha so a disabled surface composites at the right opacity.
float drawAlpha(const KitColor& c) { return c.a / 255.0f; } float drawAlpha(const KitColor& c) { return c.a / 255.0f; }
// Font::RegionTitle exists so an ACCENT-colored title can answer to the 3:1 indicator floor
// instead of the 4.5:1 body floor. That entitlement is the font's, not the color's, so the
// metrics are pinned here against theme's thresholds: shrink either one and the build stops
// rather than silently reclassifying every pair drawn in it.
constexpr int kRegionTitlePx = 19;
constexpr int kRegionTitleWeight = FW_BOLD;
static_assert(kRegionTitlePx >= ui::kLargeTextMinBoldPx, "region title must clear WCAG large");
static_assert(kRegionTitleWeight >= FW_BOLD, "the large-bold threshold requires bold, not semi");
struct KitFonts { struct KitFonts {
LICE_CachedFont regionTitle;
LICE_CachedFont title; LICE_CachedFont title;
LICE_CachedFont label; LICE_CachedFont label;
LICE_CachedFont valueMono; LICE_CachedFont valueMono;
@@ -65,6 +75,7 @@ void loadFont(LICE_CachedFont& dst, int pxHeight, int weight, const char* face)
LICE_CachedFont* fontFor(Font f) { LICE_CachedFont* fontFor(Font f) {
if (!g_fonts.ready) return nullptr; if (!g_fonts.ready) return nullptr;
switch (f) { switch (f) {
case Font::RegionTitle: return &g_fonts.regionTitle;
case Font::Title: return &g_fonts.title; case Font::Title: return &g_fonts.title;
case Font::Label: return &g_fonts.label; case Font::Label: return &g_fonts.label;
case Font::ValueMono: return &g_fonts.valueMono; case Font::ValueMono: return &g_fonts.valueMono;
@@ -136,6 +147,7 @@ RECT toRect(const KitBox& b) {
void kitFontsInit() { void kitFontsInit() {
if (g_fonts.ready) return; // idempotent if (g_fonts.ready) return; // idempotent
loadFont(g_fonts.regionTitle, kRegionTitlePx, kRegionTitleWeight, "Segoe UI");
loadFont(g_fonts.title, 15, FW_SEMIBOLD, "Segoe UI"); loadFont(g_fonts.title, 15, FW_SEMIBOLD, "Segoe UI");
loadFont(g_fonts.label, 12, FW_NORMAL, "Segoe UI"); loadFont(g_fonts.label, 12, FW_NORMAL, "Segoe UI");
loadFont(g_fonts.valueMono, 12, FW_NORMAL, "Consolas"); loadFont(g_fonts.valueMono, 12, FW_NORMAL, "Consolas");
@@ -147,6 +159,7 @@ void kitFontsShutdown() {
if (!g_fonts.ready) return; // idempotent if (!g_fonts.ready) return; // idempotent
// g_fonts is a static instance, never re-created, so free the HFONTs explicitly: // g_fonts is a static instance, never re-created, so free the HFONTs explicitly:
// handing each a null font with OWNS_HFONT cleans up the prior HFONT (lice_text.h). // handing each a null font with OWNS_HFONT cleans up the prior HFONT (lice_text.h).
g_fonts.regionTitle.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
g_fonts.title.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT); g_fonts.title.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
g_fonts.label.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT); g_fonts.label.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
g_fonts.valueMono.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT); g_fonts.valueMono.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
+8 -7
View File
@@ -42,13 +42,14 @@ using ui::ListRowBox;
using ui::Role; using ui::Role;
using ui::SliderGeometry; using ui::SliderGeometry;
// The kit's four cached fonts. Consumers pass a Font to text() to pick the size/weight; // The kit's cached fonts. Consumers pass a Font to text() to pick the size/weight; the kit
// the kit maps it to the matching LICE_CachedFont. // maps it to the matching LICE_CachedFont.
enum class Font { enum class Font {
Title, // ~15px semibold — region titles, headings RegionTitle, // 19px BOLD — text that must qualify as WCAG large-scale (3:1 floor)
Label, // ~12px regular — labels, body Title, // ~15px semibold — headings (BODY class: 4.5:1 floor)
ValueMono, // ~12px tabular/mono — numbers (dB/ms/notes) that must not jitter Label, // ~12px regular — labels, body
Micro, // ~10px dim — units, counts, keybinding sub-labels ValueMono, // ~12px tabular/mono — numbers (dB/ms/notes) that must not jitter
Micro, // ~10px dim — units, counts, keybinding sub-labels
}; };
// Horizontal text alignment for text(). Vertical is always centered in the rect (the kit's // Horizontal text alignment for text(). Vertical is always centered in the rect (the kit's
@@ -58,7 +59,7 @@ enum class Align { Left, Center, Right };
// The one place a pure KitColor becomes a LICE_pixel. Defined in draw_kit.cpp. // The one place a pure KitColor becomes a LICE_pixel. Defined in draw_kit.cpp.
LICE_pixel toLice(const KitColor& c); LICE_pixel toLice(const KitColor& c);
// Creates the four cached fonts once; idempotent. Segoe UI for title/label/micro, // Creates the cached fonts once; idempotent. Segoe UI for region-title/title/label/micro,
// Consolas (tabular) for value-mono. No-op-safe: if font creation fails, text() // Consolas (tabular) for value-mono. No-op-safe: if font creation fails, text()
// draws nothing rather than crashing. // draws nothing rather than crashing.
void kitFontsInit(); void kitFontsInit();
+4 -2
View File
@@ -327,12 +327,14 @@ void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title,
// Title, left. The two regions are distinct KINDS of container, so the title carries a // Title, left. The two regions are distinct KINDS of container, so the title carries a
// CATEGORICAL accent (secondary/tertiary mark kinds, never intensity) — Pool = secondary // CATEGORICAL accent (secondary/tertiary mark kinds, never intensity) — Pool = secondary
// teal, Banks = tertiary purple. This is a category mark, NOT the "what's live" signal // teal, Banks = tertiary purple. This is a category mark, NOT the "what's live" signal
// (that stays the primary-lime "Active:" readout beside it). // (that stays the primary-lime "Active:" readout beside it). Font::RegionTitle, not
// Font::Title: an accent this deep only clears the 3:1 indicator floor, so the type has to
// be large enough to be ENTITLED to it — see draw_kit.cpp's metrics static_assert.
RECT titleRc = hdr; RECT titleRc = hdr;
titleRc.left += 8; titleRc.left += 8;
titleRc.right = titleRc.left + 120; titleRc.right = titleRc.left + 120;
const Role titleRole = poolBtnIsPool ? Role::AccentSecondary : Role::AccentTertiary; const Role titleRole = poolBtnIsPool ? Role::AccentSecondary : Role::AccentTertiary;
kitText(bmp, toKitBox(titleRc), title, Font::Title, titleRole, Align::Left); kitText(bmp, toKitBox(titleRc), title, Font::RegionTitle, titleRole, Align::Left);
// Active-bank readout — the UNMISTAKABLE indicator, in the PRIMARY accent role in BOTH // Active-bank readout — the UNMISTAKABLE indicator, in the PRIMARY accent role in BOTH
// region headers so the active/capture-target bank is legible even when it is not the // region headers so the active/capture-target bank is legible even when it is not the
+3 -1
View File
@@ -198,7 +198,9 @@ inline constexpr int kTooltipCharPx = 7;
inline constexpr int kTooltipTextH = 14; inline constexpr int kTooltipTextH = 14;
// Client area top to bottom: top toolbar | split body | bottom toolbar | footer. // Client area top to bottom: top toolbar | split body | bottom toolbar | footer.
inline constexpr int kRegionHeaderHeight = 24; // 26, not 24: Font::RegionTitle's line box (19px em + Segoe UI's leading) is ~25px, and
// DT_VCENTER clips to the rect.
inline constexpr int kRegionHeaderHeight = 26;
inline constexpr int kTabStripHeight = 26; inline constexpr int kTabStripHeight = 26;
inline constexpr int kSplitDividerHeight = 3; inline constexpr int kSplitDividerHeight = 3;
inline constexpr int kFullHtBtnWidth = 22; inline constexpr int kFullHtBtnWidth = 22;
+126 -46
View File
@@ -64,12 +64,11 @@ static void testTextDimClearsItsFloorOnSurfaces() {
} }
static void testAccentsClearIndicatorFloorOnCell() { static void testAccentsClearIndicatorFloorOnCell() {
// DS-2 revised (grey re-read): each of the three pastel accents is used as a state // Each accent is used as a state indicator / active fill on bg/cell (selection border,
// indicator / active fill on bg/cell (selection border, active segment, active tab). // active segment, active tab). The move to REAPER grey shrank the cushion from ~15:1 on
// The greyer surface shrank the cushion from ~15:1 (near-black) to ~6-7:1 (grey), so // near-black to 3.9:1 (secondary) - 8.7:1 (hot), so the floor is verified on bg/cell, NOT
// this is re-verified on bg/cell (NOT the old near-black) at the 3:1 large/indicator // the old near-black. If a pastel drops below, nudge that hue deeper; hue stays
// floor. If any pastel dropped below, the fix is to nudge that hue deeper (hue stays // lime/teal/purple.
// lime/teal/purple) — the value the test locks proves it did not need it here.
for (Role a : {Role::AccentPrimary, Role::AccentSecondary, Role::AccentTertiary, for (Role a : {Role::AccentPrimary, Role::AccentSecondary, Role::AccentTertiary,
Role::AccentHot}) { Role::AccentHot}) {
CHECK(contrastRatio(roleColor(a), roleColor(Role::BgCell)) CHECK(contrastRatio(roleColor(a), roleColor(Role::BgCell))
@@ -78,13 +77,21 @@ static void testAccentsClearIndicatorFloorOnCell() {
// The primary accent also leads the mode-switch/tab active fill drawn over bg/base. // The primary accent also leads the mode-switch/tab active fill drawn over bg/base.
CHECK(contrastRatio(roleColor(Role::AccentPrimary), roleColor(Role::BgBase)) CHECK(contrastRatio(roleColor(Role::AccentPrimary), roleColor(Role::BgBase))
>= textFloor(TextClass::Large)); >= textFloor(TextClass::Large));
// bank_panel draws categorical accents as text on bg/panel (region-title band): }
// AccentSecondary/Tertiary as region-title text; AccentPrimary as the "Active:" readout.
// Verify all three clear the 3:1 large/indicator floor on bg/panel (the ACTUAL draw surface). // The docked panel's region-title band: secondary/tertiary as the title itself, primary as the
// "Active:" readout beside it, all on bg/panel. The titles clear the 3:1 indicator floor and
// NOT the 4.5:1 body floor (secondary sits at 4.38:1), so they are drawn in Font::RegionTitle —
// 19px bold, which is what ENTITLES them to the large-text floor. draw_kit.cpp static_asserts
// those metrics against theme's thresholds; that assert and this pair are one guard in two
// halves, and removing either re-opens the AA failure that 15px semibold hid.
static void testRegionTitleAccentsClearLargeFloorOnPanel() {
for (Role a : {Role::AccentPrimary, Role::AccentSecondary, Role::AccentTertiary}) { for (Role a : {Role::AccentPrimary, Role::AccentSecondary, Role::AccentTertiary}) {
CHECK(contrastRatio(roleColor(a), roleColor(Role::BgPanel)) CHECK(contrastRatio(roleColor(a), roleColor(Role::BgPanel))
>= textFloor(TextClass::Large)); >= textFloor(TextClass::Large));
} }
// Tighter pin at the measured value, so drift is caught before it reaches the floor.
CHECK(contrastRatio(roleColor(Role::AccentSecondary), roleColor(Role::BgPanel)) >= 4.3);
} }
// Text drawn ON a pastel accent fill (retained tight pair, re-verified for the pastels): // Text drawn ON a pastel accent fill (retained tight pair, re-verified for the pastels):
@@ -121,40 +128,95 @@ 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. Every categorical
// the secondary to the tertiary for exactly this reason, so the pair is pinned two ways: the // accent is light enough to sit under 2:1 against the lime, so pointing the trace back at one
// tertiary must separate from the primary MORE than the secondary did (the measurable half of // 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, on the bg/base the waveform band fills with. This pair does
CHECK(contrastRatio(overlay, wave) > contrastRatio(prior, wave)); // double duty: the GRABBED envelope handle is an overlay/trace ring around a bg/base core, so
// Pin the MEASURED value, not just the relational improvement: today's pair sits at // the same number is also that mark's internal ring-vs-core contrast.
// ~1.37:1 — BELOW the 3:1 indicator floor every other assertion in this file uses. That static void testOverlayTraceClearsFloorOnItsDrawSurface() {
// is a known, held deviation (Daniel's palette call, tracked separately) rather than a CHECK(contrastRatio(roleColor(Role::OverlayTrace), roleColor(Role::BgBase))
// silently accepted one; a regression toward the old ~1.12:1 secondary pairing must fail. >= textFloor(TextClass::Large));
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. // The velocity-curve trace is drawn in accent/secondary over a HOVER-lightened bg/cell (the
CHECK(wave.g > wave.r); // chrome band's mini preview button). Hover lightens the surface toward accent/hot, closing on
CHECK(overlay.r > overlay.g); // the deep teal from below: 3.03:1 against a 3:1 floor — the thinnest margin in the tree, and
const int delta = std::abs(int(wave.r) - int(overlay.r)) + // the true binding limiter on how dark accent/secondary may go. No rest-surface check sees it.
std::abs(int(wave.g) - int(overlay.g)) + static void testCurveTraceOnHoverSurfaceClearsFloor() {
std::abs(int(wave.b) - int(overlay.b)); CHECK(contrastRatio(roleColor(Role::AccentSecondary),
CHECK(delta >= 60); roleColorState(Role::BgCell, InteractionState::Hover))
>= textFloor(TextClass::Large));
}
// The trace's OTHER two neighbours in the waveform rect, both composited or drawn beneath it.
// Enumerating only bg/base under-counted them.
// - The loop-span fill: accent/secondary at kLoopSpanFillAlpha over bg/base. The trace over
// that composite is 2.25:1 — a KNOWN, ACCEPTED under-floor pair, pinned here as a RANGE so
// it is recorded rather than silently believed to clear. The two-neighbour ceiling means no
// trace value fixes it (clearing the fill breaks the lime or bg/base pair); if it is ever
// resolved, the FILL is what changes, and this assertion is what will fail first.
// - drawWaveform's line/hairline zero line, at 1.92:1. Also under floor, and also unfixable
// from the trace's side.
static void testOverlayTraceAgainstItsRemainingNeighbours() {
const KitColor loopFill = compositeOver(roleColor(Role::AccentSecondary),
roleColor(Role::BgBase), kLoopSpanFillAlpha);
const double onFill = contrastRatio(roleColor(Role::OverlayTrace), loopFill);
CHECK(onFill >= 2.2 && onFill < textFloor(TextClass::Large));
const double onZeroLine = contrastRatio(roleColor(Role::OverlayTrace),
roleColor(Role::LineHairline));
CHECK(onZeroLine >= 1.9 && onZeroLine < textFloor(TextClass::Large));
}
// compositeOver is the arithmetic the pairs above depend on, so anchor it: full alpha is the
// overlay, zero alpha is the surface, and the composite of a lighter color over a darker one
// lands strictly between the two.
static void testCompositeOverAnchors() {
const KitColor over = roleColor(Role::AccentSecondary);
const KitColor under = roleColor(Role::BgBase);
CHECK(compositeOver(over, under, 1.0) == over);
CHECK(compositeOver(over, under, 0.0) == under);
CHECK(compositeOver(over, under, 5.0) == over); // alpha clamps
CHECK(compositeOver(over, under, -1.0) == under);
const double mid = relativeLuminance(compositeOver(over, under, 0.5));
CHECK(mid > relativeLuminance(under) && mid < relativeLuminance(over));
} }
// 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 nudge that erodes
// past either the 3:1 indicator floor or the AA 4.5:1 text-on-fill floor fails the build. The
// binding limiter on going darker is NOT one of these — it is the hover-surface pair above.
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 +306,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,16 +327,33 @@ 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();
testTextDimClearsItsFloorOnSurfaces(); testTextDimClearsItsFloorOnSurfaces();
testAccentsClearIndicatorFloorOnCell(); testAccentsClearIndicatorFloorOnCell();
testRegionTitleAccentsClearLargeFloorOnPanel();
testTextOnPastelFillClearsBodyFloor(); testTextOnPastelFillClearsBodyFloor();
testTextOnHoverSurfaceClearsFloor(); testTextOnHoverSurfaceClearsFloor();
testCurveTraceOnHoverSurfaceClearsFloor();
testSecondaryTertiaryAreDistinguishable(); testSecondaryTertiaryAreDistinguishable();
testOverlayAccentSeparatesFromTheWaveformAccent(); testOverlayTraceClearsIndicatorFloorOnTheWaveform();
testLoopMarkerAndEnvelopeOverlayRolesAreDistinct(); testOverlayTraceClearsFloorOnItsDrawSurface();
testOverlayTraceAgainstItsRemainingNeighbours();
testCompositeOverAnchors();
testLoopMarkerAndOverlayTraceAreDistinct();
testAccentSecondaryDarkerTealClearsFloors();
testWarnClearsStateFloorOnBackground(); testWarnClearsStateFloorOnBackground();
testLabelOnActiveSurfaceClearsFloor(); testLabelOnActiveSurfaceClearsFloor();
testRolesAreDistinctAndElevationMonotonic(); testRolesAreDistinctAndElevationMonotonic();
@@ -283,6 +362,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);