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 |
|---|---|---|---|
| `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/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`
`#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
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 +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
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
+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
(`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:17: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 (4.91:1 for `accent/secondary`). **The *binding* limiter on how dark
`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
just the draw layer.** Sub-frame hover/press/drag feedback must repaint
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.
- `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, 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.
- `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.
@@ -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
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
`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
+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 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 — roles and their categorical meaning are core/ui/CLAUDE.md's. Warning only:
// secondary CANNOT go darker. Its tightest pair is the velocity-curve trace on a HOVER-lightened
// bg/cell (3.03:1 against a 3:1 floor — the whole tree's thinnest margin), which no rest-surface
// check sees.
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. Warning only (the two-neighbour rule is
// core/ui/CLAUDE.md's): this value has NO room in either direction, and it is confined to the
// waveform band — inside the 20%-alpha loop-span fill it already sits under floor at 2.25:1,
// which no single value can fix.
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 +113,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,14 +150,20 @@ 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);
}
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) {
return 0.2126 * linearizeChannel(c.r) +
0.7152 * linearizeChannel(c.g) +
+30 -9
View File
@@ -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 <cstdint>
@@ -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
};
@@ -54,7 +56,14 @@ enum class InteractionState {
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).
enum class TextClass {
Body, // AA 4.5:1
@@ -70,15 +79,27 @@ 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.
// high note) through a pastel lime -> teal -> purple arc, so the strip reads as part of the
// 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].
KitColor spectralColor(double t);
// --- 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
// 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);
// WCAG contrast ratio between two colors, in [1, 21]. Symmetric.
+38 -20
View File
@@ -25,13 +25,21 @@ 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 translucent 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. The trace crossing
// the loop-span fill is a KNOWN, ACCEPTED under-floor pair (2.25:1 against a 3:1 floor), and no
// 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 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
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);
if (rx > lx) {
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};
@@ -114,10 +123,8 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
const std::vector<EnvVertex> 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;
@@ -128,24 +135,35 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea
}
prev = &v;
}
// Handles: a square per draggable stage node, a ROUND knot per curvable segment. Lit
// 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 handleHot = toLice(roleColor(Role::AccentHot));
// Handles: a square per draggable stage node, a ROUND knot per curvable segment. 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.
//
// GRAB is signalled by SIZE + a punched-out core, NOT by a hotter hue — the one place the
// 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) {
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseEnd) continue;
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 hy = (std::max)(area.y + r, (std::min)(area.bottom() - 1 - r, v.y));
if (v.knot) {
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 {
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f,
0);
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, handle, 1.0f, 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.
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 {
LICE_CachedFont regionTitle;
LICE_CachedFont title;
LICE_CachedFont label;
LICE_CachedFont valueMono;
@@ -65,6 +75,7 @@ void loadFont(LICE_CachedFont& dst, int pxHeight, int weight, const char* face)
LICE_CachedFont* fontFor(Font f) {
if (!g_fonts.ready) return nullptr;
switch (f) {
case Font::RegionTitle: return &g_fonts.regionTitle;
case Font::Title: return &g_fonts.title;
case Font::Label: return &g_fonts.label;
case Font::ValueMono: return &g_fonts.valueMono;
@@ -136,6 +147,7 @@ RECT toRect(const KitBox& b) {
void kitFontsInit() {
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.label, 12, FW_NORMAL, "Segoe UI");
loadFont(g_fonts.valueMono, 12, FW_NORMAL, "Consolas");
@@ -147,6 +159,7 @@ void kitFontsShutdown() {
if (!g_fonts.ready) return; // idempotent
// 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).
g_fonts.regionTitle.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.valueMono.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT);
+8 -7
View File
@@ -42,13 +42,14 @@ using ui::ListRowBox;
using ui::Role;
using ui::SliderGeometry;
// The kit's four cached fonts. Consumers pass a Font to text() to pick the size/weight;
// the kit maps it to the matching LICE_CachedFont.
// The kit's cached fonts. Consumers pass a Font to text() to pick the size/weight; the kit
// maps it to the matching LICE_CachedFont.
enum class Font {
Title, // ~15px semibold — region titles, headings
Label, // ~12px regular — labels, body
ValueMono, // ~12px tabular/mono — numbers (dB/ms/notes) that must not jitter
Micro, // ~10px dim — units, counts, keybinding sub-labels
RegionTitle, // 19px BOLD — text that must qualify as WCAG large-scale (3:1 floor)
Title, // ~15px semibold — headings (BODY class: 4.5:1 floor)
Label, // ~12px regular — labels, body
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
@@ -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.
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()
// draws nothing rather than crashing.
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
// 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
// (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;
titleRc.left += 8;
titleRc.right = titleRc.left + 120;
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
// 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;
// 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 kSplitDividerHeight = 3;
inline constexpr int kFullHtBtnWidth = 22;
+126 -46
View File
@@ -64,12 +64,11 @@ static void testTextDimClearsItsFloorOnSurfaces() {
}
static void testAccentsClearIndicatorFloorOnCell() {
// DS-2 revised (grey re-read): each of the three pastel accents is used as a state
// indicator / active fill on bg/cell (selection border, active segment, active tab).
// The greyer surface shrank the cushion from ~15:1 (near-black) to ~6-7:1 (grey), so
// this is re-verified on bg/cell (NOT the old near-black) at the 3:1 large/indicator
// floor. If any pastel dropped below, the fix is to nudge that hue deeper (hue stays
// lime/teal/purple) — the value the test locks proves it did not need it here.
// Each accent is used as a state indicator / active fill on bg/cell (selection border,
// active segment, active tab). The move to REAPER grey shrank the cushion from ~15:1 on
// near-black to 3.9:1 (secondary) - 8.7:1 (hot), so the floor is verified on bg/cell, NOT
// the old near-black. If a pastel drops below, nudge that hue deeper; hue stays
// lime/teal/purple.
for (Role a : {Role::AccentPrimary, Role::AccentSecondary, Role::AccentTertiary,
Role::AccentHot}) {
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.
CHECK(contrastRatio(roleColor(Role::AccentPrimary), roleColor(Role::BgBase))
>= 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}) {
CHECK(contrastRatio(roleColor(a), roleColor(Role::BgPanel))
>= 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):
@@ -121,40 +128,95 @@ 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. 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, on the bg/base the waveform band fills with. This pair does
// double duty: the GRABBED envelope handle is an overlay/trace ring around a bg/base core, so
// the same number is also that mark's internal ring-vs-core contrast.
static void testOverlayTraceClearsFloorOnItsDrawSurface() {
CHECK(contrastRatio(roleColor(Role::OverlayTrace), roleColor(Role::BgBase))
>= textFloor(TextClass::Large));
}
// The velocity-curve trace is drawn in accent/secondary over a HOVER-lightened bg/cell (the
// chrome band's mini preview button). Hover lightens the surface toward accent/hot, closing on
// the deep teal from below: 3.03:1 against a 3:1 floor — the thinnest margin in the tree, and
// the true binding limiter on how dark accent/secondary may go. No rest-surface check sees it.
static void testCurveTraceOnHoverSurfaceClearsFloor() {
CHECK(contrastRatio(roleColor(Role::AccentSecondary),
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
// 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 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() {
@@ -244,15 +306,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,16 +327,33 @@ 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();
testTextDimClearsItsFloorOnSurfaces();
testAccentsClearIndicatorFloorOnCell();
testRegionTitleAccentsClearLargeFloorOnPanel();
testTextOnPastelFillClearsBodyFloor();
testTextOnHoverSurfaceClearsFloor();
testCurveTraceOnHoverSurfaceClearsFloor();
testSecondaryTertiaryAreDistinguishable();
testOverlayAccentSeparatesFromTheWaveformAccent();
testLoopMarkerAndEnvelopeOverlayRolesAreDistinct();
testOverlayTraceClearsIndicatorFloorOnTheWaveform();
testOverlayTraceClearsFloorOnItsDrawSurface();
testOverlayTraceAgainstItsRemainingNeighbours();
testCompositeOverAnchors();
testLoopMarkerAndOverlayTraceAreDistinct();
testAccentSecondaryDarkerTealClearsFloors();
testWarnClearsStateFloorOnBackground();
testLabelOnActiveSurfaceClearsFloor();
testRolesAreDistinctAndElevationMonotonic();
@@ -283,6 +362,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);