Compare commits

...

4 Commits

Author SHA1 Message Date
Yong Tang
14ddef5c05
feat(gallery): add Style Detail Modal with phone UI controls preview (#155)
Click any StyleCard to open a full-screen modal showing the style applied
to 9 interactive phone UI controls (buttons, inputs, toggles, checkboxes,
radios, cards, badges, slider, nav bar) inside an iPhone mockup frame.

Co-authored-by: yongtang <jlinux@msn.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-01 08:29:30 +07:00
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
44 changed files with 4654 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);

3
gallery/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
.next/
out/

22
gallery/app/globals.css Normal file
View File

@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100;
}
}
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-700 rounded-full;
}
}

22
gallery/app/layout.tsx Normal file
View File

@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Providers } from "./providers";
import "./globals.css";
export const metadata: Metadata = {
title: "Style Gallery — UI/UX Pro Max",
description: "Browse and explore 67 UI styles with live previews, color palettes, and implementation details.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className="min-h-screen antialiased">
<Providers>{children}</Providers>
</body>
</html>
);
}

16
gallery/app/page.tsx Normal file
View File

@ -0,0 +1,16 @@
import fs from "fs";
import path from "path";
import { parseStylesCSV } from "@/lib/parseStyles";
import { GalleryGrid } from "@/components/GalleryGrid";
export default function Home() {
const csvPath = path.join(process.cwd(), "data", "styles.csv");
const csvContent = fs.readFileSync(csvPath, "utf-8");
const styles = parseStylesCSV(csvContent);
return (
<main>
<GalleryGrid styles={styles} />
</main>
);
}

11
gallery/app/providers.tsx Normal file
View File

@ -0,0 +1,11 @@
"use client";
import { ThemeProvider } from "next-themes";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
);
}

View File

@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
interface CodeSnippetProps {
code: string;
label?: string;
}
export function CodeSnippet({ code, label }: CodeSnippetProps) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: ignore
}
}
return (
<div className="relative group">
{label && (
<span className="text-[10px] font-medium text-gray-400 uppercase tracking-wider">
{label}
</span>
)}
<pre className="mt-1 p-3 text-xs bg-gray-50 dark:bg-gray-900 rounded-lg overflow-x-auto scrollbar-thin font-mono text-gray-700 dark:text-gray-300 leading-relaxed">
{code}
</pre>
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-gray-200/80 dark:bg-gray-700/80 text-gray-500 dark:text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-300 dark:hover:bg-gray-600"
title="Copy to clipboard"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
interface ColorPaletteProps {
colors: string[];
}
export function ColorPalette({ colors }: ColorPaletteProps) {
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
if (colors.length === 0) return null;
async function copyColor(color: string, idx: number) {
try {
await navigator.clipboard.writeText(color);
setCopiedIdx(idx);
setTimeout(() => setCopiedIdx(null), 2000);
} catch {
// Fallback: ignore
}
}
// Show at most 8 colors
const displayed = colors.slice(0, 8);
return (
<div className="flex items-center gap-1.5">
{displayed.map((color, i) => (
<div key={`${color}-${i}`} className="group relative">
<button
onClick={() => copyColor(color, i)}
className="w-6 h-6 rounded-full border border-gray-200 dark:border-gray-700 transition-transform hover:scale-125 focus:outline-none focus:ring-2 focus:ring-blue-400"
style={{ backgroundColor: color }}
title={color}
/>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 text-[10px] font-mono bg-gray-900 text-white rounded opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-10">
{copiedIdx === i ? "Copied!" : color}
</div>
</div>
))}
{colors.length > 8 && (
<span className="text-xs text-gray-400">+{colors.length - 8}</span>
)}
</div>
);
}

View File

@ -0,0 +1,35 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export function DarkModeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <div className="w-9 h-9" />;
}
const isDark = theme === "dark";
return (
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className="p-2 rounded-lg bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</button>
);
}

View File

