fix(brand): stop adjustBrightness collapsing dark color shades to black (#497)

adjustBrightness() added/subtracted a flat 255*percent to every RGB
channel. For a dark base color (channels already close to 0), darkening
by -0.3/-0.45/-0.6 clamped every channel to 0, so shades 700, 800, and
900 all came back as the identical, useless #000000 instead of a graded
dark scale -- reproducible with any sufficiently dark brand primary
(e.g. #4A3228), not just an edge case.

Blend each channel proportionally toward white (percent > 0) or black
(percent < 0) instead, so the shift scales with how much headroom the
channel actually has.

Adds a regression test that syncs a dark brand color and asserts the
700/800/900 shades stay distinct and non-black.


Claude-Session: https://claude.ai/code/session_01MmtTPnoqMrjjhKjod9VTzr

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nadiiasoshenko-netizen 2026-09-15 01:00:09 +01:00 committed by GitHub
parent 7f69fed6a2
commit d8ce090f82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 58 additions and 5 deletions

View File

@ -99,15 +99,34 @@ function generateColorScale(baseHex, darkHex, lightHex) {
}
/**
* Adjust hex color brightness
* Adjust hex color brightness.
*
* Blends each channel proportionally toward white (percent > 0) or toward
* black (percent < 0) instead of adding/subtracting a flat 255*percent to
* every channel. The flat-shift approach clamped all three channels to 0
* (or 255) whenever the base color's channels were already low (or high)
* relative to the shift e.g. darkening a dark brand color like #4A3228
* by -0.3/-0.45/-0.6 produced #000000 for all three, collapsing shades
* 700/800/900 into an identical, useless black.
*/
function adjustBrightness(hex, percent) {
if (typeof hex !== 'string') return '#000000';
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.min(255, Math.max(0, (num >> 16) + Math.round(255 * percent)));
const g = Math.min(255, Math.max(0, ((num >> 8) & 0x00FF) + Math.round(255 * percent)));
const b = Math.min(255, Math.max(0, (num & 0x0000FF) + Math.round(255 * percent)));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0').toUpperCase()}`;
const r = (num >> 16) & 0xFF;
const g = (num >> 8) & 0xFF;
const b = num & 0xFF;
const adjustChannel = (channel) => {
const adjusted = percent >= 0
? channel + (255 - channel) * percent
: channel * (1 + percent);
return Math.min(255, Math.max(0, Math.round(adjusted)));
};
const newR = adjustChannel(r);
const newG = adjustChannel(g);
const newB = adjustChannel(b);
return `#${((newR << 16) | (newG << 8) | newB).toString(16).padStart(6, '0').toUpperCase()}`;
}
/**

View File

@ -71,6 +71,40 @@ def test_sync_parses_bundled_starter_template(tmp_path):
assert css.exists() and css.stat().st_size > 0
def test_dark_base_color_does_not_collapse_shades_to_black(tmp_path):
"""adjustBrightness() used to add/subtract a flat 255*percent per channel.
For a dark base color (channels already close to 0), darkening by
-0.3/-0.45/-0.6 clamped every channel to 0, so shades 700, 800, and 900
all came back as the identical, useless #000000 instead of a graded dark
scale. This runs the sync against a dark, coffee-roastery-style brand
color and asserts the three shades stay distinct and non-black.
"""
(tmp_path / "docs").mkdir()
(tmp_path / "assets").mkdir()
shutil.copy(TOKENS_STARTER, tmp_path / "assets" / "design-tokens.json")
(tmp_path / "docs" / "brand-guidelines.md").write_text(
"## Quick Reference\n\n"
"| Element | Value |\n"
"|---------|-------|\n"
"| Primary Color | #4A3228 |\n"
"| Secondary Color | #C08A3E |\n"
"| Accent Color | #6B8F71 |\n"
)
result = _run(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
tokens = json.loads((tmp_path / "assets" / "design-tokens.json").read_text())
primary = tokens["primitive"]["color"]["primary"]
dark_shades = [primary[shade]["$value"] for shade in ("700", "800", "900")]
assert len(set(dark_shades)) == 3, (
f"expected three distinct dark shades, got {dark_shades}"
)
assert "#000000" not in dark_shades, dark_shades
def test_reports_missing_guidelines_without_breaking_the_harness(tmp_path):
"""The missing-guidelines path is the one that breaks a locale-decoded pipe.