From a19d645a49a1f2a7134ad8ef952e3acaa5b9eb71 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 31 Jul 2026 14:07:01 -0400 Subject: [PATCH] palette: enlarge the region title into WCAG large class; repoint the grabbed envelope handle off hue Corrects theme.h's large-text thresholds, names accent/secondary's real binding limiter, and adds compositeOver so the loop-span fill's 2.25:1 under-floor pair is asserted rather than assumed. --- docs/product/visual-design-language.md | 32 ++++--- src/core/ui/CLAUDE.md | 43 ++++++--- src/core/ui/theme.cpp | 24 +++-- src/core/ui/theme.h | 27 +++++- .../instrument/editor_paint_waveform.cpp | 46 ++++++--- src/shell/panel/draw_kit.cpp | 13 +++ src/shell/panel/draw_kit.h | 15 +-- src/shell/panel/panel_render.cpp | 6 +- src/shell/panel/panel_state.h | 4 +- tests/test_theme.cpp | 93 ++++++++++++++----- 10 files changed, 221 insertions(+), 82 deletions(-) diff --git a/docs/product/visual-design-language.md b/docs/product/visual-design-language.md index cde545a..c1d6aee 100644 --- a/docs/product/visual-design-language.md +++ b/docs/product/visual-design-language.md @@ -416,25 +416,29 @@ 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), which put its -binding limiter on the AA 4.5:1 text-on-fill pair (4.91:1 on `bg/base`) rather than the 3:1 -indicator floor (3.94:1 on `bg/cell`). Two roles were added past the original three, since -"exactly three accents" is no longer a constraint: `overlay/trace` `#816AA6` (a muted violet for -marks drawn *over* an accent fill — see §the two-neighbour problem below), and the spectral -ramp's mid stop, which keeps the original pastel teal `#84D6D0` as its own constant rather than -aliasing `accent/secondary`. +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.** A mark drawn *over* an accent fill has to read against the fill -*and* the surface behind it. For the envelope trace those are the lime waveform (relative -luminance 0.648) and `bg/base` (0.024) — 9.41:1 apart, so `sqrt(9.41)` ≈ **3.07:1 is the most -any single value can clear against both**. `overlay/trace` sits at that optimum; it has no -headroom in either direction, and no value at all can clear 3:1 against the lime *and* on -`bg/cell`. Such a role is confined to the band it was picked for. +**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 diff --git a/src/core/ui/CLAUDE.md b/src/core/ui/CLAUDE.md index 274bb99..bb91c53 100644 --- a/src/core/ui/CLAUDE.md +++ b/src/core/ui/CLAUDE.md @@ -43,15 +43,28 @@ L7 sub-pass, 2026-07-27): fills on `bg/cell` have a shrunken contrast cushion (primary ~7.6:1, tertiary ~5.5:1, secondary only ~3.9:1 since it was darkened — all still clear 3:1, but re-check every one on any hue nudge). Body text on an accent fill is a tight AA - 4.5:1 pair, and for `accent/secondary` it is the *binding* limiter (~4.9:1), not - the indicator floor. Take the most pastel value that still clears the floor, - approached from the soft side, never re-saturated toward neon "to be safe." -- **A mark drawn OVER an accent fill answers to two neighbours at once.** - `overlay/trace` (the envelope trace on the waveform) must clear 3:1 against both - `accent/primary` and `bg/base`, which sit 9.41:1 apart — so `sqrt(9.41)` ≈ 3.07:1 - is the ceiling for *any* single value, and the role sits at that optimum with no - headroom either way. It is therefore confined to the waveform band: no value can - clear 3:1 on `bg/cell` and against the lime simultaneously. + 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 @@ -79,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 + the pastel accent system, the keyboard strip's spectral ramp (its own three stops, deliberately not aliases of the accents), 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. @@ -93,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 diff --git a/src/core/ui/theme.cpp b/src/core/ui/theme.cpp index 7de97b2..d510df2 100644 --- a/src/core/ui/theme.cpp +++ b/src/core/ui/theme.cpp @@ -34,22 +34,20 @@ 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) -// Accent system: primary = pastel lime (the live/active/selected signal); secondary = deep -// teal, tertiary = pastel purple (CATEGORICAL distinctions — a KIND, never intensity). -// accent/hot is a brighter tint OF the primary for hover/live/drag. Secondary is the one -// accent off the pastel side: its binding limiter is the AA 4.5:1 text-on-fill pair (a bg/base -// label on a secondary fill), NOT the 3:1 indicator floor, so it cannot go darker than this. -// warn is reserved for byte-deleting states only. +// 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{56, 168, 160, 255}; // #38A8A0 — deep teal constexpr KitColor kDirAccentTertiary {194, 170, 232, 255}; // #C2AAE8 — pastel purple constexpr KitColor kDirAccentHot {200, 236, 178, 255}; // #C8ECB2 — lighter pastel lime constexpr KitColor kDirWarn {235, 120, 90, 255}; // #eb785a — destructive only -// The envelope trace over the waveform. Two neighbours pin it at once — the lime accent it -// crosses and bg/base behind it — which bound ANY single value to 3.07:1 against both; this is -// that optimum, with no room in either direction. No categorical accent can carry it: every -// one of them is light enough to fall under 2:1 against the lime. +// 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 @@ -160,6 +158,12 @@ KitColor spectralColor(double t) { 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) + diff --git a/src/core/ui/theme.h b/src/core/ui/theme.h index 6678fc9..cfc8610 100644 --- a/src/core/ui/theme.h +++ b/src/core/ui/theme.h @@ -56,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 @@ -73,14 +80,26 @@ 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 a pastel lime -> teal -> purple arc, so the strip reads as part of the -// palette rather than a separate flourish. The arc is its own three stops — a categorical -// accent move must not be able to reorder it. t is clamped to [0, 1]. +// 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. diff --git a/src/shell/instrument/editor_paint_waveform.cpp b/src/shell/instrument/editor_paint_waveform.cpp index 855f68e..53a5455 100644 --- a/src/shell/instrument/editor_paint_waveform.cpp +++ b/src/shell/instrument/editor_paint_waveform.cpp @@ -25,11 +25,21 @@ using audio::computeEnvelope; namespace { // Marker roles — semantic, drawn through the kit's palette: start AND loop start/end both -// = teal (secondary). Markers are 2px bars and a 20%-alpha span fill, not the 1px trace, so +// = 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. +// 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) { @@ -84,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(kLoopSpanFillAlpha), 0); } } const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; @@ -124,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. + // 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 handleHot = toLice(roleColor(Role::AccentHot)); + 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(hx), static_cast(hy), - static_cast(r), grabbed ? handleHot : handle, 1.0f, 0, true); + static_cast(r), handle, 1.0f, 0, true); + if (grabbed) + LICE_FillCircle(bmp, static_cast(hx), static_cast(hy), + static_cast(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); } } } diff --git a/src/shell/panel/draw_kit.cpp b/src/shell/panel/draw_kit.cpp index 4f7c9bd..888eba0 100644 --- a/src/shell/panel/draw_kit.cpp +++ b/src/shell/panel/draw_kit.cpp @@ -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); diff --git a/src/shell/panel/draw_kit.h b/src/shell/panel/draw_kit.h index e747276..a48bd2b 100644 --- a/src/shell/panel/draw_kit.h +++ b/src/shell/panel/draw_kit.h @@ -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(); diff --git a/src/shell/panel/panel_render.cpp b/src/shell/panel/panel_render.cpp index 9a0bd3f..c683a8b 100644 --- a/src/shell/panel/panel_render.cpp +++ b/src/shell/panel/panel_render.cpp @@ -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 diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index d37b10b..2187c2f 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -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; diff --git a/tests/test_theme.cpp b/tests/test_theme.cpp index 09d4dea..92dda3e 100644 --- a/tests/test_theme.cpp +++ b/tests/test_theme.cpp @@ -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): @@ -122,23 +129,65 @@ static void testSecondaryTertiaryAreDistinguishable() { } // The envelope overlay is traced OVER the waveform, which draws in the primary accent — an -// accent-on-accent pair no surface floor covers, since neither is a surface. overlay/trace -// exists so that pair can clear the real 3:1 indicator floor; every categorical accent is light -// enough to sit under 2:1 against the lime, so pointing the trace back at one fails here. +// 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. bg/base is the only surface it draws on (the waveform band -// fills with it), and that confinement is forced, not convenient: clearing 3:1 against the lime -// caps the trace's luminance at 0.183, while clearing 3:1 on bg/cell would demand 0.227. The two -// are mutually exclusive, so this role must not be reused outside that band. +// 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, 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 @@ -156,11 +205,9 @@ static void testLoopMarkerAndOverlayTraceAreDistinct() { } // AccentSecondary was darkened from a pastel teal (#84D6D0) to a deep teal (#38A8A0, same -// hue/saturation, lower lightness). Locks the re-measured numbers so a future palette nudge -// that erodes past either the 3:1 indicator floor or the tighter AA 4.5:1 text-on-fill floor -// fails the build. The text-on-fill pair (bg/base label on a secondary fill) is the binding -// limiter — it clears with far less headroom (~4.9:1) than the indicator floor on bg/cell -// (~3.9:1), which is why the value could not go darker than this. +// 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)); @@ -296,11 +343,15 @@ int main() { testTextPrimaryClearsBodyFloorOnSurfaces(); testTextDimClearsItsFloorOnSurfaces(); testAccentsClearIndicatorFloorOnCell(); + testRegionTitleAccentsClearLargeFloorOnPanel(); testTextOnPastelFillClearsBodyFloor(); testTextOnHoverSurfaceClearsFloor(); + testCurveTraceOnHoverSurfaceClearsFloor(); testSecondaryTertiaryAreDistinguishable(); testOverlayTraceClearsIndicatorFloorOnTheWaveform(); testOverlayTraceClearsFloorOnItsDrawSurface(); + testOverlayTraceAgainstItsRemainingNeighbours(); + testCompositeOverAnchors(); testLoopMarkerAndOverlayTraceAreDistinct(); testAccentSecondaryDarkerTealClearsFloors(); testWarnClearsStateFloorOnBackground();