@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
interface ExpandableSectionProps {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}
export function ExpandableSection({
title,
children,
defaultOpen = false,
}: ExpandableSectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border-t border-gray-100 dark:border-gray-800">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between py-2 px-1 text-left text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
>
<span>{title}</span>
<svg
className={`w-4 h-4 transition-transform ${open ? "rotate-90" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{open && <div className="pb-3 px-1">{children}</div>}
</div>
);
}

View File

@ -0,0 +1,111 @@
"use client";
import { Filters } from "@/lib/filterStyles";
interface FilterBarProps {
filters: Filters;
onChange: (filters: Filters) => void;
types: string[];
complexities: string[];
eras: string[];
resultCount: number;
totalCount: number;
}
function FilterGroup({
label,
options,
value,
onChange,
}: {
label: string;
options: string[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">
{label}:
</span>
<button
onClick={() => onChange("")}
className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
value === ""
? "bg-blue-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
All
</button>
{options.map((opt) => (
<button
key={opt}
onClick={() => onChange(value === opt ? "" : opt)}
className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
value === opt
? "bg-blue-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
{opt}
</button>
))}
</div>
);
}
export function FilterBar({
filters,
onChange,
types,
complexities,
eras,
resultCount,
totalCount,
}: FilterBarProps) {
const hasFilters =
filters.search || filters.type || filters.complexity || filters.era;
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-4">
<FilterGroup
label="Type"
options={types}
value={filters.type}
onChange={(type) => onChange({ ...filters, type })}
/>
<FilterGroup
label="Complexity"
options={complexities}
value={filters.complexity}
onChange={(complexity) => onChange({ ...filters, complexity })}
/>
</div>
<div className="flex flex-wrap gap-4">
<FilterGroup
label="Era"
options={eras}
value={filters.era}
onChange={(era) => onChange({ ...filters, era })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500 dark:text-gray-400">
Showing <span className="font-semibold text-gray-700 dark:text-gray-200">{resultCount}</span> of {totalCount} styles
</p>
{hasFilters && (
<button
onClick={() =>
onChange({ search: "", type: "", complexity: "", era: "" })
}
className="text-xs text-blue-500 hover:text-blue-600 transition-colors"
>
Clear all filters
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import { useMemo, useState } from "react";
import { StyleData } from "@/lib/types";
import {
Filters,
DEFAULT_FILTERS,
filterStyles,
getUniqueValues,
} from "@/lib/filterStyles";
import { StyleCard } from "./StyleCard";
import { StyleDetailModal } from "./StyleDetailModal";
import { SearchInput } from "./SearchInput";
import { FilterBar } from "./FilterBar";
import { DarkModeToggle } from "./DarkModeToggle";
interface GalleryGridProps {
styles: StyleData[];
}
export function GalleryGrid({ styles }: GalleryGridProps) {
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
const [selectedStyle, setSelectedStyle] = useState<StyleData | null>(null);
const types = useMemo(() => getUniqueValues(styles, "type"), [styles]);
const complexities = useMemo(
() => getUniqueValues(styles, "complexity"),
[styles]
);
const eras = useMemo(() => getUniqueValues(styles, "eraOrigin"), [styles]);
const filtered = useMemo(
() => filterStyles(styles, filters),
[styles, filters]
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Style Gallery
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Browse {styles.length} UI styles from Antigravity Kit
</p>
</div>
<DarkModeToggle />
</div>
{/* Search */}
<div className="mb-4">
<SearchInput
value={filters.search}
onChange={(search) => setFilters((f) => ({ ...f, search }))}
/>
</div>
{/* Filters */}
<div className="mb-6">
<FilterBar
filters={filters}
onChange={setFilters}
types={types}
complexities={complexities}
eras={eras}
resultCount={filtered.length}
totalCount={styles.length}
/>
</div>
{/* Grid */}
{filtered.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filtered.map((style) => (
<StyleCard key={style.no} style={style} onSelect={setSelectedStyle} />
))}
</div>
) : (
<div className="text-center py-16">
<p className="text-gray-500 dark:text-gray-400">
No styles match your filters.
</p>
<button
onClick={() => setFilters(DEFAULT_FILTERS)}
className="mt-2 text-sm text-blue-500 hover:text-blue-600"
>
Clear all filters
</button>
</div>
)}
{/* Style Detail Modal */}
{selectedStyle && (
<StyleDetailModal
style={selectedStyle}
onClose={() => setSelectedStyle(null)}
/>
)}
</div>
);
}

View File

@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
interface InteractiveChecklistProps {
checklist: string;
}
function parseItems(raw: string): string[] {
return raw
.split(/,\s*(?=☐)/)
.map((s) => s.replace(/^☐\s*/, "").trim())
.filter(Boolean);
}
export function InteractiveChecklist({ checklist }: InteractiveChecklistProps) {
const items = parseItems(checklist);
const [checked, setChecked] = useState<Set<number>>(new Set());
if (items.length === 0) return null;
function toggle(idx: number) {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
}
return (
<div>
<div className="text-[10px] text-gray-400 mb-2">
{checked.size} of {items.length} completed
</div>
<div className="space-y-1.5">
{items.map((item, i) => (
<label
key={i}
className="flex items-start gap-2 cursor-pointer group"
>
<input
type="checkbox"
checked={checked.has(i)}
onChange={() => toggle(i)}
className="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-blue-500 focus:ring-blue-500"
/>
<span
className={`text-xs leading-relaxed transition-colors ${
checked.has(i)
? "line-through text-gray-400 dark:text-gray-600"
: "text-gray-700 dark:text-gray-300"
}`}
>
{item}
</span>
</label>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,57 @@
import { StyleData } from "@/lib/types";
interface MetadataBadgesProps {
style: StyleData;
}
function Badge({
label,
variant,
}: {
label: string;
variant: "green" | "yellow" | "red" | "blue" | "gray";
}) {
const colors = {
green: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
yellow: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
red: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
blue: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
gray: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400",
};
return (
<span className={`inline-flex items-center px-2 py-0.5 text-[10px] font-medium rounded-full ${colors[variant]}`}>
{label}
</span>
);
}
function getComplexityVariant(c: string): "green" | "yellow" | "red" {
if (c === "Low") return "green";
if (c === "Medium") return "yellow";
return "red";
}
function getPerfVariant(p: string): "green" | "yellow" | "red" {
if (p.includes("Excellent")) return "green";
if (p.includes("Good")) return "yellow";
return "red";
}
function getA11yVariant(a: string): "green" | "yellow" | "red" {
if (a.includes("AAA")) return "green";
if (a.includes("AA") || a.includes("Good")) return "yellow";
return "red";
}
export function MetadataBadges({ style }: MetadataBadgesProps) {
return (
<div className="flex flex-wrap gap-1.5">
<Badge label={style.complexity} variant={getComplexityVariant(style.complexity)} />
<Badge label={style.performance.replace(/[⚡❌⚠]/g, "").trim()} variant={getPerfVariant(style.performance)} />
<Badge label={style.accessibility.replace(/[✓⚠✗]/g, "").trim()} variant={getA11yVariant(style.accessibility)} />
{style.lightMode.includes("Full") && <Badge label="Light" variant="blue" />}
{style.darkMode.includes("Full") && <Badge label="Dark" variant="gray" />}
</div>
);
}

View File

@ -0,0 +1,85 @@
"use client";
import React from "react";
interface PhoneMockupProps {
children: React.ReactNode;
}
export function PhoneMockup({ children }: PhoneMockupProps) {
return (
<div className="flex items-center justify-center py-4">
{/* iPhone outer frame */}
<div
className="relative bg-gray-900 rounded-[44px] p-3 shadow-2xl"
style={{ width: "300px" }}
>
{/* Notch / Dynamic Island */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 z-20">
<div className="bg-gray-900 rounded-b-2xl px-6 pt-0 pb-1.5">
<div className="w-20 h-5 bg-black rounded-full" />
</div>
</div>
{/* Screen bezel */}
<div className="rounded-[32px] overflow-hidden bg-white dark:bg-gray-950 relative">
{/* Status bar */}
<div className="flex items-center justify-between px-6 pt-3 pb-1 text-[10px] font-semibold relative z-10">
<span className="text-gray-900 dark:text-gray-100">9:41</span>
<div className="flex items-center gap-1">
{/* Signal */}
<svg
width="14"
height="10"
viewBox="0 0 14 10"
className="text-gray-900 dark:text-gray-100"
>
<rect x="0" y="7" width="2.5" height="3" rx="0.5" fill="currentColor" />
<rect x="3.5" y="5" width="2.5" height="5" rx="0.5" fill="currentColor" />
<rect x="7" y="2.5" width="2.5" height="7.5" rx="0.5" fill="currentColor" />
<rect x="10.5" y="0" width="2.5" height="10" rx="0.5" fill="currentColor" />
</svg>
{/* WiFi */}
<svg
width="12"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-gray-900 dark:text-gray-100"
>
<path d="M1.42 9a16 16 0 0121.16 0" />
<path d="M5 12.55a11 11 0 0114.08 0" />
<path d="M8.53 16.11a6 6 0 016.95 0" />
<circle cx="12" cy="20" r="1" fill="currentColor" />
</svg>
{/* Battery */}
<div className="flex items-center">
<div className="w-5 h-2.5 border border-current rounded-sm relative">
<div className="absolute inset-0.5 bg-current rounded-[1px]" />
</div>
<div className="w-0.5 h-1 bg-current rounded-r-sm" />
</div>
</div>
</div>
{/* Content area */}
<div
className="overflow-y-auto"
style={{ height: "540px" }}
>
{children}
</div>
{/* Home indicator */}
<div className="flex justify-center pb-2 pt-1">
<div className="w-28 h-1 bg-gray-400 dark:bg-gray-600 rounded-full" />
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,62 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
}
export function SearchInput({ value, onChange }: SearchInputProps) {
const [local, setLocal] = useState(value);
const timerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
setLocal(value);
}, [value]);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const v = e.target.value;
setLocal(v);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onChange(v), 300);
}
return (
<div className="relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
value={local}
onChange={handleChange}
placeholder="Search styles..."
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder:text-gray-400"
/>
{local && (
<button
onClick={() => {
setLocal("");
onChange("");
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
);
}

View File

@ -0,0 +1,126 @@
"use client";
import { StyleData } from "@/lib/types";
import { StylePreview } from "./StylePreview";
import { ColorPalette } from "./ColorPalette";
import { MetadataBadges } from "./MetadataBadges";
import { ExpandableSection } from "./ExpandableSection";
import { CodeSnippet } from "./CodeSnippet";
import { InteractiveChecklist } from "./InteractiveChecklist";
interface StyleCardProps {
style: StyleData;
onSelect?: (style: StyleData) => void;
}
export function StyleCard({ style, onSelect }: StyleCardProps) {
return (
<div
className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden hover:shadow-lg dark:hover:shadow-gray-900/50 transition-shadow cursor-pointer"
onClick={() => onSelect?.(style)}
>
{/* Header */}
<div className="px-4 pt-4 pb-2">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 leading-tight">
{style.styleCategory}
</h3>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[10px] px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
{style.type}
</span>
<span className="text-[10px] text-gray-400">{style.eraOrigin}</span>
</div>
</div>
</div>
{/* Preview */}
<div className="px-4">
<StylePreview style={style} />
</div>
{/* Color Palette */}
<div className="px-4 py-3">
<ColorPalette colors={style.extractedColors} />
</div>
{/* Metadata Badges */}
<div className="px-4 pb-3">
<MetadataBadges style={style} />
</div>
{/* Expandable Sections */}
<div className="px-4 pb-2">
<ExpandableSection title="CSS Code">
<CodeSnippet code={style.cssTechnicalKeywords} label="CSS/Technical" />
{style.designSystemVariables && (
<div className="mt-2">
<CodeSnippet
code={style.designSystemVariables}
label="Design Variables"
/>
</div>
)}
</ExpandableSection>
<ExpandableSection title="AI Prompt">
<CodeSnippet code={style.aiPromptKeywords} />
</ExpandableSection>
<ExpandableSection title="Implementation Checklist">
<InteractiveChecklist checklist={style.implementationChecklist} />
</ExpandableSection>
<ExpandableSection title="Details">
<div className="space-y-2 text-xs">
{style.bestFor && (
<div>
<span className="font-medium text-gray-500 dark:text-gray-400">
Best for:{" "}
</span>
<span className="text-gray-700 dark:text-gray-300">
{style.bestFor}
</span>
</div>
)}
{style.doNotUseFor && (
<div>
<span className="font-medium text-gray-500 dark:text-gray-400">
Avoid for:{" "}
</span>
<span className="text-gray-700 dark:text-gray-300">
{style.doNotUseFor}
</span>
</div>
)}
{style.frameworkCompatibility && (
<div>
<span className="font-medium text-gray-500 dark:text-gray-400">
Frameworks:{" "}
</span>
<span className="text-gray-700 dark:text-gray-300">
{style.frameworkCompatibility}
</span>
</div>
)}
{style.keywords && (
<div className="flex flex-wrap gap-1 mt-1">
{style.keywords
.split(",")
.slice(0, 6)
.map((kw, i) => (
<span
key={i}
className="px-1.5 py-0.5 text-[10px] bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded"
>
{kw.trim()}
</span>
))}
</div>
)}
</div>
</ExpandableSection>
</div>
</div>
);
}

View File

@ -0,0 +1,220 @@
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { StyleData } from "@/lib/types";
import {
generatePreviewStyle,
generateBackgroundGradient,
} from "@/lib/cssGenerator";
import { PhoneMockup } from "./PhoneMockup";
import { UIControlsShowcase } from "./UIControlsShowcase";
import { ColorPalette } from "./ColorPalette";
import { MetadataBadges } from "./MetadataBadges";
import { ExpandableSection } from "./ExpandableSection";
import { CodeSnippet } from "./CodeSnippet";
import { InteractiveChecklist } from "./InteractiveChecklist";
interface StyleDetailModalProps {
style: StyleData;
onClose: () => void;
}
export function StyleDetailModal({ style, onClose }: StyleDetailModalProps) {
const { theme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// ESC to close
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
// Lock body scroll
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, []);
const isDark = mounted ? theme === "dark" : false;
const cardStyle = generatePreviewStyle(style, isDark);
const bgGradient = generateBackgroundGradient(style.extractedColors, isDark);
const accentColor =
style.extractedColors[0] || (isDark ? "#6366f1" : "#3b82f6");
const secondaryColor =
style.extractedColors[1] || style.extractedColors[0] || (isDark ? "#8b5cf6" : "#6366f1");
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Modal container */}
<div
className="relative z-10 bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-[95vw] max-w-5xl max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800 shrink-0">
<button
onClick={onClose}
className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Close
</button>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{style.styleCategory}
</h2>
<div className="w-16" /> {/* Spacer for centering */}
</div>
{/* Body — two-column on desktop, single-column on mobile */}
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col lg:flex-row">
{/* Left: Phone mockup */}
<div
className="lg:w-1/2 flex items-start justify-center p-6 lg:border-r border-gray-200 dark:border-gray-800"
style={{ background: bgGradient }}
>
<PhoneMockup>
<UIControlsShowcase
accentColor={accentColor}
secondaryColor={secondaryColor}
borderRadius={cardStyle.borderRadius?.toString() || "8px"}
boxShadow={
cardStyle.boxShadow?.toString() ||
"0 4px 12px rgba(0,0,0,0.1)"
}
backdropFilter={cardStyle.backdropFilter?.toString()}
fontFamily={cardStyle.fontFamily?.toString()}
fontWeight={cardStyle.fontWeight?.toString()}
border={cardStyle.border?.toString()}
textShadow={cardStyle.textShadow?.toString()}
letterSpacing={cardStyle.letterSpacing?.toString()}
isDark={isDark}
/>
</PhoneMockup>
</div>
{/* Right: Details */}
<div className="lg:w-1/2 p-6">
{/* Color Palette */}
<div className="mb-4">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
Color Palette
</h4>
<ColorPalette colors={style.extractedColors} />
</div>
{/* Metadata */}
<div className="mb-4">
<MetadataBadges style={style} />
</div>
{/* Expandable sections */}
<div>
<ExpandableSection title="CSS Code" defaultOpen>
<CodeSnippet
code={style.cssTechnicalKeywords}
label="CSS/Technical"
/>
{style.designSystemVariables && (
<div className="mt-2">
<CodeSnippet
code={style.designSystemVariables}
label="Design Variables"
/>
</div>
)}
</ExpandableSection>
<ExpandableSection title="AI Prompt">
<CodeSnippet code={style.aiPromptKeywords} />
</ExpandableSection>
<ExpandableSection title="Implementation Checklist">
<InteractiveChecklist
checklist={style.implementationChecklist}
/>
</ExpandableSection>
<ExpandableSection title="Details">
<div className="space-y-2 text-xs">
{style.bestFor && (
<div>
<span className="font-medium text-gray-500 dark:text-gray-400">
Best for:{" "}
</span>
<span className="text-gray-700 dark:text-gray-300">
{style.bestFor}
</span>
</div>
)}
{style.doNotUseFor && (
<div>
<span className="font-medium text-gray-500 dark:text-gray-400">
Avoid for:{" "}
</span>
<span className="text-gray-700 dark:text-gray-300">
{style.doNotUseFor}
</span>
</div>
)}
{style.frameworkCompatibility && (
<div>
<span className="font-medium text-gray-500 dark:text-gray-400">
Frameworks:{" "}
</span>
<span className="text-gray-700 dark:text-gray-300">
{style.frameworkCompatibility}
</span>
</div>
)}
{style.keywords && (
<div className="flex flex-wrap gap-1 mt-1">
{style.keywords
.split(",")
.slice(0, 6)
.map((kw, i) => (
<span
key={i}
className="px-1.5 py-0.5 text-[10px] bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded"
>
{kw.trim()}
</span>
))}
</div>
)}
</div>
</ExpandableSection>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import { StyleData } from "@/lib/types";
import {
generatePreviewStyle,
generateBackgroundGradient,
} from "@/lib/cssGenerator";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
interface StylePreviewProps {
style: StyleData;
}
export function StylePreview({ style }: StylePreviewProps) {
const { theme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const isDark = mounted ? theme === "dark" : false;
const cardStyle = generatePreviewStyle(style, isDark);
const bgGradient = generateBackgroundGradient(style.extractedColors, isDark);
const accentColor = style.extractedColors[0] || (isDark ? "#6366f1" : "#3b82f6");
return (
<div
className="relative h-40 rounded-lg overflow-hidden"
style={{ background: bgGradient }}
>
{/* Demo card */}
<div className="absolute inset-0 flex items-center justify-center p-4">
<div
className="w-full max-w-[200px] p-4 bg-white/80 dark:bg-gray-900/80"
style={{
borderRadius: cardStyle.borderRadius || "8px",
boxShadow:
cardStyle.boxShadow || "0 4px 12px rgba(0,0,0,0.1)",
backdropFilter: cardStyle.backdropFilter,
WebkitBackdropFilter: cardStyle.WebkitBackdropFilter,
border: cardStyle.border || "1px solid rgba(128,128,128,0.1)",
fontFamily: cardStyle.fontFamily,
fontWeight: cardStyle.fontWeight,
filter: cardStyle.filter,
textShadow: cardStyle.textShadow,
letterSpacing: cardStyle.letterSpacing,
transition: cardStyle.transition || "all 0.2s ease",
}}
>
<div
className="h-2 rounded-full mb-2 w-3/4"
style={{ background: accentColor }}
/>
<div className="h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 mb-1.5 w-full" />
<div className="h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 mb-1.5 w-5/6" />
<div className="h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 w-2/3" />
<div
className="mt-3 h-6 rounded flex items-center justify-center"
style={{
background: accentColor,
borderRadius: cardStyle.borderRadius
? `calc(${cardStyle.borderRadius} / 2)`
: "4px",
}}
>
<span className="text-[10px] font-medium text-white">
Button
</span>
</div>
</div>
</div>
{/* Style name overlay */}
<div className="absolute bottom-0 left-0 right-0 px-3 py-1.5 bg-gradient-to-t from-black/40 to-transparent">
<span className="text-[10px] font-medium text-white/80">
Preview
</span>
</div>
</div>
);
}

View File

@ -0,0 +1,610 @@
"use client";
import React, { useState } from "react";
interface UIControlsShowcaseProps {
accentColor: string;
secondaryColor: string;
borderRadius: string;
boxShadow: string;
backdropFilter?: string;
fontFamily?: string;
fontWeight?: string;
border?: string;
textShadow?: string;
letterSpacing?: string;
isDark: boolean;
}
export function UIControlsShowcase({
accentColor,
secondaryColor,
borderRadius,
boxShadow,
backdropFilter,
fontFamily,
fontWeight,
border,
textShadow,
letterSpacing,
isDark,
}: UIControlsShowcaseProps) {
const [toggle1, setToggle1] = useState(true);
const [toggle2, setToggle2] = useState(false);
const [check1, setCheck1] = useState(true);
const [check2, setCheck2] = useState(false);
const [radio, setRadio] = useState<"a" | "b">("a");
const [slider, setSlider] = useState(65);
const bg = isDark ? "#1a1a2e" : "#ffffff";
const textPrimary = isDark ? "#f1f5f9" : "#1e293b";
const textSecondary = isDark ? "#94a3b8" : "#64748b";
const surfaceBg = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)";
const borderColor = isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)";
const halfRadius = `calc(${borderRadius} / 2)`;
const baseFont: React.CSSProperties = {
fontFamily,
fontWeight,
textShadow,
letterSpacing,
};
return (
<div
style={{
background: bg,
color: textPrimary,
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "16px",
fontSize: "13px",
lineHeight: 1.4,
minHeight: "100%",
...baseFont,
}}
>
{/* 1. Buttons */}
<Section label="Buttons" textSecondary={textSecondary}>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<button
style={{
background: accentColor,
color: "#fff",
border: "none",
borderRadius: halfRadius,
padding: "8px 16px",
fontSize: "13px",
fontWeight: 600,
boxShadow,
cursor: "pointer",
...baseFont,
}}
>
Primary
</button>
<button
style={{
background: secondaryColor,
color: "#fff",
border: "none",
borderRadius: halfRadius,
padding: "8px 16px",
fontSize: "13px",
fontWeight: 600,
cursor: "pointer",
...baseFont,
}}
>
Secondary
</button>
<button
style={{
background: "transparent",
color: accentColor,
border: `1.5px solid ${accentColor}`,
borderRadius: halfRadius,
padding: "8px 16px",
fontSize: "13px",
fontWeight: 600,
cursor: "pointer",
...baseFont,
}}
>
Outline
</button>
</div>
</Section>
{/* 2. Text Input */}
<Section label="Text Input" textSecondary={textSecondary}>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<input
type="text"
placeholder="Placeholder text..."
style={{
background: surfaceBg,
color: textPrimary,
border: border || `1px solid ${borderColor}`,
borderRadius: halfRadius,
padding: "8px 12px",
fontSize: "13px",
outline: "none",
width: "100%",
boxSizing: "border-box",
...baseFont,
}}
/>
<input
type="text"
value="Filled input"
readOnly
style={{
background: surfaceBg,
color: textPrimary,
border: border || `1px solid ${accentColor}44`,
borderRadius: halfRadius,
padding: "8px 12px",
fontSize: "13px",
outline: "none",
width: "100%",
boxSizing: "border-box",
...baseFont,
}}
/>
</div>
</Section>
{/* 3. Toggle Switch */}
<Section label="Toggle" textSecondary={textSecondary}>
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
<ToggleSwitch
on={toggle1}
onToggle={() => setToggle1(!toggle1)}
accentColor={accentColor}
borderRadius={borderRadius}
/>
<ToggleSwitch
on={toggle2}
onToggle={() => setToggle2(!toggle2)}
accentColor={accentColor}
borderRadius={borderRadius}
/>
</div>
</Section>
{/* 4. Checkbox */}
<Section label="Checkbox" textSecondary={textSecondary}>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<CheckboxItem
checked={check1}
onToggle={() => setCheck1(!check1)}
label="Checked option"
accentColor={accentColor}
halfRadius={halfRadius}
textPrimary={textPrimary}
baseFont={baseFont}
/>
<CheckboxItem
checked={check2}
onToggle={() => setCheck2(!check2)}
label="Unchecked option"
accentColor={accentColor}
halfRadius={halfRadius}
textPrimary={textPrimary}
baseFont={baseFont}
/>
</div>
</Section>
{/* 5. Radio Button */}
<Section label="Radio" textSecondary={textSecondary}>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<RadioItem
selected={radio === "a"}
onSelect={() => setRadio("a")}
label="Option A"
accentColor={accentColor}
textPrimary={textPrimary}
baseFont={baseFont}
/>
<RadioItem
selected={radio === "b"}
onSelect={() => setRadio("b")}
label="Option B"
accentColor={accentColor}
textPrimary={textPrimary}
baseFont={baseFont}
/>
</div>
</Section>
{/* 6. Card */}
<Section label="Card" textSecondary={textSecondary}>
<div
style={{
background: surfaceBg,
borderRadius,
boxShadow,
border: border || `1px solid ${borderColor}`,
overflow: "hidden",
backdropFilter,
WebkitBackdropFilter: backdropFilter,
}}
>
<div
style={{
height: "64px",
background: `linear-gradient(135deg, ${accentColor}44, ${secondaryColor}44)`,
}}
/>
<div style={{ padding: "10px 12px" }}>
<div
style={{
fontSize: "13px",
fontWeight: 600,
marginBottom: "4px",
...baseFont,
}}
>
Card Title
</div>
<div
style={{
fontSize: "11px",
color: textSecondary,
...baseFont,
}}
>
A short description of this card with some preview text.
</div>
</div>
</div>
</Section>
{/* 7. Badge / Tag */}
<Section label="Badges" textSecondary={textSecondary}>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
{["Design", "UI/UX", "Style", "Modern"].map((tag) => (
<span
key={tag}
style={{
background: `${accentColor}22`,
color: accentColor,
padding: "3px 10px",
borderRadius: "999px",
fontSize: "11px",
fontWeight: 500,
...baseFont,
}}
>
{tag}
</span>
))}
</div>
</Section>
{/* 8. Slider */}
<Section label="Slider" textSecondary={textSecondary}>
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: "6px",
fontSize: "11px",
color: textSecondary,
}}
>
<span>0</span>
<span style={{ color: accentColor, fontWeight: 600 }}>
{slider}
</span>
<span>100</span>
</div>
<input
type="range"
min={0}
max={100}
value={slider}
onChange={(e) => setSlider(Number(e.target.value))}
style={{
width: "100%",
accentColor,
cursor: "pointer",
}}
/>
</div>
</Section>
{/* 9. Navigation Bar */}
<Section label="Nav Bar" textSecondary={textSecondary}>
<div
style={{
display: "flex",
justifyContent: "space-around",
alignItems: "center",
background: surfaceBg,
borderRadius: halfRadius,
padding: "8px 0",
border: border || `1px solid ${borderColor}`,
}}
>
<NavIcon
label="Home"
active
accentColor={accentColor}
textSecondary={textSecondary}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"
/>
<NavIcon
label="Search"
accentColor={accentColor}
textSecondary={textSecondary}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
<NavIcon
label="Heart"
accentColor={accentColor}
textSecondary={textSecondary}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
<NavIcon
label="Profile"
accentColor={accentColor}
textSecondary={textSecondary}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</div>
</Section>
</div>
);
}
/* --- Sub-components --- */
function Section({
label,
textSecondary,
children,
}: {
label: string;
textSecondary: string;
children: React.ReactNode;
}) {
return (
<div>
<div
style={{
fontSize: "10px",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: textSecondary,
marginBottom: "6px",
}}
>
{label}
</div>
{children}
</div>
);
}
function ToggleSwitch({
on,
onToggle,
accentColor,
borderRadius,
}: {
on: boolean;
onToggle: () => void;
accentColor: string;
borderRadius: string;
}) {
const trackRadius =
borderRadius && parseInt(borderRadius) > 20 ? "999px" : "12px";
return (
<button
onClick={onToggle}
style={{
width: "44px",
height: "24px",
borderRadius: trackRadius,
background: on ? accentColor : "rgba(128,128,128,0.3)",
border: "none",
position: "relative",
cursor: "pointer",
transition: "background 0.2s",
flexShrink: 0,
}}
>
<div
style={{
width: "18px",
height: "18px",
borderRadius: trackRadius,
background: "#fff",
position: "absolute",
top: "3px",
left: on ? "23px" : "3px",
transition: "left 0.2s",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}}
/>
</button>
);
}
function CheckboxItem({
checked,
onToggle,
label,
accentColor,
halfRadius,
textPrimary,
baseFont,
}: {
checked: boolean;
onToggle: () => void;
label: string;
accentColor: string;
halfRadius: string;
textPrimary: string;
baseFont: React.CSSProperties;
}) {
return (
<label
style={{
display: "flex",
alignItems: "center",
gap: "8px",
cursor: "pointer",
fontSize: "13px",
color: textPrimary,
...baseFont,
}}
>
<div
onClick={(e) => {
e.preventDefault();
onToggle();
}}
style={{
width: "18px",
height: "18px",
borderRadius: `min(${halfRadius}, 4px)`,
border: checked
? `2px solid ${accentColor}`
: "2px solid rgba(128,128,128,0.4)",
background: checked ? accentColor : "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "all 0.15s",
cursor: "pointer",
}}
>
{checked && (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="#fff"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 13l4 4L19 7" />
</svg>
)}
</div>
{label}
</label>
);
}
function RadioItem({
selected,
onSelect,
label,
accentColor,
textPrimary,
baseFont,
}: {
selected: boolean;
onSelect: () => void;
label: string;
accentColor: string;
textPrimary: string;
baseFont: React.CSSProperties;
}) {
return (
<label
style={{
display: "flex",
alignItems: "center",
gap: "8px",
cursor: "pointer",
fontSize: "13px",
color: textPrimary,
...baseFont,
}}
onClick={(e) => {
e.preventDefault();
onSelect();
}}
>
<div
style={{
width: "18px",
height: "18px",
borderRadius: "50%",
border: selected
? `2px solid ${accentColor}`
: "2px solid rgba(128,128,128,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "all 0.15s",
}}
>
{selected && (
<div
style={{
width: "10px",
height: "10px",
borderRadius: "50%",
background: accentColor,
}}
/>
)}
</div>
{label}
</label>
);
}
function NavIcon({
label,
active,
accentColor,
textSecondary,
d,
}: {
label: string;
active?: boolean;
accentColor: string;
textSecondary: string;
d: string;
}) {
const color = active ? accentColor : textSecondary;
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "2px",
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={d} />
</svg>
<span style={{ fontSize: "9px", color }}>{label}</span>
</div>
);
}

1
gallery/data/styles.csv Symbolic link
View File

@ -0,0 +1 @@
../../src/ui-ux-pro-max/data/styles.csv
1 ../../src/ui-ux-pro-max/data/styles.csv

View File

@ -0,0 +1,34 @@
const HEX_RE = /#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})\b/g;
const RGBA_RE = /rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*[\d.]+\s*)?\)/g;
function normalizeHex(hex: string): string {
hex = hex.toUpperCase();
if (hex.length === 4) {
return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
}
return hex;
}
export function extractColors(text: string): string[] {
const colors: string[] = [];
const seen = new Set<string>();
const hexMatches = text.match(HEX_RE) || [];
for (const h of hexMatches) {
const normalized = normalizeHex(h);
if (!seen.has(normalized)) {
seen.add(normalized);
colors.push(normalized);
}
}
const rgbaMatches = text.match(RGBA_RE) || [];
for (const r of rgbaMatches) {
if (!seen.has(r)) {
seen.add(r);
colors.push(r);
}
}
return colors;
}

133
gallery/lib/cssGenerator.ts Normal file
View File

@ -0,0 +1,133 @@
import React from "react";
const CSS_PROPERTY_MAP: Record<string, string> = {
"border-radius": "borderRadius",
"box-shadow": "boxShadow",
"backdrop-filter": "backdropFilter",
"-webkit-backdrop-filter": "WebkitBackdropFilter",
background: "background",
"background-color": "backgroundColor",
color: "color",
"font-family": "fontFamily",
"font-weight": "fontWeight",
"font-size": "fontSize",
border: "border",
transition: "transition",
transform: "transform",
filter: "filter",
opacity: "opacity",
"mix-blend-mode": "mixBlendMode",
"text-shadow": "textShadow",
"letter-spacing": "letterSpacing",
gap: "gap",
padding: "padding",
"max-width": "maxWidth",
display: "display",
"text-transform": "textTransform",
};
export function parseCssKeywords(
cssTechnical: string
): Record<string, string> {
const result: Record<string, string> = {};
if (!cssTechnical) return result;
// Match patterns like "property: value"
const re = /([\w-]+)\s*:\s*([^,;]+?)(?=\s*[,;]|\s+[\w-]+\s*:|$)/g;
let match;
while ((match = re.exec(cssTechnical)) !== null) {
const prop = match[1].trim().toLowerCase();
const val = match[2].trim();
if (CSS_PROPERTY_MAP[prop]) {
result[CSS_PROPERTY_MAP[prop]] = val;
}
}
return result;
}
export function generatePreviewStyle(
style: { extractedColors: string[]; cssProperties: Record<string, string> },
isDark: boolean
): React.CSSProperties {
const colors = style.extractedColors;
const props = style.cssProperties;
const baseStyle: React.CSSProperties = {};
// Apply border-radius
if (props.borderRadius) {
baseStyle.borderRadius = props.borderRadius;
}
// Apply box-shadow
if (props.boxShadow) {
baseStyle.boxShadow = props.boxShadow;
}
// Apply backdrop-filter
if (props.backdropFilter) {
baseStyle.backdropFilter = props.backdropFilter;
baseStyle.WebkitBackdropFilter = props.backdropFilter;
}
// Apply font-family
if (props.fontFamily) {
baseStyle.fontFamily = props.fontFamily;
}
// Apply font-weight
if (props.fontWeight) {
baseStyle.fontWeight = props.fontWeight;
}
// Apply border
if (props.border) {
baseStyle.border = props.border;
}
// Apply transition
if (props.transition) {
baseStyle.transition = props.transition;
}
// Apply filter
if (props.filter) {
baseStyle.filter = props.filter;
}
// Apply text-shadow
if (props.textShadow) {
baseStyle.textShadow = props.textShadow;
}
// Apply letter-spacing
if (props.letterSpacing) {
baseStyle.letterSpacing = props.letterSpacing;
}
return baseStyle;
}
export function generateBackgroundGradient(
colors: string[],
isDark: boolean
): string {
if (colors.length === 0) {
return isDark
? "linear-gradient(135deg, #1a1a2e, #16213e)"
: "linear-gradient(135deg, #f5f7fa, #c3cfe2)";
}
if (colors.length === 1) {
const c = colors[0];
return isDark
? `linear-gradient(135deg, ${c}33, ${c}11)`
: `linear-gradient(135deg, ${c}22, ${c}11)`;
}
const c1 = colors[0];
const c2 = colors[1];
return isDark
? `linear-gradient(135deg, ${c1}44, ${c2}22)`
: `linear-gradient(135deg, ${c1}22, ${c2}11)`;
}

View File

@ -0,0 +1,55 @@
import { StyleData } from "./types";
export interface Filters {
search: string;
type: string;
complexity: string;
era: string;
}
export const DEFAULT_FILTERS: Filters = {
search: "",
type: "",
complexity: "",
era: "",
};
export function getUniqueValues(
styles: StyleData[],
key: keyof StyleData
): string[] {
const set = new Set<string>();
for (const s of styles) {
const val = String(s[key]).trim();
if (val) set.add(val);
}
return Array.from(set).sort();
}
export function filterStyles(
styles: StyleData[],
filters: Filters
): StyleData[] {
return styles.filter((s) => {
if (filters.type && s.type !== filters.type) return false;
if (filters.complexity && s.complexity !== filters.complexity) return false;
if (filters.era && s.eraOrigin !== filters.era) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
const searchable = [
s.styleCategory,
s.keywords,
s.bestFor,
s.eraOrigin,
s.type,
s.complexity,
]
.join(" ")
.toLowerCase();
if (!searchable.includes(q)) return false;
}
return true;
});
}

View File

@ -0,0 +1,77 @@
import { StyleData } from "./types";
import { extractColors } from "./colorExtractor";
import { parseCssKeywords } from "./cssGenerator";
function parseCSVLine(line: string): string[] {
const fields: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (inQuotes) {
if (char === '"') {
if (i + 1 < line.length && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = false;
}
} else {
current += char;
}
} else {
if (char === '"') {
inQuotes = true;
} else if (char === ",") {
fields.push(current.trim());
current = "";
} else {
current += char;
}
}
}
fields.push(current.trim());
return fields;
}
export function parseStylesCSV(csvContent: string): StyleData[] {
const lines = csvContent.split("\n").filter((l) => l.trim().length > 0);
if (lines.length < 2) return [];
// Skip header
const dataLines = lines.slice(1);
return dataLines.map((line) => {
const f = parseCSVLine(line);
const primaryColors = f[4] || "";
const secondaryColors = f[5] || "";
const cssTechnicalKeywords = f[19] || "";
return {
no: parseInt(f[0]) || 0,
styleCategory: f[1] || "",
type: f[2] || "",
keywords: f[3] || "",
primaryColors,
secondaryColors,
effectsAnimation: f[6] || "",
bestFor: f[7] || "",
doNotUseFor: f[8] || "",
lightMode: f[9] || "",
darkMode: f[10] || "",
performance: f[11] || "",
accessibility: f[12] || "",
mobileFriendly: f[13] || "",
conversionFocused: f[14] || "",
frameworkCompatibility: f[15] || "",
eraOrigin: f[16] || "",
complexity: f[17] || "",
aiPromptKeywords: f[18] || "",
cssTechnicalKeywords,
implementationChecklist: f[20] || "",
designSystemVariables: f[21] || "",
extractedColors: extractColors(`${primaryColors} ${secondaryColors}`),
cssProperties: parseCssKeywords(cssTechnicalKeywords),
};
});
}

27
gallery/lib/types.ts Normal file
View File

@ -0,0 +1,27 @@
export interface StyleData {
no: number;
styleCategory: string;
type: string;
keywords: string;
primaryColors: string;
secondaryColors: string;
effectsAnimation: string;
bestFor: string;
doNotUseFor: string;
lightMode: string;
darkMode: string;
performance: string;
accessibility: string;
mobileFriendly: string;
conversionFocused: string;
frameworkCompatibility: string;
eraOrigin: string;
complexity: string;
aiPromptKeywords: string;
cssTechnicalKeywords: string;
implementationChecklist: string;
designSystemVariables: string;
// Computed fields
extractedColors: string[];
cssProperties: Record<string, string>;
}

5
gallery/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

3
gallery/next.config.js Normal file
View File

@ -0,0 +1,3 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = nextConfig;

1628
gallery/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
gallery/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "style-gallery",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"next-themes": "^0.4.4"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,15 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
],
darkMode: "class",
theme: {
extend: {},
},
plugins: [],
};
export default config;

21
gallery/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

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