Compare commits

...

3 Commits

Author SHA1 Message Date
vkayatas
b0ebb1797e
fix(copilot): generate .prompt.md file for VS Code Copilot slash commands (#328)
* fix(copilot): generate .prompt.md file for VS Code Copilot slash commands

VS Code Copilot requires reusable prompts to be files named
`<name>.prompt.md` directly in `.github/prompts/`. The previous
config generated `.github/prompts/ui-ux-pro-max/PROMPT.md` (a folder
with PROMPT.md inside), which is not recognized as a slash command.

Changes:
- copilot.json: set skillPath to 'prompts', filename to
  'ui-ux-pro-max.prompt.md', add dataPath for data/scripts location
- copilot.json: use 'mode: agent' frontmatter (VS Code format) instead
  of 'name' field
- template.ts: add dataPath support so data/scripts go to a separate
  directory from the prompt file when configured
- template.ts: rewrite hardcoded script paths to platform-specific
  scriptPath for platforms that differ from the default
- uninstall.ts: handle copilot-specific file layout during uninstall

After this fix, `uipro init --ai copilot` produces:
  .github/prompts/ui-ux-pro-max.prompt.md  (slash command file)
  .github/prompts/ui-ux-pro-max/data/      (search database)
  .github/prompts/ui-ux-pro-max/scripts/   (search engine)

* fix(copilot): apply prompt-file layout fix to canonical source template

Address review feedback on PR #328:
- Update src/ui-ux-pro-max/templates/platforms/copilot.json (source of
  truth) with the same .prompt.md layout fix that was previously applied
  only to the packaged CLI asset copy
- Sync cli/assets/templates/platforms/copilot.json byte-for-byte from
  the source template (also fixes stale '15 technology stacks' count;
  there are 16 stack CSVs)

Smoke check (uipro init --ai copilot --offline):
  .github/prompts/ui-ux-pro-max.prompt.md  (slash command, mode: agent)
  .github/prompts/ui-ux-pro-max/data/      (search database)
  .github/prompts/ui-ux-pro-max/scripts/   (search engine)
uninstall --ai copilot removes both the prompt file and data directory.

---------

Co-authored-by: vkayata <volkan.kayatas@mercedes-benz.com>
2026-07-31 23:02:31 +07:00
Lin Junrong
ec1f2a9027
fix(design-system): resolve palette and anti-patterns against the style's mode (#428) (#434)
generate() ranked style, palette and anti-patterns independently, so a
dark-primary style could come back with a light palette and a
"Dark mode by default" anti-pattern in the same output. The palette is
what users copy into CSS variables, so the output shipped a light theme
with dark-theme styling instructions attached.

Resolve the mode first -- from the query keywords and the style's own
Light/Dark Mode columns -- then pick a palette whose Background matches
it and drop mode-contradicting anti-pattern clauses.

Only the dark case filters palettes. Light keeps the existing top-hit
behaviour so queries that never mention a mode are untouched, and dark
falls back to the top hit when colors.csv has no dark ramp for the
product type.

Adds scripts/tests/test_design_system_mode.py (stdlib unittest, matching
test_core.py) and syncs cli/assets + .claude/skills via sync-assets.mjs.
2026-07-31 18:00:15 +07:00
Kc
4857a2c5ef
fix(cli): sync shadcn test asset (#433) 2026-07-28 12:00:07 +07:00
12 changed files with 865 additions and 22 deletions

View File

@ -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": {

View 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)

View File

@ -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": {

View 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)

View File

@ -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

View File

@ -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

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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": {

View 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)

View File

@ -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