diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 7509169..8c21eab 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -435,7 +435,7 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, // Border: accent when selected, else hairline. A focus ring is a distinct text/primary // double-line (the kit's focus convention) so focus reads even on a selected cell. - const KitColor border = selected ? roleColor(Role::Accent) : roleColor(Role::LineHairline); + const KitColor border = selected ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline); LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, toLice(border), 1.0f, 0); if (focused) { const LICE_pixel ring = toLice(roleColor(Role::TextPrimary)); @@ -446,7 +446,7 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, // selected cell (whose fill is already the accent) — there it draws in bg/base for contrast. const LICE_pixel midCol = toLice(roleColor(Role::LineHairline)); const LICE_pixel waveCol = - toLice(selected ? roleColor(Role::BgBase) : roleColor(Role::Accent)); + toLice(selected ? roleColor(Role::BgBase) : roleColor(Role::AccentPrimary)); if (env.empty()) { const int midY = rect.y + rect.height / 2; @@ -1023,21 +1023,26 @@ void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title, LICE_Line(bmp, hdr.left, hdr.bottom - 1, hdr.right, hdr.bottom - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); - // Title, left (Font::Title — a region heading). + // Title, left (Font::Title — a region heading). The two regions are distinct KINDS of + // container, so the title carries a CATEGORICAL accent (DS-2 revised: 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), keeping primary reserved for the live/active layer. RECT titleRc = hdr; titleRc.left += 8; titleRc.right = titleRc.left + 120; - kitText(bmp, toKitBox(titleRc), title, Font::Title, Role::TextPrimary, Align::Left); + const Role titleRole = poolBtnIsPool ? Role::AccentSecondary : Role::AccentTertiary; + kitText(bmp, toKitBox(titleRc), title, Font::Title, titleRole, Align::Left); - // Active-bank readout — the UNMISTAKABLE indicator (settled B4 constraint), in the ACCENT - // role in BOTH region headers so the active/capture-target bank is legible even when it is - // not the shown tab and even when it is the pool. Accent = "where the punch lives" (DS-2). + // Active-bank readout — the UNMISTAKABLE indicator (settled B4 constraint), in the PRIMARY + // accent role in BOTH region headers so the active/capture-target bank is legible even when + // it is not the shown tab and even when it is the pool. Primary = "what's live" (DS-2). const std::string readout = "Active: " + activeName; RECT actRc = hdr; actRc.left = titleRc.right + 6; actRc.right = createBtnRect(region).left - 6; if (actRc.right > actRc.left) - kitText(bmp, toKitBox(actRc), readout.c_str(), Font::Label, Role::Accent, Align::Left); + kitText(bmp, toKitBox(actRc), readout.c_str(), Font::Label, Role::AccentPrimary, Align::Left); // Full-height toggle button: an arrow glyph. In split it means "maximize this region"; // when this region is already full it means "restore the split". Kit drawButton + hover. @@ -1113,7 +1118,7 @@ void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) { // The active bank's tab gets a bright accent border (unmistakable), distinct from the // shown tab's fill — active != shown, made visible (kit accent role). - const KitColor border = active ? roleColor(Role::Accent) : roleColor(Role::LineHairline); + const KitColor border = active ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline); LICE_DrawRect(bmp, tr.x, tr.y, tr.width, tr.height, toLice(border), 1.0f, 0); if (active) LICE_DrawRect(bmp, tr.x + 1, tr.y + 1, tr.width - 2, tr.height - 2, diff --git a/src/draw_kit.cpp b/src/draw_kit.cpp index 379eea1..99ebe94 100644 --- a/src/draw_kit.cpp +++ b/src/draw_kit.cpp @@ -208,7 +208,7 @@ void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label const int radius = b.height >= 20 ? 5 : (b.height >= 12 ? 3 : 2); const KitColor borderCol = (state == InteractionState::Active || state == InteractionState::Focus) - ? roleColor(Role::Accent) + ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline); LICE_RoundRect(bmp, static_cast(b.x), static_cast(b.y), static_cast(b.width - 1), static_cast(b.height - 1), @@ -238,7 +238,7 @@ void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState ? InteractionState::Hover : InteractionState::Active; KitColor top, bottom; - gradientPair(roleColorState(Role::Accent, fillState), top, bottom); + gradientPair(roleColorState(Role::AccentPrimary, fillState), top, bottom); fillGradient(bmp, geom.filled, top, bottom); } @@ -281,7 +281,7 @@ void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env) { if (!bmp || box.empty()) return; const LICE_pixel midCol = toLice(roleColor(Role::LineHairline)); - const LICE_pixel waveCol = toLice(roleColor(Role::Accent)); + const LICE_pixel waveCol = toLice(roleColor(Role::AccentPrimary)); if (env.empty()) { const int midY = box.y + box.height / 2; diff --git a/src/theme.cpp b/src/theme.cpp index 276bb6a..5e4bad3 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -10,39 +10,59 @@ namespace reasampler { namespace { // =========================================================================== -// THE ONE DIRECTION CONSTANTS BLOCK (DS-2: B "Neon Console" + C spectral). +// THE ONE DIRECTION CONSTANTS BLOCK (DS-2 revised: B "Neon Console" REAPER-grey +// neutrals + three-accent pastel system + C pastel spectral). // // This is the SINGLE POINT OF CHANGE. Every role color below is one of these // constants; roleColor() is a pure switch over them. To re-pick the visual // direction (§4: A Studio Rack / B Neon Console / C full spectral), edit THIS -// block — no shell, no other module, names a color. The values are chosen from -// the vibrant side of each WCAG floor (the "punch" rule): the accents are as -// saturated as they can be while text still clears 4.5:1 / 3:1 on the surfaces -// they land on (proven by test_theme.cpp). +// block — no shell, no other module, names a color. Values are locked against +// each WCAG floor (proven by test_theme.cpp): text/dim is lifted to the lightest +// grey that still clears AA 4.5:1 body on the greyest surface it draws on; each +// pastel accent is the softest tint that still clears the 3:1 indicator floor on +// bg/cell ("punch from the soft side" — DS-2 revised §2.1 grey re-read). // =========================================================================== -// Near-black elevation stack (B: base ~18,18,22). Each step a few points lighter -// so elevation reads without a border. -constexpr KitColor kDirBgBase {18, 18, 22, 255}; -constexpr KitColor kDirBgPanel {26, 26, 31, 255}; -constexpr KitColor kDirBgCell {40, 40, 46, 255}; -constexpr KitColor kDirHairline {60, 60, 66, 255}; +// REAPER-theme mid-grey elevation stack (DS-2 revised — NOT near-black). Matches +// Daniel's REAPER theme so the dock reads as part of REAPER: base = window chrome +// grey, panel/cell one step lighter each. The elevation-ladder discipline is +// unchanged (base < panel < cell by a few %, micro-gradient + inner highlight/ +// shadow carry elevation, not hard borders); only the VALUES moved up into grey. +constexpr KitColor kDirBgBase {43, 43, 43, 255}; // #2b2b2b — REAPER chrome grey +constexpr KitColor kDirBgPanel {51, 51, 51, 255}; // #333333 — one step lighter +constexpr KitColor kDirBgCell {58, 58, 58, 255}; // #3a3a3a — REAPER track bg +constexpr KitColor kDirHairline {74, 74, 74, 255}; // #4a4a4a — subtle step above cell -// Text: near-white primary + a dimmer secondary. Both must clear their floor on -// bg/panel AND bg/cell (the surfaces text lands on); the test enforces it. -constexpr KitColor kDirTextPrimary{224, 228, 234, 255}; -constexpr KitColor kDirTextDim {150, 156, 166, 255}; +// Text: REAPER body light-grey primary (#dcdcdc, clears ~8:1 on bg/cell) + a dimmer +// grey secondary. The greyer surfaces shrank the dim cushion (mid-grey-on-mid-grey +// is the classic AA failure): the spec-start #a0a0a0 lands ~4.35:1 on bg/cell, UNDER +// the AA 4.5 body floor — lifted to #a8a8a8 (~4.78:1 on bg/cell), the lightest grey +// that still reads dim while clearing AA 4.5 body on the greyest surface it draws +// body text on. Locked by test_theme.cpp. +constexpr KitColor kDirTextPrimary{220, 220, 220, 255}; // #dcdcdc +constexpr KitColor kDirTextDim {168, 168, 168, 255}; // #a8a8a8 (lifted from #a0a0a0) -// The single vivid accent — electric cyan leads (B). Bright enough to clear -// AA-large on near-black from the vibrant side; the hot tint is a lighter cyan -// for hover/live. warn is a reserved red/amber for byte-deleting states only. -constexpr KitColor kDirAccent {60, 200, 235, 255}; -constexpr KitColor kDirAccentHot {130, 224, 245, 255}; -constexpr KitColor kDirWarn {235, 120, 90, 255}; +// The three-accent pastel system (DS-2 revised — replaces the single electric cyan). +// primary = pastel lime (the live/active/selected signal, the eye-magnet); 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 the +// greyer bg/cell the pastels clear the 3:1 indicator floor comfortably (primary ~7.6, +// secondary ~6.8, tertiary ~5.5) at the spec-start values, so no per-hue nudge was +// needed — the hues stay pastel lime/teal/purple. warn is a reserved red/amber for +// byte-deleting states only. +constexpr KitColor kDirAccentPrimary {176, 224, 152, 255}; // #B0E098 — pastel lime +constexpr KitColor kDirAccentSecondary{132, 214, 208, 255}; // #84D6D0 — pastel teal +constexpr KitColor 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 -// Direction C spectral endpoints (cool-blue -> hot-magenta) for the keyboard strip. -constexpr KitColor kDirSpectralLo {70, 120, 235, 255}; // low notes: cool blue -constexpr KitColor kDirSpectralHi {235, 70, 170, 255}; // high notes: hot magenta +// Direction C pastel spectral ramp (DS-2 revised): a three-stop sweep through the +// accents — pastel lime (low) -> pastel teal (mid) -> pastel purple (high) — so the +// signature keyboard strip reads as an extension of the accent system, not a neon +// flourish. Endpoints/midpoint ARE the three accent constants (single source). +constexpr KitColor kDirSpectralLo = kDirAccentPrimary; // low notes: pastel lime +constexpr KitColor kDirSpectralMid = kDirAccentSecondary; // mid notes: pastel teal +constexpr KitColor kDirSpectralHi = kDirAccentTertiary; // high notes: pastel purple // --- state transform helpers ------------------------------------------------- @@ -97,7 +117,9 @@ KitColor roleColor(Role role) { case Role::LineHairline: return kDirHairline; case Role::TextPrimary: return kDirTextPrimary; case Role::TextDim: return kDirTextDim; - case Role::Accent: return kDirAccent; + case Role::AccentPrimary: return kDirAccentPrimary; + case Role::AccentSecondary: return kDirAccentSecondary; + case Role::AccentTertiary: return kDirAccentTertiary; case Role::AccentHot: return kDirAccentHot; case Role::Warn: return kDirWarn; } @@ -113,18 +135,20 @@ KitColor roleColorState(Role role, InteractionState state) { // Lighten the surface toward the hot accent (~10%) — the "alive" cue. return mix(base, roleColor(Role::AccentHot), 0.10); case InteractionState::Active: - // The selected/active layer carries the accent itself. - return roleColor(Role::Accent); + // The selected/active layer carries the PRIMARY accent — "this is live" + // is always the primary hue (DS-2 revised: primary leads state; secondary/ + // tertiary are categorical, never intensity). + return roleColor(Role::AccentPrimary); case InteractionState::Pressed: // The surface "pushes in": darken. return scale(base, 0.82); case InteractionState::Dragging: - // A live-drag element reads as active-but-lighter. - return mix(roleColor(Role::Accent), roleColor(Role::AccentHot), 0.30); + // A live-drag element reads as active-but-lighter (primary -> hot). + return mix(roleColor(Role::AccentPrimary), roleColor(Role::AccentHot), 0.30); case InteractionState::Focus: // Focus keeps the surface but is drawn with a text/primary ring by the - // shell; the fill nudges toward the accent so focus reads even pre-ring. - return mix(base, roleColor(Role::Accent), 0.08); + // shell; the fill nudges toward the primary accent so focus reads pre-ring. + return mix(base, roleColor(Role::AccentPrimary), 0.08); case InteractionState::Disabled: { // Desaturate and drop alpha to 40% (§3.3). KitColor d = desaturate(base, 0.6); @@ -138,7 +162,14 @@ KitColor roleColorState(Role role, InteractionState state) { KitColor spectralColor(double t) { if (t < 0.0) t = 0.0; if (t > 1.0) t = 1.0; - return mix(kDirSpectralLo, kDirSpectralHi, t); + // Three-stop pastel sweep anchored on the accent trio (DS-2 revised Direction C): + // lime (low) -> teal (mid, t=0.5) -> purple (high). A single Lo->Hi lerp would skip + // the teal midpoint and drift the ramp off the accent family; interpolate each half + // so the midpoint IS the secondary accent and every stop stays in the pastel band. + if (t <= 0.5) { + return mix(kDirSpectralLo, kDirSpectralMid, t / 0.5); + } + return mix(kDirSpectralMid, kDirSpectralHi, (t - 0.5) / 0.5); } double relativeLuminance(const KitColor& c) { diff --git a/src/theme.h b/src/theme.h index 38d6bfc..2137113 100644 --- a/src/theme.h +++ b/src/theme.h @@ -2,16 +2,20 @@ // theme — the REAPER-free, LICE-free palette + type-scale core of the shared drawing // kit (Phase L, L1). This is the "one source of drawing" made testable at its root: a // ROLE-based color model (bg/base, bg/panel, bg/cell, line/hairline, text/primary, -// text/dim, accent, 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 ("punch to the floor, not past it"). +// 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 ("punch to the floor, not past it"). // -// THE SINGLE POINT OF CHANGE (DS-2): every role color is produced by roleColor() from -// ONE direction constants block (kDirection*, below) carrying the settled B (Neon -// Console) + C spectral values. Switching the visual direction is editing that block and -// nothing else — no shell hardcodes a color; the shell asks the theme by role. The -// spectral (Direction C) hue ramp lives here too (spectralColor) so the signature -// keyboard strip's L3 consumer derives its per-note hue from the same source. +// THE SINGLE POINT OF CHANGE (DS-2 revised): every role color is produced by roleColor() +// from ONE direction constants block (kDirection*, below) carrying the settled B (Neon +// Console) neutrals — now REAPER-theme mid-grey, not near-black — plus the three-accent +// pastel system (primary lime / secondary teal / tertiary purple) and the C pastel +// spectral ramp. Switching the visual direction is editing that block and nothing else — +// no shell hardcodes a color; the shell asks the theme by role. The spectral (Direction C) +// hue ramp lives here too (spectralColor) so the signature keyboard strip's L3 consumer +// derives its per-note hue from the same source (a pastel sweep anchored on the three +// accents: primary lime -> secondary teal -> tertiary purple). // // PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library // only. Builds and unit-tests without REAPER. Mirror of mode_switch / bank_grid — the @@ -40,15 +44,17 @@ struct KitColor { // The structural palette roles (direction-independent — §2.1 of the design doc). The // direction (B/C) sets the concrete hue behind each; the shell always asks by role. enum class Role { - BgBase, // window canvas - BgPanel, // a raised region (list, waveform pane) - BgCell, // a control / row surface - LineHairline,// separators (used sparingly — elevation carries most separation) - TextPrimary, // labels, values - TextDim, // secondary / units - Accent, // selection / active / focus — where the punch lives - AccentHot, // hover / live / drag feedback (a brighter accent tint) - Warn, // clip / destructive (prune, delete) — reserved for byte-deleting states + BgBase, // window canvas + BgPanel, // a raised region (list, waveform pane) + BgCell, // a control / row surface + LineHairline, // separators (used sparingly — elevation carries most separation) + TextPrimary, // labels, values + TextDim, // secondary / units + AccentPrimary, // the live / active / selected signal — where the punch lives (pastel lime) + AccentSecondary,// categorical role A (pastel 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) + Warn, // clip / destructive (prune, delete) — reserved for byte-deleting states }; // The interaction-state model every kit component honors (§3.3). A component draws its @@ -83,11 +89,15 @@ KitColor roleColor(Role role); // Rest returns roleColor(role) unchanged. KitColor roleColorState(Role role, InteractionState state); -// Direction C's spectral hue ramp: maps a normalized position t in [0, 1] (low note -> -// high note across the keyboard strip) to a color, cool-blue at 0 -> hot-magenta at 1 -// (§4 Direction C). The signature keyboard-strip surface (an L3 consumer) derives each -// note/zone's hue from this ONE function so the spectrum is defined in the same place as -// the rest of the palette. t is clamped to [0, 1]. +// Direction C's spectral hue ramp (DS-2 revised — a PASTEL sweep anchored on the three +// accents, not the old neon cool-blue -> hot-magenta): maps a normalized position t in +// [0, 1] (low note -> high note across the keyboard strip) to a color that runs +// accent/primary (pastel lime, low) -> accent/secondary (pastel teal, mid) -> +// accent/tertiary (pastel purple, high). The same three hues that mean "live / category A +// / category B" elsewhere are the endpoints and midpoint here, so the strip reads as an +// extension of the accent system, not a separate flourish. The signature keyboard-strip +// surface (an L3 consumer) derives each note/zone's hue from this ONE function so the +// spectrum is defined in the same place as the rest of the palette. t is clamped to [0, 1]. KitColor spectralColor(double t); // --- WCAG contrast (the "punch" rule, made testable) -------------------------- diff --git a/tests/test_theme.cpp b/tests/test_theme.cpp index 465f169..1d16075 100644 --- a/tests/test_theme.cpp +++ b/tests/test_theme.cpp @@ -12,6 +12,7 @@ #include #include +#include #include using namespace reasampler; @@ -39,7 +40,7 @@ static void testContrastKnownAnchors() { // --- THE load-bearing test: every drawn text-on-surface pair clears its floor - -// The surfaces text lands on (near-black elevation stack). +// The surfaces text lands on (DS-2 revised REAPER-grey elevation stack). static void testTextPrimaryClearsBodyFloorOnSurfaces() { const KitColor tp = roleColor(Role::TextPrimary); // Primary text is body text -> 4.5:1 on every surface it is drawn on. @@ -50,22 +51,73 @@ static void testTextPrimaryClearsBodyFloorOnSurfaces() { static void testTextDimClearsItsFloorOnSurfaces() { const KitColor td = roleColor(Role::TextDim); - // Dim/secondary text is used for units/counts (large-ish, low-emphasis) -> the - // large/state floor 3:1 on the surfaces it appears on. Enforced from the vibrant side: - // it must not be dimmed BELOW the floor. - CHECK(contrastRatio(td, roleColor(Role::BgBase)) >= textFloor(TextClass::Large)); - CHECK(contrastRatio(td, roleColor(Role::BgPanel)) >= textFloor(TextClass::Large)); - CHECK(contrastRatio(td, roleColor(Role::BgCell)) >= textFloor(TextClass::Large)); + // DS-2 revised (grey re-read): text/dim is used for keybinding sub-labels / units on + // the mid-grey surfaces, and mid-grey-on-mid-grey is the classic AA failure. It is + // LOCKED to the lightest grey that still reads dim while clearing AA 4.5:1 BODY on the + // GREYEST surface it draws body text on (bg/cell). This is the load-bearing check: the + // spec-start #a0a0a0 is UNDER floor on grey by design — the value was lifted until this + // passes. Body floor on all three surfaces (bg/cell is the tight one). + CHECK(contrastRatio(td, roleColor(Role::BgBase)) >= textFloor(TextClass::Body)); + CHECK(contrastRatio(td, roleColor(Role::BgPanel)) >= textFloor(TextClass::Body)); + CHECK(contrastRatio(td, roleColor(Role::BgCell)) >= textFloor(TextClass::Body)); } -static void testAccentClearsStateFloorOnBackground() { - // The accent is a UI-state indicator (selection border / active fill) -> 3:1 minimum - // on the base background, "pushed to the floor from the vibrant side". - CHECK(contrastRatio(roleColor(Role::Accent), roleColor(Role::BgBase)) - >= textFloor(TextClass::Large)); - // The hot (hover) accent is brighter still, so it also clears. - CHECK(contrastRatio(roleColor(Role::AccentHot), roleColor(Role::BgBase)) +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. + for (Role a : {Role::AccentPrimary, Role::AccentSecondary, Role::AccentTertiary, + Role::AccentHot}) { + CHECK(contrastRatio(roleColor(a), roleColor(Role::BgCell)) + >= textFloor(TextClass::Large)); + } + // 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). + for (Role a : {Role::AccentPrimary, Role::AccentSecondary, Role::AccentTertiary}) { + CHECK(contrastRatio(roleColor(a), roleColor(Role::BgPanel)) + >= textFloor(TextClass::Large)); + } +} + +// Text drawn ON a pastel accent fill (retained tight pair, re-verified for the pastels): +// active buttons/segments/tabs fill with the primary accent and draw their label in +// bg/base (near-black-ish grey). That near-black-on-pastel pair must clear AA 4.5:1 body, +// or active controls would be unreadable. Verified for EACH pastel that can be a text fill. +static void testTextOnPastelFillClearsBodyFloor() { + const KitColor label = roleColor(Role::BgBase); // what drawButton/drawListRow use + for (Role a : {Role::AccentPrimary, Role::AccentSecondary, Role::AccentTertiary}) { + CHECK(contrastRatio(label, roleColor(a)) >= textFloor(TextClass::Body)); + } +} + +// Secondary and tertiary are the CATEGORICAL pair (region titles, spectral bands). They +// must read as DISTINCT categories, not two greys. Two pastels can be near-equal in +// LUMINANCE yet clearly distinct in HUE (teal vs purple differ mainly by channel balance, +// not brightness) — so distinguishability is a hue-separation check, not a luminance- +// contrast one. Assert a meaningful per-channel difference: teal is green/blue-leaning, +// purple is red/blue-leaning, so their channel BALANCE diverges substantially. +static void testSecondaryTertiaryAreDistinguishable() { + const KitColor sec = roleColor(Role::AccentSecondary); // teal: G,B high, R low + const KitColor ter = roleColor(Role::AccentTertiary); // purple: R,B high, G lower + CHECK(!(sec == ter)); + CHECK(!(roleColor(Role::AccentPrimary) == sec)); + // Hue divergence: teal's green dominates its red; purple's red dominates its green. + // A muddying that collapsed them toward a common grey would break this ordering. + CHECK(sec.g > sec.r); // teal leans green over red + CHECK(ter.r > ter.g); // purple leans red over green + // And the total channel-balance gap is large (sum of absolute channel deltas), so the + // two are far apart in color space even though close in luminance. + const int delta = std::abs(int(sec.r) - int(ter.r)) + + std::abs(int(sec.g) - int(ter.g)) + + std::abs(int(sec.b) - int(ter.b)); + CHECK(delta >= 60); } static void testWarnClearsStateFloorOnBackground() { @@ -93,7 +145,7 @@ static void testRolesAreDistinctAndElevationMonotonic() { CHECK(!(roleColor(Role::BgBase) == roleColor(Role::BgPanel))); CHECK(!(roleColor(Role::BgPanel) == roleColor(Role::BgCell))); CHECK(!(roleColor(Role::TextPrimary) == roleColor(Role::TextDim))); - CHECK(!(roleColor(Role::Accent) == roleColor(Role::Warn))); + CHECK(!(roleColor(Role::AccentPrimary) == roleColor(Role::Warn))); // Elevation reads as increasing luminance base < panel < cell. CHECK(relativeLuminance(roleColor(Role::BgBase)) < relativeLuminance(roleColor(Role::BgPanel))); @@ -107,7 +159,7 @@ static void testRolesAreDistinctAndElevationMonotonic() { // --- Interaction-state transform ---------------------------------------------- static void testStateRestIsIdentity() { - for (Role r : {Role::BgBase, Role::BgPanel, Role::BgCell, Role::Accent}) { + for (Role r : {Role::BgBase, Role::BgPanel, Role::BgCell, Role::AccentPrimary}) { CHECK(roleColorState(r, InteractionState::Rest) == roleColor(r)); } } @@ -121,13 +173,15 @@ static void testHoverLightensPressedDarkens() { CHECK(relativeLuminance(pressed) < relativeLuminance(rest)); } -static void testActiveIsAccent() { - CHECK(roleColorState(Role::BgCell, InteractionState::Active) == roleColor(Role::Accent)); +static void testActiveIsPrimaryAccent() { + // DS-2 revised: the Active state is ALWAYS the primary accent ("what's live" leads). + CHECK(roleColorState(Role::BgCell, InteractionState::Active) + == roleColor(Role::AccentPrimary)); } static void testDisabledDropsAlphaAndDesaturates() { - const KitColor rest = roleColor(Role::Accent); - const KitColor dis = roleColorState(Role::Accent, InteractionState::Disabled); + const KitColor rest = roleColor(Role::AccentPrimary); + const KitColor dis = roleColorState(Role::AccentPrimary, InteractionState::Disabled); // Alpha drops to ~40%. CHECK(dis.a < rest.a); CHECK(dis.a >= 90 && dis.a <= 110); // 255 * 0.4 ~= 102 @@ -135,16 +189,24 @@ static void testDisabledDropsAlphaAndDesaturates() { // --- Direction C spectral ramp ------------------------------------------------ -static void testSpectralInterpolatesEndpoints() { +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. const KitColor lo = spectralColor(0.0); - const KitColor hi = spectralColor(1.0); - // Low is cool (blue-dominant), high is hot (red-dominant) — the identity of the ramp. - CHECK(lo.b > lo.r); - CHECK(hi.r > hi.b); - // Midpoint sits between the endpoints on each channel. const KitColor mid = spectralColor(0.5); - CHECK(mid.r > lo.r && mid.r < hi.r); - // Clamps out of range. + 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. + CHECK(lo == roleColor(Role::AccentPrimary)); // low = pastel lime + CHECK(mid == roleColor(Role::AccentSecondary)); // mid = pastel teal + 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); + CHECK(hi.b > hi.g && hi.r > hi.g); + // A quarter-point interpolates within the lime->teal half (not off the family). + const KitColor q = spectralColor(0.25); + CHECK(q.b > lo.b && q.b < mid.b); // blue rises lime -> teal + // Clamps out of range to the endpoints. CHECK(spectralColor(-1.0) == lo); CHECK(spectralColor(2.0) == hi); } @@ -153,15 +215,17 @@ int main() { testContrastKnownAnchors(); testTextPrimaryClearsBodyFloorOnSurfaces(); testTextDimClearsItsFloorOnSurfaces(); - testAccentClearsStateFloorOnBackground(); + testAccentsClearIndicatorFloorOnCell(); + testTextOnPastelFillClearsBodyFloor(); + testSecondaryTertiaryAreDistinguishable(); testWarnClearsStateFloorOnBackground(); testLabelOnActiveSurfaceClearsFloor(); testRolesAreDistinctAndElevationMonotonic(); testStateRestIsIdentity(); testHoverLightensPressedDarkens(); - testActiveIsAccent(); + testActiveIsPrimaryAccent(); testDisabledDropsAlphaAndDesaturates(); - testSpectralInterpolatesEndpoints(); + testSpectralIsPastelRampAnchoredOnAccents(); if (g_fail == 0) std::printf("theme: all tests passed\n"); else std::printf("theme: %d CHECK(s) FAILED\n", g_fail);