mirror of
https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git
synced 2026-08-01 19:06:14 +00:00
Compare commits
No commits in common. "main" and "v2.11.3" have entirely different histories.
@ -77,108 +77,6 @@ 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."""
|
||||||
@ -346,11 +244,7 @@ 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)
|
||||||
# Resolve the mode from the style + query first, then pick a palette that
|
best_color = color_results[0] if color_results else {}
|
||||||
# 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 {}
|
||||||
|
|
||||||
@ -419,9 +313,7 @@ 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": _filter_anti_patterns_for_mode(
|
"anti_patterns": reasoning.get("anti_patterns", ""),
|
||||||
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": {
|
||||||
|
|||||||
@ -1,159 +0,0 @@
|
|||||||
#!/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,108 +77,6 @@ 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."""
|
||||||
@ -346,11 +244,7 @@ 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)
|
||||||
# Resolve the mode from the style + query first, then pick a palette that
|
best_color = color_results[0] if color_results else {}
|
||||||
# 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 {}
|
||||||
|
|
||||||
@ -419,9 +313,7 @@ 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": _filter_anti_patterns_for_mode(
|
"anti_patterns": reasoning.get("anti_patterns", ""),
|
||||||
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": {
|
||||||
|
|||||||
@ -1,159 +0,0 @@
|
|||||||
#!/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,14 +4,13 @@
|
|||||||
"installType": "full",
|
"installType": "full",
|
||||||
"folderStructure": {
|
"folderStructure": {
|
||||||
"root": ".github",
|
"root": ".github",
|
||||||
"skillPath": "prompts",
|
"skillPath": "prompts/ui-ux-pro-max",
|
||||||
"filename": "ui-ux-pro-max.prompt.md",
|
"filename": "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": {
|
||||||
"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.",
|
"name": "ui-ux-pro-max",
|
||||||
"mode": "agent"
|
"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."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"quickReference": false
|
"quickReference": false
|
||||||
|
|||||||
@ -30,18 +30,9 @@ 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) {
|
parents.add(join(folderStructure.root, dirname(folderStructure.skillPath)));
|
||||||
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)));
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// No platform config — fall back to the legacy folders below.
|
// No platform config — fall back to the legacy folders below.
|
||||||
}
|
}
|
||||||
@ -49,18 +40,6 @@ 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,7 +31,6 @@ 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,7 +21,6 @@ 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;
|
||||||
@ -152,22 +151,12 @@ 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(
|
||||||
new RegExp(`python3 ${config.scriptPath.replace(/\//g, '\\/')}`, 'g'),
|
/python3 skills\//g,
|
||||||
`python3 ${globalPrefix}${config.scriptPath}`
|
`python3 ${globalPrefix}skills/`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,24 +272,17 @@ 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 data directory (may differ from skill file location)
|
// Copy data and scripts into the skill directory (self-contained)
|
||||||
const dataDir = config.folderStructure.dataPath
|
await copyDataAndScripts(skillDir);
|
||||||
? 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. For platforms with
|
// copilot, steering/ for kiro) — derived, not hardcoded.
|
||||||
// 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,
|
||||||
config.folderStructure.dataPath
|
dirname(config.folderStructure.skillPath)
|
||||||
? dirname(config.folderStructure.dataPath)
|
|
||||||
: dirname(config.folderStructure.skillPath)
|
|
||||||
);
|
);
|
||||||
await copySubSkills(skillsParentDir, force);
|
await copySubSkills(skillsParentDir, force);
|
||||||
|
|
||||||
|
|||||||
3
gallery/.gitignore
vendored
3
gallery/.gitignore
vendored
@ -1,3 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
.next/
|
|
||||||
out/
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ThemeProvider } from "next-themes";
|
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
|
||||||
{children}
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,220 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,610 +0,0 @@
|
|||||||
"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 +0,0 @@
|
|||||||
../../src/ui-ux-pro-max/data/styles.csv
|
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,133 +0,0 @@
|
|||||||
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)`;
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
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),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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
5
gallery/next-env.d.ts
vendored
@ -1,5 +0,0 @@
|
|||||||
/// <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.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {};
|
|
||||||
module.exports = nextConfig;
|
|
||||||
1628
gallery/package-lock.json
generated
1628
gallery/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import type { Config } from "tailwindcss";
|
|
||||||
|
|
||||||
const config: Config = {
|
|
||||||
content: [
|
|
||||||
"./app/**/*.{ts,tsx}",
|
|
||||||
"./components/**/*.{ts,tsx}",
|
|
||||||
],
|
|
||||||
darkMode: "class",
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"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,108 +77,6 @@ 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."""
|
||||||
@ -346,11 +244,7 @@ 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)
|
||||||
# Resolve the mode from the style + query first, then pick a palette that
|
best_color = color_results[0] if color_results else {}
|
||||||
# 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 {}
|
||||||
|
|
||||||
@ -419,9 +313,7 @@ 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": _filter_anti_patterns_for_mode(
|
"anti_patterns": reasoning.get("anti_patterns", ""),
|
||||||
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": {
|
||||||
|
|||||||
@ -1,159 +0,0 @@
|
|||||||
#!/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,14 +4,13 @@
|
|||||||
"installType": "full",
|
"installType": "full",
|
||||||
"folderStructure": {
|
"folderStructure": {
|
||||||
"root": ".github",
|
"root": ".github",
|
||||||
"skillPath": "prompts",
|
"skillPath": "prompts/ui-ux-pro-max",
|
||||||
"filename": "ui-ux-pro-max.prompt.md",
|
"filename": "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": {
|
||||||
"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.",
|
"name": "ui-ux-pro-max",
|
||||||
"mode": "agent"
|
"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."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"quickReference": false
|
"quickReference": false
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user