mirror of
https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git
synced 2026-08-04 11:58:44 +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)
|
|
||||||
@ -315,7 +315,6 @@ uipro init --ai kilocode # KiloCode
|
|||||||
uipro init --ai warp # Warp
|
uipro init --ai warp # Warp
|
||||||
uipro init --ai augment # Augment
|
uipro init --ai augment # Augment
|
||||||
uipro init --ai codewhale # CodeWhale
|
uipro init --ai codewhale # CodeWhale
|
||||||
uipro init --ai universal # Universal / Agent Standard (.agents/skills/)
|
|
||||||
uipro init --ai all # All assistants
|
uipro init --ai all # All assistants
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -326,7 +325,6 @@ The npm package is `ui-ux-pro-max-cli`; it still installs the `uipro` command. O
|
|||||||
```bash
|
```bash
|
||||||
uipro init --ai claude --global # Install to ~/.claude/skills/
|
uipro init --ai claude --global # Install to ~/.claude/skills/
|
||||||
uipro init --ai cursor --global # Install to ~/.cursor/skills/
|
uipro init --ai cursor --global # Install to ~/.cursor/skills/
|
||||||
uipro init --ai universal --global # Install to ~/.agents/skills/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Other CLI Commands
|
### Other CLI Commands
|
||||||
@ -334,7 +332,6 @@ uipro init --ai universal --global # Install to ~/.agents/skills/
|
|||||||
```bash
|
```bash
|
||||||
uipro versions # List available versions
|
uipro versions # List available versions
|
||||||
uipro update # Refresh skill files from installed CLI package
|
uipro update # Refresh skill files from installed CLI package
|
||||||
uipro update --global # Refresh global skill files from installed CLI package
|
|
||||||
uipro init --offline # Compatibility flag; installs bundled templates
|
uipro init --offline # Compatibility flag; installs bundled templates
|
||||||
uipro uninstall # Remove skill (auto-detect platform)
|
uipro uninstall # Remove skill (auto-detect platform)
|
||||||
uipro uninstall --ai claude # Remove specific platform
|
uipro uninstall --ai claude # Remove specific platform
|
||||||
|
|||||||
@ -315,7 +315,6 @@ uipro init --ai kilocode # KiloCode
|
|||||||
uipro init --ai warp # Warp
|
uipro init --ai warp # Warp
|
||||||
uipro init --ai augment # Augment
|
uipro init --ai augment # Augment
|
||||||
uipro init --ai codewhale # CodeWhale
|
uipro init --ai codewhale # CodeWhale
|
||||||
uipro init --ai universal # Universal / Agent Standard (.agents/skills/)
|
|
||||||
uipro init --ai all # 所有助手
|
uipro init --ai all # 所有助手
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -326,7 +325,6 @@ npm 包名为 `ui-ux-pro-max-cli`;它仍然安装 `uipro` 命令。旧版 `uip
|
|||||||
```bash
|
```bash
|
||||||
uipro init --ai claude --global # 安装到 ~/.claude/skills/
|
uipro init --ai claude --global # 安装到 ~/.claude/skills/
|
||||||
uipro init --ai cursor --global # 安装到 ~/.cursor/skills/
|
uipro init --ai cursor --global # 安装到 ~/.cursor/skills/
|
||||||
uipro init --ai universal --global # 安装到 ~/.agents/skills/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 其他 CLI 命令
|
### 其他 CLI 命令
|
||||||
@ -334,7 +332,6 @@ uipro init --ai universal --global # 安装到 ~/.agents/skills/
|
|||||||
```bash
|
```bash
|
||||||
uipro versions # 列出可用版本
|
uipro versions # 列出可用版本
|
||||||
uipro update # 从已安装的 CLI 包刷新技能文件
|
uipro update # 从已安装的 CLI 包刷新技能文件
|
||||||
uipro update --global # 从已安装的 CLI 包刷新全局技能文件
|
|
||||||
uipro init --offline # 兼容性标志;安装捆绑模板
|
uipro init --offline # 兼容性标志;安装捆绑模板
|
||||||
uipro uninstall # 移除技能(自动检测平台)
|
uipro uninstall # 移除技能(自动检测平台)
|
||||||
uipro uninstall --ai claude # 移除特定平台
|
uipro uninstall --ai claude # 移除特定平台
|
||||||
|
|||||||
@ -24,18 +24,16 @@ uipro init --ai qoder # Qoder
|
|||||||
uipro init --ai gemini # Gemini CLI
|
uipro init --ai gemini # Gemini CLI
|
||||||
uipro init --ai trae # Trae
|
uipro init --ai trae # Trae
|
||||||
uipro init --ai opencode # OpenCode
|
uipro init --ai opencode # OpenCode
|
||||||
uipro init --ai universal # Universal / Agent Standard (.agents/skills/)
|
uipro init --ai continue # Continue (Skills)
|
||||||
uipro init --ai all # All assistants
|
uipro init --ai all # All assistants
|
||||||
|
|
||||||
# Options
|
# Options
|
||||||
uipro init --offline # Compatibility flag; installs bundled templates
|
uipro init --offline # Compatibility flag; installs bundled templates
|
||||||
uipro init --force # Overwrite existing files
|
uipro init --force # Overwrite existing files
|
||||||
uipro init --global # Install globally to home directory (~/)
|
|
||||||
|
|
||||||
# Other commands
|
# Other commands
|
||||||
uipro versions # List available versions
|
uipro versions # List available versions
|
||||||
uipro update # Update the global CLI to the latest release
|
uipro update # Update the global CLI to the latest release
|
||||||
uipro update --global # Refresh globally installed skill files from this CLI package
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## GitHub Authentication
|
## GitHub Authentication
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"platform": "universal",
|
|
||||||
"displayName": "Universal / Agent Standard",
|
|
||||||
"installType": "full",
|
|
||||||
"folderStructure": {
|
|
||||||
"root": ".agents",
|
|
||||||
"skillPath": "skills/ui-ux-pro-max",
|
|
||||||
"filename": "SKILL.md"
|
|
||||||
},
|
|
||||||
"scriptPath": "skills/ui-ux-pro-max/scripts/search.py",
|
|
||||||
"frontmatter": {
|
|
||||||
"name": "ui-ux-pro-max",
|
|
||||||
"description": "Comprehensive design guide for web, mobile, and desktop applications. Contains 67 styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 22 technology stacks."
|
|
||||||
},
|
|
||||||
"sections": {
|
|
||||||
"quickReference": false
|
|
||||||
},
|
|
||||||
"title": "ui-ux-pro-max",
|
|
||||||
"description": "Comprehensive design guide for web, mobile, and desktop applications. Contains 67 styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 22 technology stacks. Searchable database with priority-based recommendations.",
|
|
||||||
"skillOrWorkflow": "Skill"
|
|
||||||
}
|
|
||||||
@ -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) {
|
|
||||||
skillFiles.add(join(folderStructure.root, folderStructure.skillPath, folderStructure.filename));
|
|
||||||
parents.add(join(folderStructure.root, dirname(folderStructure.dataPath)));
|
|
||||||
} else {
|
|
||||||
parents.add(join(folderStructure.root, dirname(folderStructure.skillPath)));
|
parents.add(join(folderStructure.root, dirname(folderStructure.skillPath)));
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// No platform config — fall back to the legacy folders below.
|
// No platform config — fall back to the legacy folders below.
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@ -15,18 +14,11 @@ const CLI_PACKAGE_NAME = 'ui-ux-pro-max-cli';
|
|||||||
|
|
||||||
interface UpdateOptions {
|
interface UpdateOptions {
|
||||||
ai?: AIType;
|
ai?: AIType;
|
||||||
global?: boolean;
|
|
||||||
token?: string;
|
token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPackageVersion(): Promise<string> {
|
async function getPackageVersion(): Promise<string> {
|
||||||
const packageCandidates = [
|
const packagePath = join(__dirname, '..', 'package.json');
|
||||||
// Bun bundle or root: dist/index.js -> ../package.json
|
|
||||||
join(__dirname, '..', 'package.json'),
|
|
||||||
// TypeScript fallback: dist/commands/update.js -> ../../package.json
|
|
||||||
join(__dirname, '..', '..', 'package.json'),
|
|
||||||
];
|
|
||||||
const packagePath = packageCandidates.find(path => existsSync(path)) ?? packageCandidates[0];
|
|
||||||
const pkg = JSON.parse(await readFile(packagePath, 'utf-8')) as { version: string };
|
const pkg = JSON.parse(await readFile(packagePath, 'utf-8')) as { version: string };
|
||||||
return pkg.version;
|
return pkg.version;
|
||||||
}
|
}
|
||||||
@ -46,9 +38,6 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {
|
|||||||
const latestVersion = normalizeTagVersion(release.tag_name);
|
const latestVersion = normalizeTagVersion(release.tag_name);
|
||||||
spinner.succeed(`Latest version: ${chalk.cyan(release.tag_name)}`);
|
spinner.succeed(`Latest version: ${chalk.cyan(release.tag_name)}`);
|
||||||
|
|
||||||
const aiFlag = options.ai ? ` --ai ${options.ai}` : ' --ai <platform>';
|
|
||||||
const globalFlag = options.global ? ' --global' : '';
|
|
||||||
|
|
||||||
if (currentVersion !== latestVersion) {
|
if (currentVersion !== latestVersion) {
|
||||||
console.log();
|
console.log();
|
||||||
|
|
||||||
@ -57,7 +46,7 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {
|
|||||||
if (!/^\d+\.\d+\.\d+([.-][0-9A-Za-z.-]+)?$/.test(latestVersion)) {
|
if (!/^\d+\.\d+\.\d+([.-][0-9A-Za-z.-]+)?$/.test(latestVersion)) {
|
||||||
logger.warn(`Installed CLI is ${chalk.cyan(currentVersion)}; latest release is ${chalk.cyan(release.tag_name)}.`);
|
logger.warn(`Installed CLI is ${chalk.cyan(currentVersion)}; latest release is ${chalk.cyan(release.tag_name)}.`);
|
||||||
logger.info(`Update the CLI package: ${chalk.cyan(`npm install -g ${CLI_PACKAGE_NAME}@${latestVersion}`)}`);
|
logger.info(`Update the CLI package: ${chalk.cyan(`npm install -g ${CLI_PACKAGE_NAME}@${latestVersion}`)}`);
|
||||||
logger.info(`Then rerun: uipro init${aiFlag} --force${globalFlag}`);
|
logger.info('Then rerun: uipro init --ai <platform> --force');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,7 +71,7 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {
|
|||||||
|
|
||||||
console.log();
|
console.log();
|
||||||
logger.success(`Updated to ${chalk.cyan(latestVersion)}.`);
|
logger.success(`Updated to ${chalk.cyan(latestVersion)}.`);
|
||||||
logger.info(`Now rerun ${chalk.cyan(`uipro init${aiFlag} --force${globalFlag}`)} to refresh your skill files.`);
|
logger.info(`Now rerun ${chalk.cyan('uipro init --ai <platform> --force')} to refresh your skill files.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,7 +82,6 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {
|
|||||||
await initCommand({
|
await initCommand({
|
||||||
ai: options.ai,
|
ai: options.ai,
|
||||||
force: true,
|
force: true,
|
||||||
global: options.global,
|
|
||||||
token: options.token,
|
token: options.token,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -55,7 +55,6 @@ program
|
|||||||
.command('update')
|
.command('update')
|
||||||
.description('Update UI/UX Pro Max to latest version')
|
.description('Update UI/UX Pro Max to latest version')
|
||||||
.option('-a, --ai <type>', `AI assistant type (${AI_TYPES.join(', ')})`)
|
.option('-a, --ai <type>', `AI assistant type (${AI_TYPES.join(', ')})`)
|
||||||
.option('-g, --global', 'Update global installation in home directory (~/)')
|
|
||||||
.option('-t, --token <token>', 'GitHub Personal Access Token for higher API rate limits')
|
.option('-t, --token <token>', 'GitHub Personal Access Token for higher API rate limits')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
if (options.ai && !AI_TYPES.includes(options.ai)) {
|
if (options.ai && !AI_TYPES.includes(options.ai)) {
|
||||||
@ -65,7 +64,6 @@ program
|
|||||||
}
|
}
|
||||||
await updateCommand({
|
await updateCommand({
|
||||||
ai: options.ai as AIType | undefined,
|
ai: options.ai as AIType | undefined,
|
||||||
global: options.global,
|
|
||||||
token: options.token,
|
token: options.token,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export type AIType = 'claude' | 'cursor' | 'windsurf' | 'antigravity' | 'copilot' | 'kiro' | 'roocode' | 'codex' | 'qoder' | 'gemini' | 'trae' | 'opencode' | 'continue' | 'codebuddy' | 'droid' | 'kilocode' | 'warp' | 'augment' | 'codewhale' | 'universal' | 'all';
|
export type AIType = 'claude' | 'cursor' | 'windsurf' | 'antigravity' | 'copilot' | 'kiro' | 'roocode' | 'codex' | 'qoder' | 'gemini' | 'trae' | 'opencode' | 'continue' | 'codebuddy' | 'droid' | 'kilocode' | 'warp' | 'augment' | 'codewhale' | 'all';
|
||||||
export type ConcreteAIType = Exclude<AIType, 'all'>;
|
export type ConcreteAIType = Exclude<AIType, 'all'>;
|
||||||
|
|
||||||
export type InstallType = 'full' | 'reference';
|
export type InstallType = 'full' | 'reference';
|
||||||
@ -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;
|
||||||
@ -43,7 +42,7 @@ export interface PlatformConfig {
|
|||||||
skillOrWorkflow: string;
|
skillOrWorkflow: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AI_TYPES: AIType[] = ['claude', 'cursor', 'windsurf', 'antigravity', 'copilot', 'roocode', 'kiro', 'codex', 'qoder', 'gemini', 'trae', 'opencode', 'continue', 'codebuddy', 'droid', 'kilocode', 'warp', 'augment', 'codewhale', 'universal', 'all'];
|
export const AI_TYPES: AIType[] = ['claude', 'cursor', 'windsurf', 'antigravity', 'copilot', 'roocode', 'kiro', 'codex', 'qoder', 'gemini', 'trae', 'opencode', 'continue', 'codebuddy', 'droid', 'kilocode', 'warp', 'augment', 'codewhale', 'all'];
|
||||||
|
|
||||||
// Legacy folder mapping for backward compatibility with ZIP-based installs.
|
// Legacy folder mapping for backward compatibility with ZIP-based installs.
|
||||||
// Note: .shared is included for platforms that used ZIP installs. Post-ZIP platforms
|
// Note: .shared is included for platforms that used ZIP installs. Post-ZIP platforms
|
||||||
@ -68,5 +67,4 @@ export const AI_FOLDERS: Record<ConcreteAIType, string[]> = {
|
|||||||
warp: ['.warp', '.shared'],
|
warp: ['.warp', '.shared'],
|
||||||
augment: ['.augment', '.shared'],
|
augment: ['.augment', '.shared'],
|
||||||
codewhale: ['.codewhale', '.shared'],
|
codewhale: ['.codewhale', '.shared'],
|
||||||
universal: ['.agents', '.shared'],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -22,7 +22,6 @@ export function detectAIType(cwd: string = process.cwd()): DetectionResult {
|
|||||||
if (existsSync(join(cwd, '.agents'))) {
|
if (existsSync(join(cwd, '.agents'))) {
|
||||||
detected.push('antigravity');
|
detected.push('antigravity');
|
||||||
detected.push('codex');
|
detected.push('codex');
|
||||||
detected.push('universal');
|
|
||||||
} else if (existsSync(join(cwd, '.agent'))) {
|
} else if (existsSync(join(cwd, '.agent'))) {
|
||||||
detected.push('antigravity');
|
detected.push('antigravity');
|
||||||
}
|
}
|
||||||
@ -77,11 +76,11 @@ export function detectAIType(cwd: string = process.cwd()): DetectionResult {
|
|||||||
if (detected.length === 1) {
|
if (detected.length === 1) {
|
||||||
suggested = detected[0];
|
suggested = detected[0];
|
||||||
} else if (
|
} else if (
|
||||||
(detected.length === 2 || detected.length === 3) &&
|
detected.length === 2 &&
|
||||||
detected.includes('antigravity') &&
|
detected.includes('antigravity') &&
|
||||||
detected.includes('codex')
|
detected.includes('codex')
|
||||||
) {
|
) {
|
||||||
// Platforms share `.agents`; avoid suggesting an install for every AI.
|
// Both platforms share `.agents`; avoid suggesting an install for every AI.
|
||||||
suggested = 'codex';
|
suggested = 'codex';
|
||||||
} else if (detected.length > 1) {
|
} else if (detected.length > 1) {
|
||||||
suggested = 'all';
|
suggested = 'all';
|
||||||
@ -130,8 +129,6 @@ export function getAITypeDescription(aiType: AIType): string {
|
|||||||
return 'Augment (.augment/skills/)';
|
return 'Augment (.augment/skills/)';
|
||||||
case 'codewhale':
|
case 'codewhale':
|
||||||
return 'CodeWhale (.codewhale/skills/)';
|
return 'CodeWhale (.codewhale/skills/)';
|
||||||
case 'universal':
|
|
||||||
return 'Universal (.agents/skills/)';
|
|
||||||
case 'all':
|
case 'all':
|
||||||
return 'All AI assistants';
|
return 'All AI assistants';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -54,7 +53,6 @@ const AI_TO_PLATFORM: Record<string, string> = {
|
|||||||
warp: 'warp',
|
warp: 'warp',
|
||||||
augment: 'augment',
|
augment: 'augment',
|
||||||
codewhale: 'codewhale',
|
codewhale: 'codewhale',
|
||||||
universal: 'universal',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function exists(path: string): Promise<boolean> {
|
async function exists(path: string): Promise<boolean> {
|
||||||
@ -153,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/`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,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
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"platform": "universal",
|
|
||||||
"displayName": "Universal / Agent Standard",
|
|
||||||
"installType": "full",
|
|
||||||
"folderStructure": {
|
|
||||||
"root": ".agents",
|
|
||||||
"skillPath": "skills/ui-ux-pro-max",
|
|
||||||
"filename": "SKILL.md"
|
|
||||||
},
|
|
||||||
"scriptPath": "skills/ui-ux-pro-max/scripts/search.py",
|
|
||||||
"frontmatter": {
|
|
||||||
"name": "ui-ux-pro-max",
|
|
||||||
"description": "Comprehensive design guide for web, mobile, and desktop applications. Contains 67 styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 22 technology stacks."
|
|
||||||
},
|
|
||||||
"sections": {
|
|
||||||
"quickReference": false
|
|
||||||
},
|
|
||||||
"title": "ui-ux-pro-max",
|
|
||||||
"description": "Comprehensive design guide for web, mobile, and desktop applications. Contains 67 styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 22 technology stacks. Searchable database with priority-based recommendations.",
|
|
||||||
"skillOrWorkflow": "Skill"
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user