mirror of
https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git
synced 2026-07-31 18:36:13 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0ebb1797e | ||
|
|
ec1f2a9027 | ||
|
|
4857a2c5ef |
@ -77,6 +77,108 @@ def _resolve_dial(dial_name: str, value) -> dict:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============ COLOR MODE RESOLUTION ============
|
||||||
|
# Style, palette and anti-patterns are resolved from separate CSVs. Without a
|
||||||
|
# shared notion of "which mode did we land on", a dark-primary style can be
|
||||||
|
# paired with a light palette and a "don't use dark mode" anti-pattern.
|
||||||
|
|
||||||
|
# Phrases in styles.csv "Light Mode ✓" / "Dark Mode ✓" that mark a style as
|
||||||
|
# dark-first rather than merely dark-capable ("✓ Full" means both work).
|
||||||
|
_DARK_PRIMARY_MARKERS = (
|
||||||
|
"dark mode primary", "dark primary", "dark-only", "dark only",
|
||||||
|
"dark preferred", "dark focused", "dark-first", "dark rich",
|
||||||
|
"light mode only as exception",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Query phrases that are an explicit request for a dark theme.
|
||||||
|
_DARK_QUERY_MARKERS = (
|
||||||
|
"dark mode", "dark theme", "dark ui", "dark-mode", "darkmode",
|
||||||
|
"night mode", "midnight", "oled",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Anti-pattern clauses that contradict a resolved dark mode.
|
||||||
|
_DARK_ANTI_PATTERN_MARKERS = ("dark mode", "dark modes", "dark theme")
|
||||||
|
|
||||||
|
# Relative luminance below which a Background hex counts as a dark surface.
|
||||||
|
# #1F2937 (the lightest dark background in colors.csv) sits at ~0.026 and
|
||||||
|
# #E8ECF1 (the darkest light background) at ~0.79, so the gap is wide.
|
||||||
|
_DARK_BACKGROUND_MAX_LUMINANCE = 0.18
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_luminance(hex_color: str):
|
||||||
|
"""WCAG relative luminance of a #RRGGBB string, or None if unparseable."""
|
||||||
|
if not hex_color:
|
||||||
|
return None
|
||||||
|
value = hex_color.strip().lstrip("#")
|
||||||
|
if len(value) == 3:
|
||||||
|
value = "".join(c * 2 for c in value)
|
||||||
|
if len(value) != 6:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
channels = [int(value[i:i + 2], 16) / 255 for i in (0, 2, 4)]
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
linear = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||||
|
for c in channels]
|
||||||
|
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _palette_is_dark(palette: dict) -> bool:
|
||||||
|
"""True when a colors.csv row's Background is a dark surface."""
|
||||||
|
luminance = _relative_luminance((palette or {}).get("Background", ""))
|
||||||
|
return luminance is not None and luminance < _DARK_BACKGROUND_MAX_LUMINANCE
|
||||||
|
|
||||||
|
|
||||||
|
def _style_is_dark_primary(style: dict) -> bool:
|
||||||
|
"""True when a styles.csv row describes itself as dark-first."""
|
||||||
|
if not style:
|
||||||
|
return False
|
||||||
|
declared = "{} {}".format(
|
||||||
|
style.get("Light Mode ✓", ""), style.get("Dark Mode ✓", "")
|
||||||
|
).lower()
|
||||||
|
return any(marker in declared for marker in _DARK_PRIMARY_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_wants_dark(query: str) -> bool:
|
||||||
|
"""True when the query explicitly asks for a dark theme."""
|
||||||
|
lowered = (query or "").lower()
|
||||||
|
return any(marker in lowered for marker in _DARK_QUERY_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_color_mode(query: str, style: dict) -> str:
|
||||||
|
"""Resolve the mode the rest of the output has to agree with."""
|
||||||
|
if _query_wants_dark(query) or _style_is_dark_primary(style):
|
||||||
|
return "dark"
|
||||||
|
return "light"
|
||||||
|
|
||||||
|
|
||||||
|
def _select_palette_for_mode(palettes: list, mode: str) -> dict:
|
||||||
|
"""Pick the highest-ranked palette matching the resolved mode.
|
||||||
|
|
||||||
|
Only the dark case filters. Light is left on the existing "top hit wins"
|
||||||
|
behaviour so queries that never mention a mode keep their current palette.
|
||||||
|
Falls back to the top hit when the data has no matching ramp.
|
||||||
|
"""
|
||||||
|
if not palettes:
|
||||||
|
return {}
|
||||||
|
if mode == "dark":
|
||||||
|
for palette in palettes:
|
||||||
|
if _palette_is_dark(palette):
|
||||||
|
return palette
|
||||||
|
return palettes[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_anti_patterns_for_mode(anti_patterns: str, mode: str) -> str:
|
||||||
|
"""Drop "avoid dark mode" advice once dark mode is the resolved answer."""
|
||||||
|
if mode != "dark" or not anti_patterns:
|
||||||
|
return anti_patterns
|
||||||
|
kept = [
|
||||||
|
clause for clause in anti_patterns.split("+")
|
||||||
|
if not any(marker in clause.lower() for marker in _DARK_ANTI_PATTERN_MARKERS)
|
||||||
|
]
|
||||||
|
return " + ".join(clause.strip() for clause in kept if clause.strip())
|
||||||
|
|
||||||
|
|
||||||
# ============ DESIGN SYSTEM GENERATOR ============
|
# ============ DESIGN SYSTEM GENERATOR ============
|
||||||
class DesignSystemGenerator:
|
class DesignSystemGenerator:
|
||||||
"""Generates design system recommendations from aggregated searches."""
|
"""Generates design system recommendations from aggregated searches."""
|
||||||
@ -244,7 +346,11 @@ class DesignSystemGenerator:
|
|||||||
landing_results = self._extract_results(search_results.get("landing", {}))
|
landing_results = self._extract_results(search_results.get("landing", {}))
|
||||||
|
|
||||||
best_style = self._select_best_match(style_results, effective_style_priority)
|
best_style = self._select_best_match(style_results, effective_style_priority)
|
||||||
best_color = color_results[0] if color_results else {}
|
# Resolve the mode from the style + query first, then pick a palette that
|
||||||
|
# agrees with it. Ranking colors independently is what let a dark-primary
|
||||||
|
# style ship with a light background.
|
||||||
|
color_mode = _resolve_color_mode(query, best_style)
|
||||||
|
best_color = _select_palette_for_mode(color_results, color_mode)
|
||||||
best_typography = typography_results[0] if typography_results else {}
|
best_typography = typography_results[0] if typography_results else {}
|
||||||
best_landing = landing_results[0] if landing_results else {}
|
best_landing = landing_results[0] if landing_results else {}
|
||||||
|
|
||||||
@ -313,7 +419,9 @@ class DesignSystemGenerator:
|
|||||||
"css_import": best_typography.get("CSS Import", "")
|
"css_import": best_typography.get("CSS Import", "")
|
||||||
},
|
},
|
||||||
"key_effects": combined_effects,
|
"key_effects": combined_effects,
|
||||||
"anti_patterns": reasoning.get("anti_patterns", ""),
|
"anti_patterns": _filter_anti_patterns_for_mode(
|
||||||
|
reasoning.get("anti_patterns", ""), color_mode
|
||||||
|
),
|
||||||
"decision_rules": reasoning.get("decision_rules", {}),
|
"decision_rules": reasoning.get("decision_rules", {}),
|
||||||
"severity": reasoning.get("severity", "MEDIUM"),
|
"severity": reasoning.get("severity", "MEDIUM"),
|
||||||
"dials": {
|
"dials": {
|
||||||
|
|||||||
@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Regression tests for color-mode coherence in design_system.py (issue #428).
|
||||||
|
|
||||||
|
Style, palette and anti-patterns used to be resolved independently, so a
|
||||||
|
dark-primary style could be returned alongside a light palette and a
|
||||||
|
"Dark mode by default" anti-pattern.
|
||||||
|
|
||||||
|
Stdlib-only (unittest, not pytest) to match test_core.py -- this project ships
|
||||||
|
with zero external dependencies.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
python -m unittest discover -s scripts/tests -v
|
||||||
|
or directly:
|
||||||
|
python scripts/tests/test_design_system_mode.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from design_system import ( # noqa: E402
|
||||||
|
_filter_anti_patterns_for_mode,
|
||||||
|
_palette_is_dark,
|
||||||
|
_query_wants_dark,
|
||||||
|
_relative_luminance,
|
||||||
|
_resolve_color_mode,
|
||||||
|
_select_palette_for_mode,
|
||||||
|
_style_is_dark_primary,
|
||||||
|
DesignSystemGenerator,
|
||||||
|
) # noqa: I001 - private helpers first, public class last
|
||||||
|
|
||||||
|
LIGHT_PALETTE = {"Product Type": "SaaS", "Background": "#F8FAFC", "Foreground": "#020617"}
|
||||||
|
DARK_PALETTE = {"Product Type": "Fintech/Crypto", "Background": "#0F172A", "Foreground": "#F8FAFC"}
|
||||||
|
|
||||||
|
# Verbatim from styles.csv row "Modern Dark (Cinema Mobile)".
|
||||||
|
DARK_PRIMARY_STYLE = {
|
||||||
|
"Style Category": "Modern Dark (Cinema Mobile)",
|
||||||
|
"Light Mode ✓": "✓ Light mode only as exception",
|
||||||
|
"Dark Mode ✓": "✓ Dark Mode Primary",
|
||||||
|
}
|
||||||
|
DUAL_MODE_STYLE = {
|
||||||
|
"Style Category": "Minimalism",
|
||||||
|
"Light Mode ✓": "✓ Full",
|
||||||
|
"Dark Mode ✓": "✓ Full",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestLuminance(unittest.TestCase):
|
||||||
|
def test_parses_six_and_three_digit_hex(self):
|
||||||
|
self.assertAlmostEqual(_relative_luminance("#FFFFFF"), 1.0, places=6)
|
||||||
|
self.assertAlmostEqual(_relative_luminance("#000000"), 0.0, places=6)
|
||||||
|
self.assertAlmostEqual(_relative_luminance("#FFF"), 1.0, places=6)
|
||||||
|
|
||||||
|
def test_returns_none_for_unparseable(self):
|
||||||
|
for value in ("", "nope", "#12", "#GGGGGG", None):
|
||||||
|
self.assertIsNone(_relative_luminance(value))
|
||||||
|
|
||||||
|
def test_classifies_backgrounds_from_the_shipped_data(self):
|
||||||
|
# Lightest dark background and darkest light background in colors.csv.
|
||||||
|
self.assertTrue(_palette_is_dark({"Background": "#1F2937"}))
|
||||||
|
self.assertFalse(_palette_is_dark({"Background": "#E8ECF1"}))
|
||||||
|
|
||||||
|
def test_missing_background_is_not_dark(self):
|
||||||
|
self.assertFalse(_palette_is_dark({}))
|
||||||
|
self.assertFalse(_palette_is_dark(None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestModeResolution(unittest.TestCase):
|
||||||
|
def test_dark_primary_style_detected(self):
|
||||||
|
self.assertTrue(_style_is_dark_primary(DARK_PRIMARY_STYLE))
|
||||||
|
|
||||||
|
def test_dual_mode_style_is_not_dark_primary(self):
|
||||||
|
self.assertFalse(_style_is_dark_primary(DUAL_MODE_STYLE))
|
||||||
|
self.assertFalse(_style_is_dark_primary({}))
|
||||||
|
|
||||||
|
def test_query_keywords(self):
|
||||||
|
self.assertTrue(_query_wants_dark("fintech B2B professional dark mode"))
|
||||||
|
self.assertTrue(_query_wants_dark("gaming app OLED"))
|
||||||
|
self.assertFalse(_query_wants_dark("healthcare clinic booking app"))
|
||||||
|
self.assertFalse(_query_wants_dark(""))
|
||||||
|
|
||||||
|
def test_either_signal_resolves_dark(self):
|
||||||
|
self.assertEqual(_resolve_color_mode("saas dark mode", DUAL_MODE_STYLE), "dark")
|
||||||
|
self.assertEqual(_resolve_color_mode("saas", DARK_PRIMARY_STYLE), "dark")
|
||||||
|
self.assertEqual(_resolve_color_mode("saas", DUAL_MODE_STYLE), "light")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaletteSelection(unittest.TestCase):
|
||||||
|
def test_dark_mode_skips_light_palettes(self):
|
||||||
|
chosen = _select_palette_for_mode([LIGHT_PALETTE, DARK_PALETTE], "dark")
|
||||||
|
self.assertEqual(chosen["Background"], "#0F172A")
|
||||||
|
|
||||||
|
def test_dark_mode_falls_back_to_top_hit_when_no_dark_ramp_exists(self):
|
||||||
|
chosen = _select_palette_for_mode([LIGHT_PALETTE], "dark")
|
||||||
|
self.assertEqual(chosen["Background"], "#F8FAFC")
|
||||||
|
|
||||||
|
def test_light_mode_keeps_the_existing_top_hit_behaviour(self):
|
||||||
|
chosen = _select_palette_for_mode([DARK_PALETTE, LIGHT_PALETTE], "light")
|
||||||
|
self.assertEqual(chosen["Background"], "#0F172A")
|
||||||
|
|
||||||
|
def test_empty_results(self):
|
||||||
|
self.assertEqual(_select_palette_for_mode([], "dark"), {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestAntiPatternGating(unittest.TestCase):
|
||||||
|
def test_dark_clause_dropped_others_kept(self):
|
||||||
|
result = _filter_anti_patterns_for_mode(
|
||||||
|
"Excessive animation + Dark mode by default", "dark")
|
||||||
|
self.assertEqual(result, "Excessive animation")
|
||||||
|
|
||||||
|
def test_light_mode_is_a_no_op(self):
|
||||||
|
original = "Excessive animation + Dark mode by default"
|
||||||
|
self.assertEqual(_filter_anti_patterns_for_mode(original, "light"), original)
|
||||||
|
|
||||||
|
def test_unrelated_anti_patterns_survive_dark_mode(self):
|
||||||
|
original = "Complex jargon + Tiny tap targets"
|
||||||
|
self.assertEqual(_filter_anti_patterns_for_mode(original, "dark"), original)
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
self.assertEqual(_filter_anti_patterns_for_mode("", "dark"), "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEndToEndCoherence(unittest.TestCase):
|
||||||
|
"""The exact reproduction from issue #428."""
|
||||||
|
|
||||||
|
QUERY = "SaaS invoicing fintech B2B professional dark mode"
|
||||||
|
|
||||||
|
def test_dark_query_gets_a_dark_background(self):
|
||||||
|
ds = DesignSystemGenerator().generate(self.QUERY)
|
||||||
|
background = ds["colors"]["background"]
|
||||||
|
self.assertTrue(
|
||||||
|
_palette_is_dark({"Background": background}),
|
||||||
|
"dark-mode query returned a light background: {}".format(background),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dark_query_foreground_is_lighter_than_background(self):
|
||||||
|
ds = DesignSystemGenerator().generate(self.QUERY)
|
||||||
|
background = _relative_luminance(ds["colors"]["background"])
|
||||||
|
foreground = _relative_luminance(ds["colors"]["foreground"])
|
||||||
|
self.assertIsNotNone(background)
|
||||||
|
self.assertIsNotNone(foreground)
|
||||||
|
self.assertGreater(foreground, background)
|
||||||
|
|
||||||
|
def test_dark_query_does_not_advise_against_dark_mode(self):
|
||||||
|
ds = DesignSystemGenerator().generate(self.QUERY)
|
||||||
|
self.assertNotIn("dark mode", ds["anti_patterns"].lower())
|
||||||
|
|
||||||
|
def test_light_query_keeps_a_light_background(self):
|
||||||
|
ds = DesignSystemGenerator().generate("healthcare clinic booking app")
|
||||||
|
self.assertFalse(_palette_is_dark({"Background": ds["colors"]["background"]}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@ -77,6 +77,108 @@ def _resolve_dial(dial_name: str, value) -> dict:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============ COLOR MODE RESOLUTION ============
|
||||||
|
# Style, palette and anti-patterns are resolved from separate CSVs. Without a
|
||||||
|
# shared notion of "which mode did we land on", a dark-primary style can be
|
||||||
|
# paired with a light palette and a "don't use dark mode" anti-pattern.
|
||||||
|
|
||||||
|
# Phrases in styles.csv "Light Mode ✓" / "Dark Mode ✓" that mark a style as
|
||||||
|
# dark-first rather than merely dark-capable ("✓ Full" means both work).
|
||||||
|
_DARK_PRIMARY_MARKERS = (
|
||||||
|
"dark mode primary", "dark primary", "dark-only", "dark only",
|
||||||
|
"dark preferred", "dark focused", "dark-first", "dark rich",
|
||||||
|
"light mode only as exception",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Query phrases that are an explicit request for a dark theme.
|
||||||
|
_DARK_QUERY_MARKERS = (
|
||||||
|
"dark mode", "dark theme", "dark ui", "dark-mode", "darkmode",
|
||||||
|
"night mode", "midnight", "oled",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Anti-pattern clauses that contradict a resolved dark mode.
|
||||||
|
_DARK_ANTI_PATTERN_MARKERS = ("dark mode", "dark modes", "dark theme")
|
||||||
|
|
||||||
|
# Relative luminance below which a Background hex counts as a dark surface.
|
||||||
|
# #1F2937 (the lightest dark background in colors.csv) sits at ~0.026 and
|
||||||
|
# #E8ECF1 (the darkest light background) at ~0.79, so the gap is wide.
|
||||||
|
_DARK_BACKGROUND_MAX_LUMINANCE = 0.18
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_luminance(hex_color: str):
|
||||||
|
"""WCAG relative luminance of a #RRGGBB string, or None if unparseable."""
|
||||||
|
if not hex_color:
|
||||||
|
return None
|
||||||
|
value = hex_color.strip().lstrip("#")
|
||||||
|
if len(value) == 3:
|
||||||
|
value = "".join(c * 2 for c in value)
|
||||||
|
if len(value) != 6:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
channels = [int(value[i:i + 2], 16) / 255 for i in (0, 2, 4)]
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
linear = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||||
|
for c in channels]
|
||||||
|
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _palette_is_dark(palette: dict) -> bool:
|
||||||
|
"""True when a colors.csv row's Background is a dark surface."""
|
||||||
|
luminance = _relative_luminance((palette or {}).get("Background", ""))
|
||||||
|
return luminance is not None and luminance < _DARK_BACKGROUND_MAX_LUMINANCE
|
||||||
|
|
||||||
|
|
||||||
|
def _style_is_dark_primary(style: dict) -> bool:
|
||||||
|
"""True when a styles.csv row describes itself as dark-first."""
|
||||||
|
if not style:
|
||||||
|
return False
|
||||||
|
declared = "{} {}".format(
|
||||||
|
style.get("Light Mode ✓", ""), style.get("Dark Mode ✓", "")
|
||||||
|
).lower()
|
||||||
|
return any(marker in declared for marker in _DARK_PRIMARY_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_wants_dark(query: str) -> bool:
|
||||||
|
"""True when the query explicitly asks for a dark theme."""
|
||||||
|
lowered = (query or "").lower()
|
||||||
|
return any(marker in lowered for marker in _DARK_QUERY_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_color_mode(query: str, style: dict) -> str:
|
||||||
|
"""Resolve the mode the rest of the output has to agree with."""
|
||||||
|
if _query_wants_dark(query) or _style_is_dark_primary(style):
|
||||||
|
return "dark"
|
||||||
|
return "light"
|
||||||
|
|
||||||
|
|
||||||
|
def _select_palette_for_mode(palettes: list, mode: str) -> dict:
|
||||||
|
"""Pick the highest-ranked palette matching the resolved mode.
|
||||||
|
|
||||||
|
Only the dark case filters. Light is left on the existing "top hit wins"
|
||||||
|
behaviour so queries that never mention a mode keep their current palette.
|
||||||
|
Falls back to the top hit when the data has no matching ramp.
|
||||||
|
"""
|
||||||
|
if not palettes:
|
||||||
|
return {}
|
||||||
|
if mode == "dark":
|
||||||
|
for palette in palettes:
|
||||||
|
if _palette_is_dark(palette):
|
||||||
|
return palette
|
||||||
|
return palettes[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_anti_patterns_for_mode(anti_patterns: str, mode: str) -> str:
|
||||||
|
"""Drop "avoid dark mode" advice once dark mode is the resolved answer."""
|
||||||
|
if mode != "dark" or not anti_patterns:
|
||||||
|
return anti_patterns
|
||||||
|
kept = [
|
||||||
|
clause for clause in anti_patterns.split("+")
|
||||||
|
if not any(marker in clause.lower() for marker in _DARK_ANTI_PATTERN_MARKERS)
|
||||||
|
]
|
||||||
|
return " + ".join(clause.strip() for clause in kept if clause.strip())
|
||||||
|
|
||||||
|
|
||||||
# ============ DESIGN SYSTEM GENERATOR ============
|
# ============ DESIGN SYSTEM GENERATOR ============
|
||||||
class DesignSystemGenerator:
|
class DesignSystemGenerator:
|
||||||
"""Generates design system recommendations from aggregated searches."""
|
"""Generates design system recommendations from aggregated searches."""
|
||||||
@ -244,7 +346,11 @@ class DesignSystemGenerator:
|
|||||||
landing_results = self._extract_results(search_results.get("landing", {}))
|
landing_results = self._extract_results(search_results.get("landing", {}))
|
||||||
|
|
||||||
best_style = self._select_best_match(style_results, effective_style_priority)
|
best_style = self._select_best_match(style_results, effective_style_priority)
|
||||||
best_color = color_results[0] if color_results else {}
|
# Resolve the mode from the style + query first, then pick a palette that
|
||||||
|
# agrees with it. Ranking colors independently is what let a dark-primary
|
||||||
|
# style ship with a light background.
|
||||||
|
color_mode = _resolve_color_mode(query, best_style)
|
||||||
|
best_color = _select_palette_for_mode(color_results, color_mode)
|
||||||
best_typography = typography_results[0] if typography_results else {}
|
best_typography = typography_results[0] if typography_results else {}
|
||||||
best_landing = landing_results[0] if landing_results else {}
|
best_landing = landing_results[0] if landing_results else {}
|
||||||
|
|
||||||
@ -313,7 +419,9 @@ class DesignSystemGenerator:
|
|||||||
"css_import": best_typography.get("CSS Import", "")
|
"css_import": best_typography.get("CSS Import", "")
|
||||||
},
|
},
|
||||||
"key_effects": combined_effects,
|
"key_effects": combined_effects,
|
||||||
"anti_patterns": reasoning.get("anti_patterns", ""),
|
"anti_patterns": _filter_anti_patterns_for_mode(
|
||||||
|
reasoning.get("anti_patterns", ""), color_mode
|
||||||
|
),
|
||||||
"decision_rules": reasoning.get("decision_rules", {}),
|
"decision_rules": reasoning.get("decision_rules", {}),
|
||||||
"severity": reasoning.get("severity", "MEDIUM"),
|
"severity": reasoning.get("severity", "MEDIUM"),
|
||||||
"dials": {
|
"dials": {
|
||||||
|
|||||||
159
cli/assets/scripts/tests/test_design_system_mode.py
Normal file
159
cli/assets/scripts/tests/test_design_system_mode.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Regression tests for color-mode coherence in design_system.py (issue #428).
|
||||||
|
|
||||||
|
Style, palette and anti-patterns used to be resolved independently, so a
|
||||||
|
dark-primary style could be returned alongside a light palette and a
|
||||||
|
"Dark mode by default" anti-pattern.
|
||||||
|
|
||||||
|
Stdlib-only (unittest, not pytest) to match test_core.py -- this project ships
|
||||||
|
with zero external dependencies.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
python -m unittest discover -s scripts/tests -v
|
||||||
|
or directly:
|
||||||
|
python scripts/tests/test_design_system_mode.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from design_system import ( # noqa: E402
|
||||||
|
_filter_anti_patterns_for_mode,
|
||||||
|
_palette_is_dark,
|
||||||
|
_query_wants_dark,
|
||||||
|
_relative_luminance,
|
||||||
|
_resolve_color_mode,
|
||||||
|
_select_palette_for_mode,
|
||||||
|
_style_is_dark_primary,
|
||||||
|
DesignSystemGenerator,
|
||||||
|
) # noqa: I001 - private helpers first, public class last
|
||||||
|
|
||||||
|
LIGHT_PALETTE = {"Product Type": "SaaS", "Background": "#F8FAFC", "Foreground": "#020617"}
|
||||||
|
DARK_PALETTE = {"Product Type": "Fintech/Crypto", "Background": "#0F172A", "Foreground": "#F8FAFC"}
|
||||||
|
|
||||||
|
# Verbatim from styles.csv row "Modern Dark (Cinema Mobile)".
|
||||||
|
DARK_PRIMARY_STYLE = {
|
||||||
|
"Style Category": "Modern Dark (Cinema Mobile)",
|
||||||
|
"Light Mode ✓": "✓ Light mode only as exception",
|
||||||
|
"Dark Mode ✓": "✓ Dark Mode Primary",
|
||||||
|
}
|
||||||
|
DUAL_MODE_STYLE = {
|
||||||
|
"Style Category": "Minimalism",
|
||||||
|
"Light Mode ✓": "✓ Full",
|
||||||
|
"Dark Mode ✓": "✓ Full",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestLuminance(unittest.TestCase):
|
||||||
|
def test_parses_six_and_three_digit_hex(self):
|
||||||
|
self.assertAlmostEqual(_relative_luminance("#FFFFFF"), 1.0, places=6)
|
||||||
|
self.assertAlmostEqual(_relative_luminance("#000000"), 0.0, places=6)
|
||||||
|
self.assertAlmostEqual(_relative_luminance("#FFF"), 1.0, places=6)
|
||||||
|
|
||||||
|
def test_returns_none_for_unparseable(self):
|
||||||
|
for value in ("", "nope", "#12", "#GGGGGG", None):
|
||||||
|
self.assertIsNone(_relative_luminance(value))
|
||||||
|
|
||||||
|
def test_classifies_backgrounds_from_the_shipped_data(self):
|
||||||
|
# Lightest dark background and darkest light background in colors.csv.
|
||||||
|
self.assertTrue(_palette_is_dark({"Background": "#1F2937"}))
|
||||||
|
self.assertFalse(_palette_is_dark({"Background": "#E8ECF1"}))
|
||||||
|
|
||||||
|
def test_missing_background_is_not_dark(self):
|
||||||
|
self.assertFalse(_palette_is_dark({}))
|
||||||
|
self.assertFalse(_palette_is_dark(None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestModeResolution(unittest.TestCase):
|
||||||
|
def test_dark_primary_style_detected(self):
|
||||||
|
self.assertTrue(_style_is_dark_primary(DARK_PRIMARY_STYLE))
|
||||||
|
|
||||||
|
def test_dual_mode_style_is_not_dark_primary(self):
|
||||||
|
self.assertFalse(_style_is_dark_primary(DUAL_MODE_STYLE))
|
||||||
|
self.assertFalse(_style_is_dark_primary({}))
|
||||||
|
|
||||||
|
def test_query_keywords(self):
|
||||||
|
self.assertTrue(_query_wants_dark("fintech B2B professional dark mode"))
|
||||||
|
self.assertTrue(_query_wants_dark("gaming app OLED"))
|
||||||
|
self.assertFalse(_query_wants_dark("healthcare clinic booking app"))
|
||||||
|
self.assertFalse(_query_wants_dark(""))
|
||||||
|
|
||||||
|
def test_either_signal_resolves_dark(self):
|
||||||
|
self.assertEqual(_resolve_color_mode("saas dark mode", DUAL_MODE_STYLE), "dark")
|
||||||
|
self.assertEqual(_resolve_color_mode("saas", DARK_PRIMARY_STYLE), "dark")
|
||||||
|
self.assertEqual(_resolve_color_mode("saas", DUAL_MODE_STYLE), "light")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaletteSelection(unittest.TestCase):
|
||||||
|
def test_dark_mode_skips_light_palettes(self):
|
||||||
|
chosen = _select_palette_for_mode([LIGHT_PALETTE, DARK_PALETTE], "dark")
|
||||||
|
self.assertEqual(chosen["Background"], "#0F172A")
|
||||||
|
|
||||||
|
def test_dark_mode_falls_back_to_top_hit_when_no_dark_ramp_exists(self):
|
||||||
|
chosen = _select_palette_for_mode([LIGHT_PALETTE], "dark")
|
||||||
|
self.assertEqual(chosen["Background"], "#F8FAFC")
|
||||||
|
|
||||||
|
def test_light_mode_keeps_the_existing_top_hit_behaviour(self):
|
||||||
|
chosen = _select_palette_for_mode([DARK_PALETTE, LIGHT_PALETTE], "light")
|
||||||
|
self.assertEqual(chosen["Background"], "#0F172A")
|
||||||
|
|
||||||
|
def test_empty_results(self):
|
||||||
|
self.assertEqual(_select_palette_for_mode([], "dark"), {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestAntiPatternGating(unittest.TestCase):
|
||||||
|
def test_dark_clause_dropped_others_kept(self):
|
||||||
|
result = _filter_anti_patterns_for_mode(
|
||||||
|
"Excessive animation + Dark mode by default", "dark")
|
||||||
|
self.assertEqual(result, "Excessive animation")
|
||||||
|
|
||||||
|
def test_light_mode_is_a_no_op(self):
|
||||||
|
original = "Excessive animation + Dark mode by default"
|
||||||
|
self.assertEqual(_filter_anti_patterns_for_mode(original, "light"), original)
|
||||||
|
|
||||||
|
def test_unrelated_anti_patterns_survive_dark_mode(self):
|
||||||
|
original = "Complex jargon + Tiny tap targets"
|
||||||
|
self.assertEqual(_filter_anti_patterns_for_mode(original, "dark"), original)
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
self.assertEqual(_filter_anti_patterns_for_mode("", "dark"), "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEndToEndCoherence(unittest.TestCase):
|
||||||
|
"""The exact reproduction from issue #428."""
|
||||||
|
|
||||||
|
QUERY = "SaaS invoicing fintech B2B professional dark mode"
|
||||||
|
|
||||||
|
def test_dark_query_gets_a_dark_background(self):
|
||||||
|
ds = DesignSystemGenerator().generate(self.QUERY)
|
||||||
|
background = ds["colors"]["background"]
|
||||||
|
self.assertTrue(
|
||||||
|
_palette_is_dark({"Background": background}),
|
||||||
|
"dark-mode query returned a light background: {}".format(background),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dark_query_foreground_is_lighter_than_background(self):
|
||||||
|
ds = DesignSystemGenerator().generate(self.QUERY)
|
||||||
|
background = _relative_luminance(ds["colors"]["background"])
|
||||||
|
foreground = _relative_luminance(ds["colors"]["foreground"])
|
||||||
|
self.assertIsNotNone(background)
|
||||||
|
self.assertIsNotNone(foreground)
|
||||||
|
self.assertGreater(foreground, background)
|
||||||
|
|
||||||
|
def test_dark_query_does_not_advise_against_dark_mode(self):
|
||||||
|
ds = DesignSystemGenerator().generate(self.QUERY)
|
||||||
|
self.assertNotIn("dark mode", ds["anti_patterns"].lower())
|
||||||
|
|
||||||
|
def test_light_query_keeps_a_light_background(self):
|
||||||
|
ds = DesignSystemGenerator().generate("healthcare clinic booking app")
|
||||||
|
self.assertFalse(_palette_is_dark({"Background": ds["colors"]["background"]}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@ -173,7 +173,7 @@ class TestShadcnInstaller:
|
|||||||
# Verify correct command was called
|
# Verify correct command was called
|
||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
call_args = mock_run.call_args[0][0]
|
call_args = mock_run.call_args[0][0]
|
||||||
assert call_args[:3] == ["npx", "shadcn@latest", "add"]
|
assert call_args[:3] == ["npx", "shadcn@2.3.0", "add"]
|
||||||
assert "button" in call_args
|
assert "button" in call_args
|
||||||
assert "card" in call_args
|
assert "card" in call_args
|
||||||
|
|
||||||
|
|||||||
@ -4,13 +4,14 @@
|
|||||||
"installType": "full",
|
"installType": "full",
|
||||||
"folderStructure": {
|
"folderStructure": {
|
||||||
"root": ".github",
|
"root": ".github",
|
||||||
"skillPath": "prompts/ui-ux-pro-max",
|
"skillPath": "prompts",
|
||||||
"filename": "PROMPT.md"
|
"filename": "ui-ux-pro-max.prompt.md",
|
||||||
|
"dataPath": "prompts/ui-ux-pro-max"
|
||||||
},
|
},
|
||||||
"scriptPath": "prompts/ui-ux-pro-max/scripts/search.py",
|
"scriptPath": "prompts/ui-ux-pro-max/scripts/search.py",
|
||||||
"frontmatter": {
|
"frontmatter": {
|
||||||
"name": "ui-ux-pro-max",
|
"description": "Comprehensive design guide for web, mobile, and desktop applications. Contains 67 styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 22 technology stacks.",
|
||||||
"description": "Comprehensive design guide for web, mobile, and desktop applications. Contains 67 styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 22 technology stacks."
|
"mode": "agent"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"quickReference": false
|
"quickReference": false
|
||||||
|
|||||||
@ -30,9 +30,18 @@ async function removeSkillDir(baseDir: string, aiType: ConcreteAIType): Promise<
|
|||||||
// `.github/prompts/`, kiro under `.kiro/steering/`. Also clean the legacy
|
// `.github/prompts/`, kiro under `.kiro/steering/`. Also clean the legacy
|
||||||
// `<folder>/skills/` layout (incl. `.shared/`) so older installs are removed.
|
// `<folder>/skills/` layout (incl. `.shared/`) so older installs are removed.
|
||||||
const parents = new Set<string>();
|
const parents = new Set<string>();
|
||||||
|
// Standalone skill files to remove (platforms with a dataPath keep the
|
||||||
|
// rendered skill file apart from the data directory — e.g. copilot's
|
||||||
|
// `.github/prompts/ui-ux-pro-max.prompt.md`).
|
||||||
|
const skillFiles = new Set<string>();
|
||||||
try {
|
try {
|
||||||
const { folderStructure } = await loadPlatformConfig(aiType);
|
const { folderStructure } = await loadPlatformConfig(aiType);
|
||||||
|
if (folderStructure.dataPath) {
|
||||||
|
skillFiles.add(join(folderStructure.root, folderStructure.skillPath, folderStructure.filename));
|
||||||
|
parents.add(join(folderStructure.root, dirname(folderStructure.dataPath)));
|
||||||
|
} else {
|
||||||
parents.add(join(folderStructure.root, dirname(folderStructure.skillPath)));
|
parents.add(join(folderStructure.root, dirname(folderStructure.skillPath)));
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// No platform config — fall back to the legacy folders below.
|
// No platform config — fall back to the legacy folders below.
|
||||||
}
|
}
|
||||||
@ -40,6 +49,18 @@ async function removeSkillDir(baseDir: string, aiType: ConcreteAIType): Promise<
|
|||||||
parents.add(join(folder, 'skills'));
|
parents.add(join(folder, 'skills'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const skillFile of skillFiles) {
|
||||||
|
const filePath = join(baseDir, skillFile);
|
||||||
|
try {
|
||||||
|
await stat(filePath);
|
||||||
|
await rm(filePath, { force: true });
|
||||||
|
removed.push(skillFile.replaceAll('\\', '/'));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Skip non-existent files; re-throw permission or other errors
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const parent of parents) {
|
for (const parent of parents) {
|
||||||
for (const name of skillNames) {
|
for (const name of skillNames) {
|
||||||
const skillDir = join(baseDir, parent, name);
|
const skillDir = join(baseDir, parent, name);
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export interface PlatformConfig {
|
|||||||
root: string;
|
root: string;
|
||||||
skillPath: string;
|
skillPath: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
dataPath?: string;
|
||||||
};
|
};
|
||||||
scriptPath: string;
|
scriptPath: string;
|
||||||
frontmatter: Record<string, string> | null;
|
frontmatter: Record<string, string> | null;
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export interface PlatformConfig {
|
|||||||
root: string;
|
root: string;
|
||||||
skillPath: string;
|
skillPath: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
dataPath?: string;
|
||||||
};
|
};
|
||||||
scriptPath: string;
|
scriptPath: string;
|
||||||
frontmatter: Record<string, string> | null;
|
frontmatter: Record<string, string> | null;
|
||||||
@ -151,12 +152,22 @@ export async function renderSkillFile(config: PlatformConfig, isGlobal = false):
|
|||||||
.replace(/\{\{SKILL_OR_WORKFLOW\}\}/g, config.skillOrWorkflow)
|
.replace(/\{\{SKILL_OR_WORKFLOW\}\}/g, config.skillOrWorkflow)
|
||||||
.replace(/\{\{QUICK_REFERENCE\}\}/g, quickRefWithNewline);
|
.replace(/\{\{QUICK_REFERENCE\}\}/g, quickRefWithNewline);
|
||||||
|
|
||||||
|
// Rewrite the hardcoded default script path to the platform-specific path
|
||||||
|
const defaultScriptPath = 'skills/ui-ux-pro-max/scripts/search.py';
|
||||||
|
if (config.scriptPath !== defaultScriptPath) {
|
||||||
|
content = content.replace(
|
||||||
|
new RegExp(defaultScriptPath.replace(/\//g, '\\/'), 'g'),
|
||||||
|
config.scriptPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// For global install, rewrite relative script paths to absolute ~/root/ paths
|
// For global install, rewrite relative script paths to absolute ~/root/ paths
|
||||||
if (isGlobal) {
|
if (isGlobal) {
|
||||||
const globalPrefix = `~/${config.folderStructure.root}/`;
|
const globalPrefix = `~/${config.folderStructure.root}/`;
|
||||||
|
// Match any platform's script path pattern (skills/, prompts/, steering/, etc.)
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/python3 skills\//g,
|
new RegExp(`python3 ${config.scriptPath.replace(/\//g, '\\/')}`, 'g'),
|
||||||
`python3 ${globalPrefix}skills/`
|
`python3 ${globalPrefix}${config.scriptPath}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -272,17 +283,24 @@ export async function generatePlatformFiles(
|
|||||||
await writeFile(skillFilePath, skillContent, 'utf-8');
|
await writeFile(skillFilePath, skillContent, 'utf-8');
|
||||||
createdFolders.push(config.folderStructure.root);
|
createdFolders.push(config.folderStructure.root);
|
||||||
|
|
||||||
// Copy data and scripts into the skill directory (self-contained)
|
// Copy data and scripts into the data directory (may differ from skill file location)
|
||||||
await copyDataAndScripts(skillDir);
|
const dataDir = config.folderStructure.dataPath
|
||||||
|
? join(effectiveDir, config.folderStructure.root, config.folderStructure.dataPath)
|
||||||
|
: skillDir;
|
||||||
|
await mkdir(dataDir, { recursive: true });
|
||||||
|
await copyDataAndScripts(dataDir);
|
||||||
|
|
||||||
// Install the sibling sub-skills (banner-design, brand, design, ...) next to
|
// Install the sibling sub-skills (banner-design, brand, design, ...) next to
|
||||||
// the orchestrator so all 7 skills are delivered. The skills parent is the
|
// the orchestrator so all 7 skills are delivered. The skills parent is the
|
||||||
// orchestrator's parent dir (skills/ for most platforms, prompts/ for
|
// orchestrator's parent dir (skills/ for most platforms, prompts/ for
|
||||||
// copilot, steering/ for kiro) — derived, not hardcoded.
|
// copilot, steering/ for kiro) — derived, not hardcoded. For platforms with
|
||||||
|
// a separate dataPath (copilot), the orchestrator's data dir is the anchor.
|
||||||
const skillsParentDir = join(
|
const skillsParentDir = join(
|
||||||
effectiveDir,
|
effectiveDir,
|
||||||
config.folderStructure.root,
|
config.folderStructure.root,
|
||||||
dirname(config.folderStructure.skillPath)
|
config.folderStructure.dataPath
|
||||||
|
? dirname(config.folderStructure.dataPath)
|
||||||
|
: dirname(config.folderStructure.skillPath)
|
||||||
);
|
);
|
||||||
await copySubSkills(skillsParentDir, force);
|
await copySubSkills(skillsParentDir, force);
|
||||||
|
|
||||||
|
|||||||
@ -77,6 +77,108 @@ def _resolve_dial(dial_name: str, value) -> dict:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============ COLOR MODE RESOLUTION ============
|
||||||
|
# Style, palette and anti-patterns are resolved from separate CSVs. Without a
|
||||||
|
# shared notion of "which mode did we land on", a dark-primary style can be
|
||||||
|
# paired with a light palette and a "don't use dark mode" anti-pattern.
|
||||||
|
|
||||||
|
# Phrases in styles.csv "Light Mode ✓" / "Dark Mode ✓" that mark a style as
|
||||||
|
# dark-first rather than merely dark-capable ("✓ Full" means both work).
|
||||||
|
_DARK_PRIMARY_MARKERS = (
|
||||||
|
"dark mode primary", "dark primary", "dark-only", "dark only",
|
||||||
|
"dark preferred", "dark focused", "dark-first", "dark rich",
|
||||||
|
"light mode only as exception",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Query phrases that are an explicit request for a dark theme.
|
||||||
|
_DARK_QUERY_MARKERS = (
|
||||||
|
"dark mode", "dark theme", "dark ui", "dark-mode", "darkmode",
|
||||||
|
"night mode", "midnight", "oled",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Anti-pattern clauses that contradict a resolved dark mode.
|
||||||
|
_DARK_ANTI_PATTERN_MARKERS = ("dark mode", "dark modes", "dark theme")
|
||||||
|
|
||||||
|
# Relative luminance below which a Background hex counts as a dark surface.
|
||||||
|
# #1F2937 (the lightest dark background in colors.csv) sits at ~0.026 and
|
||||||
|
# #E8ECF1 (the darkest light background) at ~0.79, so the gap is wide.
|
||||||
|
_DARK_BACKGROUND_MAX_LUMINANCE = 0.18
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_luminance(hex_color: str):
|
||||||
|
"""WCAG relative luminance of a #RRGGBB string, or None if unparseable."""
|
||||||
|
if not hex_color:
|
||||||
|
return None
|
||||||
|
value = hex_color.strip().lstrip("#")
|
||||||
|
if len(value) == 3:
|
||||||
|
value = "".join(c * 2 for c in value)
|
||||||
|
if len(value) != 6:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
channels = [int(value[i:i + 2], 16) / 255 for i in (0, 2, 4)]
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
linear = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||||
|
for c in channels]
|
||||||
|
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _palette_is_dark(palette: dict) -> bool:
|
||||||
|
"""True when a colors.csv row's Background is a dark surface."""
|
||||||
|
luminance = _relative_luminance((palette or {}).get("Background", ""))
|
||||||
|
return luminance is not None and luminance < _DARK_BACKGROUND_MAX_LUMINANCE
|
||||||
|
|
||||||
|
|
||||||
|
def _style_is_dark_primary(style: dict) -> bool:
|
||||||
|
"""True when a styles.csv row describes itself as dark-first."""
|
||||||
|
if not style:
|
||||||
|
return False
|
||||||
|
declared = "{} {}".format(
|
||||||
|
style.get("Light Mode ✓", ""), style.get("Dark Mode ✓", "")
|
||||||
|
).lower()
|
||||||
|
return any(marker in declared for marker in _DARK_PRIMARY_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_wants_dark(query: str) -> bool:
|
||||||
|
"""True when the query explicitly asks for a dark theme."""
|
||||||
|
lowered = (query or "").lower()
|
||||||
|
return any(marker in lowered for marker in _DARK_QUERY_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_color_mode(query: str, style: dict) -> str:
|
||||||
|
"""Resolve the mode the rest of the output has to agree with."""
|
||||||
|
if _query_wants_dark(query) or _style_is_dark_primary(style):
|
||||||
|
return "dark"
|
||||||
|
return "light"
|
||||||
|
|
||||||
|
|
||||||
|
def _select_palette_for_mode(palettes: list, mode: str) -> dict:
|
||||||
|
"""Pick the highest-ranked palette matching the resolved mode.
|
||||||
|
|
||||||
|
Only the dark case filters. Light is left on the existing "top hit wins"
|
||||||
|
behaviour so queries that never mention a mode keep their current palette.
|
||||||
|
Falls back to the top hit when the data has no matching ramp.
|
||||||
|
"""
|
||||||
|
if not palettes:
|
||||||
|
return {}
|
||||||
|
if mode == "dark":
|
||||||
|
for palette in palettes:
|
||||||
|
if _palette_is_dark(palette):
|
||||||
|
return palette
|
||||||
|
return palettes[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_anti_patterns_for_mode(anti_patterns: str, mode: str) -> str:
|
||||||
|
"""Drop "avoid dark mode" advice once dark mode is the resolved answer."""
|
||||||
|
if mode != "dark" or not anti_patterns:
|
||||||
|
return anti_patterns
|
||||||
|
kept = [
|
||||||
|
clause for clause in anti_patterns.split("+")
|
||||||
|
if not any(marker in clause.lower() for marker in _DARK_ANTI_PATTERN_MARKERS)
|
||||||
|
]
|
||||||
|
return " + ".join(clause.strip() for clause in kept if clause.strip())
|
||||||
|
|
||||||
|
|
||||||
# ============ DESIGN SYSTEM GENERATOR ============
|
# ============ DESIGN SYSTEM GENERATOR ============
|
||||||
class DesignSystemGenerator:
|
class DesignSystemGenerator:
|
||||||
"""Generates design system recommendations from aggregated searches."""
|
"""Generates design system recommendations from aggregated searches."""
|
||||||
@ -244,7 +346,11 @@ class DesignSystemGenerator:
|
|||||||
landing_results = self._extract_results(search_results.get("landing", {}))
|
landing_results = self._extract_results(search_results.get("landing", {}))
|
||||||
|
|
||||||
best_style = self._select_best_match(style_results, effective_style_priority)
|
best_style = self._select_best_match(style_results, effective_style_priority)
|
||||||
best_color = color_results[0] if color_results else {}
|
# Resolve the mode from the style + query first, then pick a palette that
|
||||||
|
# agrees with it. Ranking colors independently is what let a dark-primary
|
||||||
|
# style ship with a light background.
|
||||||
|
color_mode = _resolve_color_mode(query, best_style)
|
||||||
|
best_color = _select_palette_for_mode(color_results, color_mode)
|
||||||
best_typography = typography_results[0] if typography_results else {}
|
best_typography = typography_results[0] if typography_results else {}
|
||||||
best_landing = landing_results[0] if landing_results else {}
|
best_landing = landing_results[0] if landing_results else {}
|
||||||
|
|
||||||
@ -313,7 +419,9 @@ class DesignSystemGenerator:
|
|||||||
"css_import": best_typography.get("CSS Import", "")
|
"css_import": best_typography.get("CSS Import", "")
|
||||||
},
|
},
|
||||||
"key_effects": combined_effects,
|
"key_effects": combined_effects,
|
||||||
"anti_patterns": reasoning.get("anti_patterns", ""),
|
"anti_patterns": _filter_anti_patterns_for_mode(
|
||||||
|
reasoning.get("anti_patterns", ""), color_mode
|
||||||
|
),
|
||||||
"decision_rules": reasoning.get("decision_rules", {}),
|
"decision_rules": reasoning.get("decision_rules", {}),
|
||||||
"severity": reasoning.get("severity", "MEDIUM"),
|
"severity": reasoning.get("severity", "MEDIUM"),
|
||||||
"dials": {
|
"dials": {
|
||||||
|
|||||||
159
src/ui-ux-pro-max/scripts/tests/test_design_system_mode.py
Normal file
159
src/ui-ux-pro-max/scripts/tests/test_design_system_mode.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Regression tests for color-mode coherence in design_system.py (issue #428).
|
||||||
|
|
||||||
|
Style, palette and anti-patterns used to be resolved independently, so a
|
||||||
|
dark-primary style could be returned alongside a light palette and a
|
||||||
|
"Dark mode by default" anti-pattern.
|
||||||
|
|
||||||
|
Stdlib-only (unittest, not pytest) to match test_core.py -- this project ships
|
||||||
|
with zero external dependencies.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
python -m unittest discover -s scripts/tests -v
|
||||||
|
or directly:
|
||||||
|
python scripts/tests/test_design_system_mode.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
from design_system import ( # noqa: E402
|
||||||
|
_filter_anti_patterns_for_mode,
|
||||||
|
_palette_is_dark,
|
||||||
|
_query_wants_dark,
|
||||||
|
_relative_luminance,
|
||||||
|
_resolve_color_mode,
|
||||||
|
_select_palette_for_mode,
|
||||||
|
_style_is_dark_primary,
|
||||||
|
DesignSystemGenerator,
|
||||||
|
) # noqa: I001 - private helpers first, public class last
|
||||||
|
|
||||||
|
LIGHT_PALETTE = {"Product Type": "SaaS", "Background": "#F8FAFC", "Foreground": "#020617"}
|
||||||
|
DARK_PALETTE = {"Product Type": "Fintech/Crypto", "Background": "#0F172A", "Foreground": "#F8FAFC"}
|
||||||
|
|
||||||
|
# Verbatim from styles.csv row "Modern Dark (Cinema Mobile)".
|
||||||
|
DARK_PRIMARY_STYLE = {
|
||||||
|
"Style Category": "Modern Dark (Cinema Mobile)",
|
||||||
|
"Light Mode ✓": "✓ Light mode only as exception",
|
||||||
|
"Dark Mode ✓": "✓ Dark Mode Primary",
|
||||||
|
}
|
||||||
|
DUAL_MODE_STYLE = {
|
||||||
|
"Style Category": "Minimalism",
|
||||||
|
"Light Mode ✓": "✓ Full",
|
||||||
|
"Dark Mode ✓": "✓ Full",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestLuminance(unittest.TestCase):
|
||||||
|
def test_parses_six_and_three_digit_hex(self):
|
||||||
|
self.assertAlmostEqual(_relative_luminance("#FFFFFF"), 1.0, places=6)
|
||||||
|
self.assertAlmostEqual(_relative_luminance("#000000"), 0.0, places=6)
|
||||||
|
self.assertAlmostEqual(_relative_luminance("#FFF"), 1.0, places=6)
|
||||||
|
|
||||||
|
def test_returns_none_for_unparseable(self):
|
||||||
|
for value in ("", "nope", "#12", "#GGGGGG", None):
|
||||||
|
self.assertIsNone(_relative_luminance(value))
|
||||||
|
|
||||||
|
def test_classifies_backgrounds_from_the_shipped_data(self):
|
||||||
|
# Lightest dark background and darkest light background in colors.csv.
|
||||||
|
self.assertTrue(_palette_is_dark({"Background": "#1F2937"}))
|
||||||
|
self.assertFalse(_palette_is_dark({"Background": "#E8ECF1"}))
|
||||||
|
|
||||||
|
def test_missing_background_is_not_dark(self):
|
||||||
|
self.assertFalse(_palette_is_dark({}))
|
||||||
|
self.assertFalse(_palette_is_dark(None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestModeResolution(unittest.TestCase):
|
||||||
|
def test_dark_primary_style_detected(self):
|
||||||
|
self.assertTrue(_style_is_dark_primary(DARK_PRIMARY_STYLE))
|
||||||
|
|
||||||
|
def test_dual_mode_style_is_not_dark_primary(self):
|
||||||
|
self.assertFalse(_style_is_dark_primary(DUAL_MODE_STYLE))
|
||||||
|
self.assertFalse(_style_is_dark_primary({}))
|
||||||
|
|
||||||
|
def test_query_keywords(self):
|
||||||
|
self.assertTrue(_query_wants_dark("fintech B2B professional dark mode"))
|
||||||
|
self.assertTrue(_query_wants_dark("gaming app OLED"))
|
||||||
|
self.assertFalse(_query_wants_dark("healthcare clinic booking app"))
|
||||||
|
self.assertFalse(_query_wants_dark(""))
|
||||||
|
|
||||||
|
def test_either_signal_resolves_dark(self):
|
||||||
|
self.assertEqual(_resolve_color_mode("saas dark mode", DUAL_MODE_STYLE), "dark")
|
||||||
|
self.assertEqual(_resolve_color_mode("saas", DARK_PRIMARY_STYLE), "dark")
|
||||||
|
self.assertEqual(_resolve_color_mode("saas", DUAL_MODE_STYLE), "light")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaletteSelection(unittest.TestCase):
|
||||||
|
def test_dark_mode_skips_light_palettes(self):
|
||||||
|
chosen = _select_palette_for_mode([LIGHT_PALETTE, DARK_PALETTE], "dark")
|
||||||
|
self.assertEqual(chosen["Background"], "#0F172A")
|
||||||
|
|
||||||
|
def test_dark_mode_falls_back_to_top_hit_when_no_dark_ramp_exists(self):
|
||||||
|
chosen = _select_palette_for_mode([LIGHT_PALETTE], "dark")
|
||||||
|
self.assertEqual(chosen["Background"], "#F8FAFC")
|
||||||
|
|
||||||
|
def test_light_mode_keeps_the_existing_top_hit_behaviour(self):
|
||||||
|
chosen = _select_palette_for_mode([DARK_PALETTE, LIGHT_PALETTE], "light")
|
||||||
|
self.assertEqual(chosen["Background"], "#0F172A")
|
||||||
|
|
||||||
|
def test_empty_results(self):
|
||||||
|
self.assertEqual(_select_palette_for_mode([], "dark"), {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestAntiPatternGating(unittest.TestCase):
|
||||||
|
def test_dark_clause_dropped_others_kept(self):
|
||||||
|
result = _filter_anti_patterns_for_mode(
|
||||||
|
"Excessive animation + Dark mode by default", "dark")
|
||||||
|
self.assertEqual(result, "Excessive animation")
|
||||||
|
|
||||||
|
def test_light_mode_is_a_no_op(self):
|
||||||
|
original = "Excessive animation + Dark mode by default"
|
||||||
|
self.assertEqual(_filter_anti_patterns_for_mode(original, "light"), original)
|
||||||
|
|
||||||
|
def test_unrelated_anti_patterns_survive_dark_mode(self):
|
||||||
|
original = "Complex jargon + Tiny tap targets"
|
||||||
|
self.assertEqual(_filter_anti_patterns_for_mode(original, "dark"), original)
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
self.assertEqual(_filter_anti_patterns_for_mode("", "dark"), "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEndToEndCoherence(unittest.TestCase):
|
||||||
|
"""The exact reproduction from issue #428."""
|
||||||
|
|
||||||
|
QUERY = "SaaS invoicing fintech B2B professional dark mode"
|
||||||
|
|
||||||
|
def test_dark_query_gets_a_dark_background(self):
|
||||||
|
ds = DesignSystemGenerator().generate(self.QUERY)
|
||||||
|
background = ds["colors"]["background"]
|
||||||
|
self.assertTrue(
|
||||||
|
_palette_is_dark({"Background": background}),
|
||||||
|
"dark-mode query returned a light background: {}".format(background),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dark_query_foreground_is_lighter_than_background(self):
|
||||||
|
ds = DesignSystemGenerator().generate(self.QUERY)
|
||||||
|
background = _relative_luminance(ds["colors"]["background"])
|
||||||
|
foreground = _relative_luminance(ds["colors"]["foreground"])
|
||||||
|
self.assertIsNotNone(background)
|
||||||
|
self.assertIsNotNone(foreground)
|
||||||
|
self.assertGreater(foreground, background)
|
||||||
|
|
||||||
|
def test_dark_query_does_not_advise_against_dark_mode(self):
|
||||||
|
ds = DesignSystemGenerator().generate(self.QUERY)
|
||||||
|
self.assertNotIn("dark mode", ds["anti_patterns"].lower())
|
||||||
|
|
||||||
|
def test_light_query_keeps_a_light_background(self):
|
||||||
|
ds = DesignSystemGenerator().generate("healthcare clinic booking app")
|
||||||
|
self.assertFalse(_palette_is_dark({"Background": ds["colors"]["background"]}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@ -4,13 +4,14 @@
|
|||||||
"installType": "full",
|
"installType": "full",
|
||||||
"folderStructure": {
|
"folderStructure": {
|
||||||
"root": ".github",
|
"root": ".github",
|
||||||
"skillPath": "prompts/ui-ux-pro-max",
|
"skillPath": "prompts",
|
||||||
"filename": "PROMPT.md"
|
"filename": "ui-ux-pro-max.prompt.md",
|
||||||
|
"dataPath": "prompts/ui-ux-pro-max"
|
||||||
},
|
},
|
||||||
"scriptPath": "prompts/ui-ux-pro-max/scripts/search.py",
|
"scriptPath": "prompts/ui-ux-pro-max/scripts/search.py",
|
||||||
"frontmatter": {
|
"frontmatter": {
|
||||||
"name": "ui-ux-pro-max",
|
"description": "Comprehensive design guide for web, mobile, and desktop applications. Contains 67 styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 22 technology stacks.",
|
||||||
"description": "Comprehensive design guide for web, mobile, and desktop applications. Contains 67 styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 22 technology stacks."
|
"mode": "agent"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"quickReference": false
|
"quickReference": false
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user