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