From d8ce090f82580c68f5471cc6dd3f56faf9a37afb Mon Sep 17 00:00:00 2001 From: nadiiasoshenko-netizen Date: Tue, 15 Sep 2026 01:00:09 +0100 Subject: [PATCH] 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 --- .../brand/scripts/sync-brand-to-tokens.cjs | 29 +++++++++++++--- .../tests/test_sync_brand_to_tokens.py | 34 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/cli/assets/skills/brand/scripts/sync-brand-to-tokens.cjs b/cli/assets/skills/brand/scripts/sync-brand-to-tokens.cjs index 1e3b8ce..75e8430 100644 --- a/cli/assets/skills/brand/scripts/sync-brand-to-tokens.cjs +++ b/cli/assets/skills/brand/scripts/sync-brand-to-tokens.cjs @@ -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()}`; } /** diff --git a/cli/assets/skills/brand/scripts/tests/test_sync_brand_to_tokens.py b/cli/assets/skills/brand/scripts/tests/test_sync_brand_to_tokens.py index 53c5437..aed7946 100644 --- a/cli/assets/skills/brand/scripts/tests/test_sync_brand_to_tokens.py +++ b/cli/assets/skills/brand/scripts/tests/test_sync_brand_to_tokens.py @@ -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.