Compare commits

...

10 Commits

Author SHA1 Message Date
Yadnesh Teli
4d140cf8ff
feat(cli): add --global flag to uipro update and support --ai universal target (#435, #436) (#437)
* feat(cli): add --global flag to update command and support --ai universal (#435, #436)

* fix(cli): support dist package.json resolution in update command
2026-08-03 19:30:53 +07:00
Yong Tang
14ddef5c05
feat(gallery): add Style Detail Modal with phone UI controls preview (#155)
Click any StyleCard to open a full-screen modal showing the style applied
to 9 interactive phone UI controls (buttons, inputs, toggles, checkboxes,
radios, cards, badges, slider, nav bar) inside an iPhone mockup frame.

Co-authored-by: yongtang <jlinux@msn.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-01 08:29:30 +07:00
vkayatas
b0ebb1797e
fix(copilot): generate .prompt.md file for VS Code Copilot slash commands (#328)
* fix(copilot): generate .prompt.md file for VS Code Copilot slash commands

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

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

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

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

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

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

---------

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

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

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

Adds scripts/tests/test_design_system_mode.py (stdlib unittest, matching
test_core.py) and syncs cli/assets + .claude/skills via sync-assets.mjs.
2026-07-31 18:00:15 +07:00
Kc
4857a2c5ef
fix(cli): sync shadcn test asset (#433) 2026-07-28 12:00:07 +07:00
Clark Cant
3b5df75479
Merge pull request #430 from scarletkc/codex/fix-codex-agents-skill-path
fix(codex): install skills under .agents
2026-07-27 13:30:30 +07:00
Kc
98a80d8630
test: expect pinned shadcn CLI version
Align the test expectation with the pinned shadcn@2.3.0 fallback implementation.
2026-07-27 13:01:28 +07:00
Kc
310810de9a docs: label shared .agents uninstall path 2026-07-27 12:31:40 +08:00
Kc
38e3de0bfe fix(cli): handle shared .agents targets 2026-07-27 12:29:21 +08:00
Kc
3321626663 fix(codex): install skills under .agents 2026-07-27 12:16:38 +08:00
55 changed files with 4757 additions and 36 deletions

View File

@ -173,7 +173,7 @@ class TestShadcnInstaller:
# Verify correct command was called # Verify correct command was called
mock_run.assert_called_once() mock_run.assert_called_once()
call_args = mock_run.call_args[0][0] call_args = mock_run.call_args[0][0]
assert call_args[:3] == ["npx", "shadcn@latest", "add"] assert call_args[:3] == ["npx", "shadcn@2.3.0", "add"]
assert "button" in call_args assert "button" in call_args
assert "card" in call_args assert "card" in call_args

View File

@ -77,6 +77,108 @@ def _resolve_dial(dial_name: str, value) -> dict:
return None return None
# ============ COLOR MODE RESOLUTION ============
# Style, palette and anti-patterns are resolved from separate CSVs. Without a
# shared notion of "which mode did we land on", a dark-primary style can be
# paired with a light palette and a "don't use dark mode" anti-pattern.
# Phrases in styles.csv "Light Mode ✓" / "Dark Mode ✓" that mark a style as
# dark-first rather than merely dark-capable ("✓ Full" means both work).
_DARK_PRIMARY_MARKERS = (
"dark mode primary", "dark primary", "dark-only", "dark only",
"dark preferred", "dark focused", "dark-first", "dark rich",
"light mode only as exception",
)
# Query phrases that are an explicit request for a dark theme.
_DARK_QUERY_MARKERS = (
"dark mode", "dark theme", "dark ui", "dark-mode", "darkmode",
"night mode", "midnight", "oled",
)
# Anti-pattern clauses that contradict a resolved dark mode.
_DARK_ANTI_PATTERN_MARKERS = ("dark mode", "dark modes", "dark theme")
# Relative luminance below which a Background hex counts as a dark surface.
# #1F2937 (the lightest dark background in colors.csv) sits at ~0.026 and
# #E8ECF1 (the darkest light background) at ~0.79, so the gap is wide.
_DARK_BACKGROUND_MAX_LUMINANCE = 0.18
def _relative_luminance(hex_color: str):
"""WCAG relative luminance of a #RRGGBB string, or None if unparseable."""
if not hex_color:
return None
value = hex_color.strip().lstrip("#")
if len(value) == 3:
value = "".join(c * 2 for c in value)
if len(value) != 6:
return None
try:
channels = [int(value[i:i + 2], 16) / 255 for i in (0, 2, 4)]
except ValueError:
return None
linear = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
for c in channels]
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
def _palette_is_dark(palette: dict) -> bool:
"""True when a colors.csv row's Background is a dark surface."""
luminance = _relative_luminance((palette or {}).get("Background", ""))
return luminance is not None and luminance < _DARK_BACKGROUND_MAX_LUMINANCE
def _style_is_dark_primary(style: dict) -> bool:
"""True when a styles.csv row describes itself as dark-first."""
if not style:
return False
declared = "{} {}".format(
style.get("Light Mode ✓", ""), style.get("Dark Mode ✓", "")
).lower()
return any(marker in declared for marker in _DARK_PRIMARY_MARKERS)
def _query_wants_dark(query: str) -> bool:
"""True when the query explicitly asks for a dark theme."""
lowered = (query or "").lower()
return any(marker in lowered for marker in _DARK_QUERY_MARKERS)
def _resolve_color_mode(query: str, style: dict) -> str:
"""Resolve the mode the rest of the output has to agree with."""
if _query_wants_dark(query) or _style_is_dark_primary(style):
return "dark"
return "light"
def _select_palette_for_mode(palettes: list, mode: str) -> dict:
"""Pick the highest-ranked palette matching the resolved mode.
Only the dark case filters. Light is left on the existing "top hit wins"
behaviour so queries that never mention a mode keep their current palette.
Falls back to the top hit when the data has no matching ramp.
"""
if not palettes:
return {}
if mode == "dark":
for palette in palettes:
if _palette_is_dark(palette):
return palette
return palettes[0]
def _filter_anti_patterns_for_mode(anti_patterns: str, mode: str) -> str:
"""Drop "avoid dark mode" advice once dark mode is the resolved answer."""
if mode != "dark" or not anti_patterns:
return anti_patterns
kept = [
clause for clause in anti_patterns.split("+")
if not any(marker in clause.lower() for marker in _DARK_ANTI_PATTERN_MARKERS)
]
return " + ".join(clause.strip() for clause in kept if clause.strip())
# ============ DESIGN SYSTEM GENERATOR ============ # ============ DESIGN SYSTEM GENERATOR ============
class DesignSystemGenerator: class DesignSystemGenerator:
"""Generates design system recommendations from aggregated searches.""" """Generates design system recommendations from aggregated searches."""
@ -244,7 +346,11 @@ class DesignSystemGenerator:
landing_results = self._extract_results(search_results.get("landing", {})) landing_results = self._extract_results(search_results.get("landing", {}))
best_style = self._select_best_match(style_results, effective_style_priority) best_style = self._select_best_match(style_results, effective_style_priority)
best_color = color_results[0] if color_results else {} # Resolve the mode from the style + query first, then pick a palette that
# agrees with it. Ranking colors independently is what let a dark-primary
# style ship with a light background.
color_mode = _resolve_color_mode(query, best_style)
best_color = _select_palette_for_mode(color_results, color_mode)
best_typography = typography_results[0] if typography_results else {} best_typography = typography_results[0] if typography_results else {}
best_landing = landing_results[0] if landing_results else {} best_landing = landing_results[0] if landing_results else {}
@ -313,7 +419,9 @@ class DesignSystemGenerator:
"css_import": best_typography.get("CSS Import", "") "css_import": best_typography.get("CSS Import", "")
}, },
"key_effects": combined_effects, "key_effects": combined_effects,
"anti_patterns": reasoning.get("anti_patterns", ""), "anti_patterns": _filter_anti_patterns_for_mode(
reasoning.get("anti_patterns", ""), color_mode
),
"decision_rules": reasoning.get("decision_rules", {}), "decision_rules": reasoning.get("decision_rules", {}),
"severity": reasoning.get("severity", "MEDIUM"), "severity": reasoning.get("severity", "MEDIUM"),
"dials": { "dials": {

View File

@ -0,0 +1,159 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Regression tests for color-mode coherence in design_system.py (issue #428).
Style, palette and anti-patterns used to be resolved independently, so a
dark-primary style could be returned alongside a light palette and a
"Dark mode by default" anti-pattern.
Stdlib-only (unittest, not pytest) to match test_core.py -- this project ships
with zero external dependencies.
Run with:
python -m unittest discover -s scripts/tests -v
or directly:
python scripts/tests/test_design_system_mode.py
"""
import sys
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(SCRIPTS_DIR))
from design_system import ( # noqa: E402
_filter_anti_patterns_for_mode,
_palette_is_dark,
_query_wants_dark,
_relative_luminance,
_resolve_color_mode,
_select_palette_for_mode,
_style_is_dark_primary,
DesignSystemGenerator,
) # noqa: I001 - private helpers first, public class last
LIGHT_PALETTE = {"Product Type": "SaaS", "Background": "#F8FAFC", "Foreground": "#020617"}
DARK_PALETTE = {"Product Type": "Fintech/Crypto", "Background": "#0F172A", "Foreground": "#F8FAFC"}
# Verbatim from styles.csv row "Modern Dark (Cinema Mobile)".
DARK_PRIMARY_STYLE = {
"Style Category": "Modern Dark (Cinema Mobile)",
"Light Mode ✓": "✓ Light mode only as exception",
"Dark Mode ✓": "✓ Dark Mode Primary",
}
DUAL_MODE_STYLE = {
"Style Category": "Minimalism",
"Light Mode ✓": "✓ Full",
"Dark Mode ✓": "✓ Full",
}
class TestLuminance(unittest.TestCase):
def test_parses_six_and_three_digit_hex(self):
self.assertAlmostEqual(_relative_luminance("#FFFFFF"), 1.0, places=6)
self.assertAlmostEqual(_relative_luminance("#000000"), 0.0, places=6)
self.assertAlmostEqual(_relative_luminance("#FFF"), 1.0, places=6)
def test_returns_none_for_unparseable(self):
for value in ("", "nope", "#12", "#GGGGGG", None):
self.assertIsNone(_relative_luminance(value))
def test_classifies_backgrounds_from_the_shipped_data(self):
# Lightest dark background and darkest light background in colors.csv.
self.assertTrue(_palette_is_dark({"Background": "#1F2937"}))
self.assertFalse(_palette_is_dark({"Background": "#E8ECF1"}))
def test_missing_background_is_not_dark(self):
self.assertFalse(_palette_is_dark({}))
self.assertFalse(_palette_is_dark(None))
class TestModeResolution(unittest.TestCase):
def test_dark_primary_style_detected(self):
self.assertTrue(_style_is_dark_primary(DARK_PRIMARY_STYLE))
def test_dual_mode_style_is_not_dark_primary(self):
self.assertFalse(_style_is_dark_primary(DUAL_MODE_STYLE))
self.assertFalse(_style_is_dark_primary({}))
def test_query_keywords(self):
self.assertTrue(_query_wants_dark("fintech B2B professional dark mode"))
self.assertTrue(_query_wants_dark("gaming app OLED"))
self.assertFalse(_query_wants_dark("healthcare clinic booking app"))
self.assertFalse(_query_wants_dark(""))
def test_either_signal_resolves_dark(self):
self.assertEqual(_resolve_color_mode("saas dark mode", DUAL_MODE_STYLE), "dark")
self.assertEqual(_resolve_color_mode("saas", DARK_PRIMARY_STYLE), "dark")
self.assertEqual(_resolve_color_mode("saas", DUAL_MODE_STYLE), "light")
class TestPaletteSelection(unittest.TestCase):
def test_dark_mode_skips_light_palettes(self):
chosen = _select_palette_for_mode([LIGHT_PALETTE, DARK_PALETTE], "dark")
self.assertEqual(chosen["Background"], "#0F172A")
def test_dark_mode_falls_back_to_top_hit_when_no_dark_ramp_exists(self):
chosen = _select_palette_for_mode([LIGHT_PALETTE], "dark")
self.assertEqual(chosen["Background"], "#F8FAFC")
def test_light_mode_keeps_the_existing_top_hit_behaviour(self):
chosen = _select_palette_for_mode([DARK_PALETTE, LIGHT_PALETTE], "light")
self.assertEqual(chosen["Background"], "#0F172A")
def test_empty_results(self):
self.assertEqual(_select_palette_for_mode([], "dark"), {})
class TestAntiPatternGating(unittest.TestCase):
def test_dark_clause_dropped_others_kept(self):
result = _filter_anti_patterns_for_mode(
"Excessive animation + Dark mode by default", "dark")
self.assertEqual(result, "Excessive animation")
def test_light_mode_is_a_no_op(self):
original = "Excessive animation + Dark mode by default"
self.assertEqual(_filter_anti_patterns_for_mode(original, "light"), original)
def test_unrelated_anti_patterns_survive_dark_mode(self):
original = "Complex jargon + Tiny tap targets"
self.assertEqual(_filter_anti_patterns_for_mode(original, "dark"), original)
def test_empty_input(self):
self.assertEqual(_filter_anti_patterns_for_mode("", "dark"), "")
class TestEndToEndCoherence(unittest.TestCase):
"""The exact reproduction from issue #428."""
QUERY = "SaaS invoicing fintech B2B professional dark mode"
def test_dark_query_gets_a_dark_background(self):
ds = DesignSystemGenerator().generate(self.QUERY)
background = ds["colors"]["background"]
self.assertTrue(
_palette_is_dark({"Background": background}),
"dark-mode query returned a light background: {}".format(background),
)
def test_dark_query_foreground_is_lighter_than_background(self):
ds = DesignSystemGenerator().generate(self.QUERY)
background = _relative_luminance(ds["colors"]["background"])
foreground = _relative_luminance(ds["colors"]["foreground"])
self.assertIsNotNone(background)
self.assertIsNotNone(foreground)
self.assertGreater(foreground, background)
def test_dark_query_does_not_advise_against_dark_mode(self):
ds = DesignSystemGenerator().generate(self.QUERY)
self.assertNotIn("dark mode", ds["anti_patterns"].lower())
def test_light_query_keeps_a_light_background(self):
ds = DesignSystemGenerator().generate("healthcare clinic booking app")
self.assertFalse(_palette_is_dark({"Background": ds["colors"]["background"]}))
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -315,6 +315,7 @@ 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
``` ```
@ -325,6 +326,7 @@ 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
@ -332,6 +334,7 @@ uipro init --ai cursor --global # Install to ~/.cursor/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
@ -579,7 +582,7 @@ uipro uninstall --global
rm -rf .claude/skills/ui-ux-pro-max # Claude Code rm -rf .claude/skills/ui-ux-pro-max # Claude Code
rm -rf .cursor/skills/ui-ux-pro-max # Cursor rm -rf .cursor/skills/ui-ux-pro-max # Cursor
rm -rf .windsurf/skills/ui-ux-pro-max # Windsurf rm -rf .windsurf/skills/ui-ux-pro-max # Windsurf
rm -rf .agents/skills/ui-ux-pro-max # Antigravity rm -rf .agents/skills/ui-ux-pro-max # Antigravity / Codex
``` ```
### Claude Marketplace install fails with "Zip file contains a symbolic link" ### Claude Marketplace install fails with "Zip file contains a symbolic link"

View File

@ -315,6 +315,7 @@ 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 # 所有助手
``` ```
@ -325,6 +326,7 @@ 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 命令
@ -332,6 +334,7 @@ uipro init --ai cursor --global # 安装到 ~/.cursor/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 # 移除特定平台
@ -572,7 +575,7 @@ uipro uninstall --global
rm -rf .claude/skills/ui-ux-pro-max # Claude Code rm -rf .claude/skills/ui-ux-pro-max # Claude Code
rm -rf .cursor/skills/ui-ux-pro-max # Cursor rm -rf .cursor/skills/ui-ux-pro-max # Cursor
rm -rf .windsurf/skills/ui-ux-pro-max # Windsurf rm -rf .windsurf/skills/ui-ux-pro-max # Windsurf
rm -rf .agents/skills/ui-ux-pro-max # Antigravity rm -rf .agents/skills/ui-ux-pro-max # Antigravity / Codex
``` ```
### Claude Marketplace 安装失败,提示 "Zip file contains a symbolic link" ### Claude Marketplace 安装失败,提示 "Zip file contains a symbolic link"

View File

@ -24,16 +24,18 @@ 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 continue # Continue (Skills) uipro init --ai universal # Universal / Agent Standard (.agents/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

View File

@ -77,6 +77,108 @@ def _resolve_dial(dial_name: str, value) -> dict:
return None return None
# ============ COLOR MODE RESOLUTION ============
# Style, palette and anti-patterns are resolved from separate CSVs. Without a
# shared notion of "which mode did we land on", a dark-primary style can be
# paired with a light palette and a "don't use dark mode" anti-pattern.
# Phrases in styles.csv "Light Mode ✓" / "Dark Mode ✓" that mark a style as
# dark-first rather than merely dark-capable ("✓ Full" means both work).
_DARK_PRIMARY_MARKERS = (
"dark mode primary", "dark primary", "dark-only", "dark only",
"dark preferred", "dark focused", "dark-first", "dark rich",
"light mode only as exception",
)
# Query phrases that are an explicit request for a dark theme.
_DARK_QUERY_MARKERS = (
"dark mode", "dark theme", "dark ui", "dark-mode", "darkmode",
"night mode", "midnight", "oled",
)
# Anti-pattern clauses that contradict a resolved dark mode.
_DARK_ANTI_PATTERN_MARKERS = ("dark mode", "dark modes", "dark theme")
# Relative luminance below which a Background hex counts as a dark surface.
# #1F2937 (the lightest dark background in colors.csv) sits at ~0.026 and
# #E8ECF1 (the darkest light background) at ~0.79, so the gap is wide.
_DARK_BACKGROUND_MAX_LUMINANCE = 0.18
def _relative_luminance(hex_color: str):
"""WCAG relative luminance of a #RRGGBB string, or None if unparseable."""
if not hex_color:
return None
value = hex_color.strip().lstrip("#")
if len(value) == 3:
value = "".join(c * 2 for c in value)
if len(value) != 6:
return None
try:
channels = [int(value[i:i + 2], 16) / 255 for i in (0, 2, 4)]
except ValueError:
return None
linear = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
for c in channels]
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
def _palette_is_dark(palette: dict) -> bool:
"""True when a colors.csv row's Background is a dark surface."""
luminance = _relative_luminance((palette or {}).get("Background", ""))
return luminance is not None and luminance < _DARK_BACKGROUND_MAX_LUMINANCE
def _style_is_dark_primary(style: dict) -> bool:
"""True when a styles.csv row describes itself as dark-first."""
if not style:
return False
declared = "{} {}".format(
style.get("Light Mode ✓", ""), style.get("Dark Mode ✓", "")
).lower()
return any(marker in declared for marker in _DARK_PRIMARY_MARKERS)
def _query_wants_dark(query: str) -> bool:
"""True when the query explicitly asks for a dark theme."""
lowered = (query or "").lower()
return any(marker in lowered for marker in _DARK_QUERY_MARKERS)
def _resolve_color_mode(query: str, style: dict) -> str:
"""Resolve the mode the rest of the output has to agree with."""
if _query_wants_dark(query) or _style_is_dark_primary(style):
return "dark"
return "light"
def _select_palette_for_mode(palettes: list, mode: str) -> dict:
"""Pick the highest-ranked palette matching the resolved mode.
Only the dark case filters. Light is left on the existing "top hit wins"
behaviour so queries that never mention a mode keep their current palette.
Falls back to the top hit when the data has no matching ramp.
"""
if not palettes:
return {}
if mode == "dark":
for palette in palettes:
if _palette_is_dark(palette):
return palette
return palettes[0]
def _filter_anti_patterns_for_mode(anti_patterns: str, mode: str) -> str:
"""Drop "avoid dark mode" advice once dark mode is the resolved answer."""
if mode != "dark" or not anti_patterns:
return anti_patterns
kept = [
clause for clause in anti_patterns.split("+")
if not any(marker in clause.lower() for marker in _DARK_ANTI_PATTERN_MARKERS)
]
return " + ".join(clause.strip() for clause in kept if clause.strip())
# ============ DESIGN SYSTEM GENERATOR ============ # ============ DESIGN SYSTEM GENERATOR ============
class DesignSystemGenerator: class DesignSystemGenerator:
"""Generates design system recommendations from aggregated searches.""" """Generates design system recommendations from aggregated searches."""
@ -244,7 +346,11 @@ class DesignSystemGenerator:
landing_results = self._extract_results(search_results.get("landing", {})) landing_results = self._extract_results(search_results.get("landing", {}))
best_style = self._select_best_match(style_results, effective_style_priority) best_style = self._select_best_match(style_results, effective_style_priority)
best_color = color_results[0] if color_results else {} # Resolve the mode from the style + query first, then pick a palette that
# agrees with it. Ranking colors independently is what let a dark-primary
# style ship with a light background.
color_mode = _resolve_color_mode(query, best_style)
best_color = _select_palette_for_mode(color_results, color_mode)
best_typography = typography_results[0] if typography_results else {} best_typography = typography_results[0] if typography_results else {}
best_landing = landing_results[0] if landing_results else {} best_landing = landing_results[0] if landing_results else {}
@ -313,7 +419,9 @@ class DesignSystemGenerator:
"css_import": best_typography.get("CSS Import", "") "css_import": best_typography.get("CSS Import", "")
}, },
"key_effects": combined_effects, "key_effects": combined_effects,
"anti_patterns": reasoning.get("anti_patterns", ""), "anti_patterns": _filter_anti_patterns_for_mode(
reasoning.get("anti_patterns", ""), color_mode
),
"decision_rules": reasoning.get("decision_rules", {}), "decision_rules": reasoning.get("decision_rules", {}),
"severity": reasoning.get("severity", "MEDIUM"), "severity": reasoning.get("severity", "MEDIUM"),
"dials": { "dials": {

View File

@ -0,0 +1,159 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Regression tests for color-mode coherence in design_system.py (issue #428).
Style, palette and anti-patterns used to be resolved independently, so a
dark-primary style could be returned alongside a light palette and a
"Dark mode by default" anti-pattern.
Stdlib-only (unittest, not pytest) to match test_core.py -- this project ships
with zero external dependencies.
Run with:
python -m unittest discover -s scripts/tests -v
or directly:
python scripts/tests/test_design_system_mode.py
"""
import sys
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(SCRIPTS_DIR))
from design_system import ( # noqa: E402
_filter_anti_patterns_for_mode,
_palette_is_dark,
_query_wants_dark,
_relative_luminance,
_resolve_color_mode,
_select_palette_for_mode,
_style_is_dark_primary,
DesignSystemGenerator,
) # noqa: I001 - private helpers first, public class last
LIGHT_PALETTE = {"Product Type": "SaaS", "Background": "#F8FAFC", "Foreground": "#020617"}
DARK_PALETTE = {"Product Type": "Fintech/Crypto", "Background": "#0F172A", "Foreground": "#F8FAFC"}
# Verbatim from styles.csv row "Modern Dark (Cinema Mobile)".
DARK_PRIMARY_STYLE = {
"Style Category": "Modern Dark (Cinema Mobile)",
"Light Mode ✓": "✓ Light mode only as exception",
"Dark Mode ✓": "✓ Dark Mode Primary",
}
DUAL_MODE_STYLE = {
"Style Category": "Minimalism",
"Light Mode ✓": "✓ Full",
"Dark Mode ✓": "✓ Full",
}
class TestLuminance(unittest.TestCase):
def test_parses_six_and_three_digit_hex(self):
self.assertAlmostEqual(_relative_luminance("#FFFFFF"), 1.0, places=6)
self.assertAlmostEqual(_relative_luminance("#000000"), 0.0, places=6)
self.assertAlmostEqual(_relative_luminance("#FFF"), 1.0, places=6)
def test_returns_none_for_unparseable(self):
for value in ("", "nope", "#12", "#GGGGGG", None):
self.assertIsNone(_relative_luminance(value))
def test_classifies_backgrounds_from_the_shipped_data(self):
# Lightest dark background and darkest light background in colors.csv.
self.assertTrue(_palette_is_dark({"Background": "#1F2937"}))
self.assertFalse(_palette_is_dark({"Background": "#E8ECF1"}))
def test_missing_background_is_not_dark(self):
self.assertFalse(_palette_is_dark({}))
self.assertFalse(_palette_is_dark(None))
class TestModeResolution(unittest.TestCase):
def test_dark_primary_style_detected(self):
self.assertTrue(_style_is_dark_primary(DARK_PRIMARY_STYLE))
def test_dual_mode_style_is_not_dark_primary(self):
self.assertFalse(_style_is_dark_primary(DUAL_MODE_STYLE))
self.assertFalse(_style_is_dark_primary({}))
def test_query_keywords(self):
self.assertTrue(_query_wants_dark("fintech B2B professional dark mode"))
self.assertTrue(_query_wants_dark("gaming app OLED"))
self.assertFalse(_query_wants_dark("healthcare clinic booking app"))
self.assertFalse(_query_wants_dark(""))
def test_either_signal_resolves_dark(self):
self.assertEqual(_resolve_color_mode("saas dark mode", DUAL_MODE_STYLE), "dark")
self.assertEqual(_resolve_color_mode("saas", DARK_PRIMARY_STYLE), "dark")
self.assertEqual(_resolve_color_mode("saas", DUAL_MODE_STYLE), "light")
class TestPaletteSelection(unittest.TestCase):
def test_dark_mode_skips_light_palettes(self):
chosen = _select_palette_for_mode([LIGHT_PALETTE, DARK_PALETTE], "dark")
self.assertEqual(chosen["Background"], "#0F172A")
def test_dark_mode_falls_back_to_top_hit_when_no_dark_ramp_exists(self):
chosen = _select_palette_for_mode([LIGHT_PALETTE], "dark")
self.assertEqual(chosen["Background"], "#F8FAFC")
def test_light_mode_keeps_the_existing_top_hit_behaviour(self):
chosen = _select_palette_for_mode([DARK_PALETTE, LIGHT_PALETTE], "light")
self.assertEqual(chosen["Background"], "#0F172A")
def test_empty_results(self):
self.assertEqual(_select_palette_for_mode([], "dark"), {})
class TestAntiPatternGating(unittest.TestCase):
def test_dark_clause_dropped_others_kept(self):
result = _filter_anti_patterns_for_mode(
"Excessive animation + Dark mode by default", "dark")
self.assertEqual(result, "Excessive animation")
def test_light_mode_is_a_no_op(self):
original = "Excessive animation + Dark mode by default"
self.assertEqual(_filter_anti_patterns_for_mode(original, "light"), original)
def test_unrelated_anti_patterns_survive_dark_mode(self):
original = "Complex jargon + Tiny tap targets"
self.assertEqual(_filter_anti_patterns_for_mode(original, "dark"), original)
def test_empty_input(self):
self.assertEqual(_filter_anti_patterns_for_mode("", "dark"), "")
class TestEndToEndCoherence(unittest.TestCase):
"""The exact reproduction from issue #428."""
QUERY = "SaaS invoicing fintech B2B professional dark mode"
def test_dark_query_gets_a_dark_background(self):
ds = DesignSystemGenerator().generate(self.QUERY)
background = ds["colors"]["background"]
self.assertTrue(
_palette_is_dark({"Background": background}),
"dark-mode query returned a light background: {}".format(background),
)
def test_dark_query_foreground_is_lighter_than_background(self):
ds = DesignSystemGenerator().generate(self.QUERY)
background = _relative_luminance(ds["colors"]["background"])
foreground = _relative_luminance(ds["colors"]["foreground"])
self.assertIsNotNone(background)
self.assertIsNotNone(foreground)
self.assertGreater(foreground, background)
def test_dark_query_does_not_advise_against_dark_mode(self):
ds = DesignSystemGenerator().generate(self.QUERY)
self.assertNotIn("dark mode", ds["anti_patterns"].lower())
def test_light_query_keeps_a_light_background(self):
ds = DesignSystemGenerator().generate("healthcare clinic booking app")
self.assertFalse(_palette_is_dark({"Background": ds["colors"]["background"]}))
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -173,7 +173,7 @@ class TestShadcnInstaller:
# Verify correct command was called # Verify correct command was called
mock_run.assert_called_once() mock_run.assert_called_once()
call_args = mock_run.call_args[0][0] call_args = mock_run.call_args[0][0]
assert call_args[:3] == ["npx", "shadcn@latest", "add"] assert call_args[:3] == ["npx", "shadcn@2.3.0", "add"]
assert "button" in call_args assert "button" in call_args
assert "card" in call_args assert "card" in call_args

View File

@ -3,7 +3,7 @@
"displayName": "Codex", "displayName": "Codex",
"installType": "full", "installType": "full",
"folderStructure": { "folderStructure": {
"root": ".codex", "root": ".agents",
"skillPath": "skills/ui-ux-pro-max", "skillPath": "skills/ui-ux-pro-max",
"filename": "SKILL.md" "filename": "SKILL.md"
}, },

View File

@ -4,13 +4,14 @@
"installType": "full", "installType": "full",
"folderStructure": { "folderStructure": {
"root": ".github", "root": ".github",
"skillPath": "prompts/ui-ux-pro-max", "skillPath": "prompts",
"filename": "PROMPT.md" "filename": "ui-ux-pro-max.prompt.md",
"dataPath": "prompts/ui-ux-pro-max"
}, },
"scriptPath": "prompts/ui-ux-pro-max/scripts/search.py", "scriptPath": "prompts/ui-ux-pro-max/scripts/search.py",
"frontmatter": { "frontmatter": {
"name": "ui-ux-pro-max", "description": "Comprehensive design guide for web, mobile, and desktop applications. Contains 67 styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 22 technology stacks.",
"description": "Comprehensive design guide for web, mobile, and desktop applications. Contains 67 styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 22 technology stacks." "mode": "agent"
}, },
"sections": { "sections": {
"quickReference": false "quickReference": false

View File

@ -0,0 +1,21 @@
{
"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"
}

View File

@ -30,9 +30,18 @@ async function removeSkillDir(baseDir: string, aiType: ConcreteAIType): Promise<
// `.github/prompts/`, kiro under `.kiro/steering/`. Also clean the legacy // `.github/prompts/`, kiro under `.kiro/steering/`. Also clean the legacy
// `<folder>/skills/` layout (incl. `.shared/`) so older installs are removed. // `<folder>/skills/` layout (incl. `.shared/`) so older installs are removed.
const parents = new Set<string>(); const parents = new Set<string>();
// Standalone skill files to remove (platforms with a dataPath keep the
// rendered skill file apart from the data directory — e.g. copilot's
// `.github/prompts/ui-ux-pro-max.prompt.md`).
const skillFiles = new Set<string>();
try { try {
const { folderStructure } = await loadPlatformConfig(aiType); const { folderStructure } = await loadPlatformConfig(aiType);
if (folderStructure.dataPath) {
skillFiles.add(join(folderStructure.root, folderStructure.skillPath, folderStructure.filename));
parents.add(join(folderStructure.root, dirname(folderStructure.dataPath)));
} else {
parents.add(join(folderStructure.root, dirname(folderStructure.skillPath))); parents.add(join(folderStructure.root, dirname(folderStructure.skillPath)));
}
} catch { } catch {
// No platform config — fall back to the legacy folders below. // No platform config — fall back to the legacy folders below.
} }
@ -40,6 +49,18 @@ async function removeSkillDir(baseDir: string, aiType: ConcreteAIType): Promise<
parents.add(join(folder, 'skills')); parents.add(join(folder, 'skills'));
} }
for (const skillFile of skillFiles) {
const filePath = join(baseDir, skillFile);
try {
await stat(filePath);
await rm(filePath, { force: true });
removed.push(skillFile.replaceAll('\\', '/'));
} catch (err: unknown) {
// Skip non-existent files; re-throw permission or other errors
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
}
for (const parent of parents) { for (const parent of parents) {
for (const name of skillNames) { for (const name of skillNames) {
const skillDir = join(baseDir, parent, name); const skillDir = join(baseDir, parent, name);

View File

@ -1,4 +1,5 @@
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';
@ -14,11 +15,18 @@ 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 packagePath = join(__dirname, '..', 'package.json'); const packageCandidates = [
// 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;
} }
@ -38,6 +46,9 @@ 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();
@ -46,7 +57,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 --ai <platform> --force'); logger.info(`Then rerun: uipro init${aiFlag} --force${globalFlag}`);
return; return;
} }
@ -71,7 +82,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 --ai <platform> --force')} to refresh your skill files.`); logger.info(`Now rerun ${chalk.cyan(`uipro init${aiFlag} --force${globalFlag}`)} to refresh your skill files.`);
return; return;
} }
@ -82,6 +93,7 @@ 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) {

View File

@ -55,6 +55,7 @@ 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)) {
@ -64,6 +65,7 @@ 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,
}); });
}); });

View File

@ -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' | 'all'; 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 ConcreteAIType = Exclude<AIType, 'all'>; export type ConcreteAIType = Exclude<AIType, 'all'>;
export type InstallType = 'full' | 'reference'; export type InstallType = 'full' | 'reference';
@ -31,6 +31,7 @@ export interface PlatformConfig {
root: string; root: string;
skillPath: string; skillPath: string;
filename: string; filename: string;
dataPath?: string;
}; };
scriptPath: string; scriptPath: string;
frontmatter: Record<string, string> | null; frontmatter: Record<string, string> | null;
@ -42,7 +43,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', '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', 'universal', '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
@ -67,4 +68,5 @@ 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'],
}; };

View File

@ -19,7 +19,11 @@ export function detectAIType(cwd: string = process.cwd()): DetectionResult {
if (existsSync(join(cwd, '.windsurf'))) { if (existsSync(join(cwd, '.windsurf'))) {
detected.push('windsurf'); detected.push('windsurf');
} }
if (existsSync(join(cwd, '.agents')) || existsSync(join(cwd, '.agent'))) { if (existsSync(join(cwd, '.agents'))) {
detected.push('antigravity');
detected.push('codex');
detected.push('universal');
} else if (existsSync(join(cwd, '.agent'))) {
detected.push('antigravity'); detected.push('antigravity');
} }
if (existsSync(join(cwd, '.github'))) { if (existsSync(join(cwd, '.github'))) {
@ -28,7 +32,7 @@ export function detectAIType(cwd: string = process.cwd()): DetectionResult {
if (existsSync(join(cwd, '.kiro'))) { if (existsSync(join(cwd, '.kiro'))) {
detected.push('kiro'); detected.push('kiro');
} }
if (existsSync(join(cwd, '.codex'))) { if (existsSync(join(cwd, '.codex')) && !detected.includes('codex')) {
detected.push('codex'); detected.push('codex');
} }
if (existsSync(join(cwd, '.roo'))) { if (existsSync(join(cwd, '.roo'))) {
@ -72,6 +76,13 @@ export function detectAIType(cwd: string = process.cwd()): DetectionResult {
let suggested: AIType | null = null; let suggested: AIType | null = null;
if (detected.length === 1) { if (detected.length === 1) {
suggested = detected[0]; suggested = detected[0];
} else if (
(detected.length === 2 || detected.length === 3) &&
detected.includes('antigravity') &&
detected.includes('codex')
) {
// Platforms share `.agents`; avoid suggesting an install for every AI.
suggested = 'codex';
} else if (detected.length > 1) { } else if (detected.length > 1) {
suggested = 'all'; suggested = 'all';
} }
@ -94,7 +105,7 @@ export function getAITypeDescription(aiType: AIType): string {
case 'kiro': case 'kiro':
return 'Kiro (.kiro/steering/)'; return 'Kiro (.kiro/steering/)';
case 'codex': case 'codex':
return 'Codex (.codex/skills/)'; return 'Codex (.agents/skills/)';
case 'roocode': case 'roocode':
return 'RooCode (.roo/skills/)'; return 'RooCode (.roo/skills/)';
case 'qoder': case 'qoder':
@ -119,6 +130,8 @@ 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';
} }

View File

@ -21,6 +21,7 @@ export interface PlatformConfig {
root: string; root: string;
skillPath: string; skillPath: string;
filename: string; filename: string;
dataPath?: string;
}; };
scriptPath: string; scriptPath: string;
frontmatter: Record<string, string> | null; frontmatter: Record<string, string> | null;
@ -53,6 +54,7 @@ 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> {
@ -151,12 +153,22 @@ export async function renderSkillFile(config: PlatformConfig, isGlobal = false):
.replace(/\{\{SKILL_OR_WORKFLOW\}\}/g, config.skillOrWorkflow) .replace(/\{\{SKILL_OR_WORKFLOW\}\}/g, config.skillOrWorkflow)
.replace(/\{\{QUICK_REFERENCE\}\}/g, quickRefWithNewline); .replace(/\{\{QUICK_REFERENCE\}\}/g, quickRefWithNewline);
// Rewrite the hardcoded default script path to the platform-specific path
const defaultScriptPath = 'skills/ui-ux-pro-max/scripts/search.py';
if (config.scriptPath !== defaultScriptPath) {
content = content.replace(
new RegExp(defaultScriptPath.replace(/\//g, '\\/'), 'g'),
config.scriptPath
);
}
// For global install, rewrite relative script paths to absolute ~/root/ paths // For global install, rewrite relative script paths to absolute ~/root/ paths
if (isGlobal) { if (isGlobal) {
const globalPrefix = `~/${config.folderStructure.root}/`; const globalPrefix = `~/${config.folderStructure.root}/`;
// Match any platform's script path pattern (skills/, prompts/, steering/, etc.)
content = content.replace( content = content.replace(
/python3 skills\//g, new RegExp(`python3 ${config.scriptPath.replace(/\//g, '\\/')}`, 'g'),
`python3 ${globalPrefix}skills/` `python3 ${globalPrefix}${config.scriptPath}`
); );
} }
@ -272,17 +284,24 @@ export async function generatePlatformFiles(
await writeFile(skillFilePath, skillContent, 'utf-8'); await writeFile(skillFilePath, skillContent, 'utf-8');
createdFolders.push(config.folderStructure.root); createdFolders.push(config.folderStructure.root);
// Copy data and scripts into the skill directory (self-contained) // Copy data and scripts into the data directory (may differ from skill file location)
await copyDataAndScripts(skillDir); const dataDir = config.folderStructure.dataPath
? join(effectiveDir, config.folderStructure.root, config.folderStructure.dataPath)
: skillDir;
await mkdir(dataDir, { recursive: true });
await copyDataAndScripts(dataDir);
// Install the sibling sub-skills (banner-design, brand, design, ...) next to // Install the sibling sub-skills (banner-design, brand, design, ...) next to
// the orchestrator so all 7 skills are delivered. The skills parent is the // the orchestrator so all 7 skills are delivered. The skills parent is the
// orchestrator's parent dir (skills/ for most platforms, prompts/ for // orchestrator's parent dir (skills/ for most platforms, prompts/ for
// copilot, steering/ for kiro) — derived, not hardcoded. // copilot, steering/ for kiro) — derived, not hardcoded. For platforms with
// a separate dataPath (copilot), the orchestrator's data dir is the anchor.
const skillsParentDir = join( const skillsParentDir = join(
effectiveDir, effectiveDir,
config.folderStructure.root, config.folderStructure.root,
dirname(config.folderStructure.skillPath) config.folderStructure.dataPath
? dirname(config.folderStructure.dataPath)
: dirname(config.folderStructure.skillPath)
); );
await copySubSkills(skillsParentDir, force); await copySubSkills(skillsParentDir, force);
@ -294,10 +313,20 @@ export async function generatePlatformFiles(
*/ */
export async function generateAllPlatformFiles(targetDir: string, isGlobal = false, force = false): Promise<string[]> { export async function generateAllPlatformFiles(targetDir: string, isGlobal = false, force = false): Promise<string[]> {
const allFolders = new Set<string>(); const allFolders = new Set<string>();
const generatedSkillFiles = new Set<string>();
for (const aiType of Object.keys(AI_TO_PLATFORM)) { for (const aiType of Object.keys(AI_TO_PLATFORM)) {
try { try {
const config = await loadPlatformConfig(aiType);
const skillFile = join(
config.folderStructure.root,
config.folderStructure.skillPath,
config.folderStructure.filename
);
if (generatedSkillFiles.has(skillFile)) continue;
const folders = await generatePlatformFiles(targetDir, aiType, isGlobal, force); const folders = await generatePlatformFiles(targetDir, aiType, isGlobal, force);
generatedSkillFiles.add(skillFile);
folders.forEach(f => allFolders.add(f)); folders.forEach(f => allFolders.add(f));
} catch { } catch {
// Skip if generation fails for a platform // Skip if generation fails for a platform

3
gallery/.gitignore vendored Normal file
View File

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

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

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

1628
gallery/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
gallery/package.json Normal file
View File

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

View File

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

View File

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

21
gallery/tsconfig.json Normal file
View File

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

View File

@ -77,6 +77,108 @@ def _resolve_dial(dial_name: str, value) -> dict:
return None return None
# ============ COLOR MODE RESOLUTION ============
# Style, palette and anti-patterns are resolved from separate CSVs. Without a
# shared notion of "which mode did we land on", a dark-primary style can be
# paired with a light palette and a "don't use dark mode" anti-pattern.
# Phrases in styles.csv "Light Mode ✓" / "Dark Mode ✓" that mark a style as
# dark-first rather than merely dark-capable ("✓ Full" means both work).
_DARK_PRIMARY_MARKERS = (
"dark mode primary", "dark primary", "dark-only", "dark only",
"dark preferred", "dark focused", "dark-first", "dark rich",
"light mode only as exception",
)
# Query phrases that are an explicit request for a dark theme.
_DARK_QUERY_MARKERS = (
"dark mode", "dark theme", "dark ui", "dark-mode", "darkmode",
"night mode", "midnight", "oled",
)
# Anti-pattern clauses that contradict a resolved dark mode.
_DARK_ANTI_PATTERN_MARKERS = ("dark mode", "dark modes", "dark theme")
# Relative luminance below which a Background hex counts as a dark surface.
# #1F2937 (the lightest dark background in colors.csv) sits at ~0.026 and
# #E8ECF1 (the darkest light background) at ~0.79, so the gap is wide.
_DARK_BACKGROUND_MAX_LUMINANCE = 0.18
def _relative_luminance(hex_color: str):
"""WCAG relative luminance of a #RRGGBB string, or None if unparseable."""
if not hex_color:
return None
value = hex_color.strip().lstrip("#")
if len(value) == 3:
value = "".join(c * 2 for c in value)
if len(value) != 6:
return None
try:
channels = [int(value[i:i + 2], 16) / 255 for i in (0, 2, 4)]
except ValueError:
return None
linear = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
for c in channels]
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
def _palette_is_dark(palette: dict) -> bool:
"""True when a colors.csv row's Background is a dark surface."""
luminance = _relative_luminance((palette or {}).get("Background", ""))
return luminance is not None and luminance < _DARK_BACKGROUND_MAX_LUMINANCE
def _style_is_dark_primary(style: dict) -> bool:
"""True when a styles.csv row describes itself as dark-first."""
if not style:
return False
declared = "{} {}".format(
style.get("Light Mode ✓", ""), style.get("Dark Mode ✓", "")
).lower()
return any(marker in declared for marker in _DARK_PRIMARY_MARKERS)
def _query_wants_dark(query: str) -> bool:
"""True when the query explicitly asks for a dark theme."""
lowered = (query or "").lower()
return any(marker in lowered for marker in _DARK_QUERY_MARKERS)
def _resolve_color_mode(query: str, style: dict) -> str:
"""Resolve the mode the rest of the output has to agree with."""
if _query_wants_dark(query) or _style_is_dark_primary(style):
return "dark"
return "light"
def _select_palette_for_mode(palettes: list, mode: str) -> dict:
"""Pick the highest-ranked palette matching the resolved mode.
Only the dark case filters. Light is left on the existing "top hit wins"
behaviour so queries that never mention a mode keep their current palette.
Falls back to the top hit when the data has no matching ramp.
"""
if not palettes:
return {}
if mode == "dark":
for palette in palettes:
if _palette_is_dark(palette):
return palette
return palettes[0]
def _filter_anti_patterns_for_mode(anti_patterns: str, mode: str) -> str:
"""Drop "avoid dark mode" advice once dark mode is the resolved answer."""
if mode != "dark" or not anti_patterns:
return anti_patterns
kept = [
clause for clause in anti_patterns.split("+")
if not any(marker in clause.lower() for marker in _DARK_ANTI_PATTERN_MARKERS)
]
return " + ".join(clause.strip() for clause in kept if clause.strip())
# ============ DESIGN SYSTEM GENERATOR ============ # ============ DESIGN SYSTEM GENERATOR ============
class DesignSystemGenerator: class DesignSystemGenerator:
"""Generates design system recommendations from aggregated searches.""" """Generates design system recommendations from aggregated searches."""
@ -244,7 +346,11 @@ class DesignSystemGenerator:
landing_results = self._extract_results(search_results.get("landing", {})) landing_results = self._extract_results(search_results.get("landing", {}))
best_style = self._select_best_match(style_results, effective_style_priority) best_style = self._select_best_match(style_results, effective_style_priority)
best_color = color_results[0] if color_results else {} # Resolve the mode from the style + query first, then pick a palette that
# agrees with it. Ranking colors independently is what let a dark-primary
# style ship with a light background.
color_mode = _resolve_color_mode(query, best_style)
best_color = _select_palette_for_mode(color_results, color_mode)
best_typography = typography_results[0] if typography_results else {} best_typography = typography_results[0] if typography_results else {}
best_landing = landing_results[0] if landing_results else {} best_landing = landing_results[0] if landing_results else {}
@ -313,7 +419,9 @@ class DesignSystemGenerator:
"css_import": best_typography.get("CSS Import", "") "css_import": best_typography.get("CSS Import", "")
}, },
"key_effects": combined_effects, "key_effects": combined_effects,
"anti_patterns": reasoning.get("anti_patterns", ""), "anti_patterns": _filter_anti_patterns_for_mode(
reasoning.get("anti_patterns", ""), color_mode
),
"decision_rules": reasoning.get("decision_rules", {}), "decision_rules": reasoning.get("decision_rules", {}),
"severity": reasoning.get("severity", "MEDIUM"), "severity": reasoning.get("severity", "MEDIUM"),
"dials": { "dials": {

View File

@ -0,0 +1,159 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Regression tests for color-mode coherence in design_system.py (issue #428).
Style, palette and anti-patterns used to be resolved independently, so a
dark-primary style could be returned alongside a light palette and a
"Dark mode by default" anti-pattern.
Stdlib-only (unittest, not pytest) to match test_core.py -- this project ships
with zero external dependencies.
Run with:
python -m unittest discover -s scripts/tests -v
or directly:
python scripts/tests/test_design_system_mode.py
"""
import sys
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(SCRIPTS_DIR))
from design_system import ( # noqa: E402
_filter_anti_patterns_for_mode,
_palette_is_dark,
_query_wants_dark,
_relative_luminance,
_resolve_color_mode,
_select_palette_for_mode,
_style_is_dark_primary,
DesignSystemGenerator,
) # noqa: I001 - private helpers first, public class last
LIGHT_PALETTE = {"Product Type": "SaaS", "Background": "#F8FAFC", "Foreground": "#020617"}
DARK_PALETTE = {"Product Type": "Fintech/Crypto", "Background": "#0F172A", "Foreground": "#F8FAFC"}
# Verbatim from styles.csv row "Modern Dark (Cinema Mobile)".
DARK_PRIMARY_STYLE = {
"Style Category": "Modern Dark (Cinema Mobile)",
"Light Mode ✓": "✓ Light mode only as exception",
"Dark Mode ✓": "✓ Dark Mode Primary",
}
DUAL_MODE_STYLE = {
"Style Category": "Minimalism",
"Light Mode ✓": "✓ Full",
"Dark Mode ✓": "✓ Full",
}
class TestLuminance(unittest.TestCase):
def test_parses_six_and_three_digit_hex(self):
self.assertAlmostEqual(_relative_luminance("#FFFFFF"), 1.0, places=6)
self.assertAlmostEqual(_relative_luminance("#000000"), 0.0, places=6)
self.assertAlmostEqual(_relative_luminance("#FFF"), 1.0, places=6)
def test_returns_none_for_unparseable(self):
for value in ("", "nope", "#12", "#GGGGGG", None):
self.assertIsNone(_relative_luminance(value))
def test_classifies_backgrounds_from_the_shipped_data(self):
# Lightest dark background and darkest light background in colors.csv.
self.assertTrue(_palette_is_dark({"Background": "#1F2937"}))
self.assertFalse(_palette_is_dark({"Background": "#E8ECF1"}))
def test_missing_background_is_not_dark(self):
self.assertFalse(_palette_is_dark({}))
self.assertFalse(_palette_is_dark(None))
class TestModeResolution(unittest.TestCase):
def test_dark_primary_style_detected(self):
self.assertTrue(_style_is_dark_primary(DARK_PRIMARY_STYLE))
def test_dual_mode_style_is_not_dark_primary(self):
self.assertFalse(_style_is_dark_primary(DUAL_MODE_STYLE))
self.assertFalse(_style_is_dark_primary({}))
def test_query_keywords(self):
self.assertTrue(_query_wants_dark("fintech B2B professional dark mode"))
self.assertTrue(_query_wants_dark("gaming app OLED"))
self.assertFalse(_query_wants_dark("healthcare clinic booking app"))
self.assertFalse(_query_wants_dark(""))
def test_either_signal_resolves_dark(self):
self.assertEqual(_resolve_color_mode("saas dark mode", DUAL_MODE_STYLE), "dark")
self.assertEqual(_resolve_color_mode("saas", DARK_PRIMARY_STYLE), "dark")
self.assertEqual(_resolve_color_mode("saas", DUAL_MODE_STYLE), "light")
class TestPaletteSelection(unittest.TestCase):
def test_dark_mode_skips_light_palettes(self):
chosen = _select_palette_for_mode([LIGHT_PALETTE, DARK_PALETTE], "dark")
self.assertEqual(chosen["Background"], "#0F172A")
def test_dark_mode_falls_back_to_top_hit_when_no_dark_ramp_exists(self):
chosen = _select_palette_for_mode([LIGHT_PALETTE], "dark")
self.assertEqual(chosen["Background"], "#F8FAFC")
def test_light_mode_keeps_the_existing_top_hit_behaviour(self):
chosen = _select_palette_for_mode([DARK_PALETTE, LIGHT_PALETTE], "light")
self.assertEqual(chosen["Background"], "#0F172A")
def test_empty_results(self):
self.assertEqual(_select_palette_for_mode([], "dark"), {})
class TestAntiPatternGating(unittest.TestCase):
def test_dark_clause_dropped_others_kept(self):
result = _filter_anti_patterns_for_mode(
"Excessive animation + Dark mode by default", "dark")
self.assertEqual(result, "Excessive animation")
def test_light_mode_is_a_no_op(self):
original = "Excessive animation + Dark mode by default"
self.assertEqual(_filter_anti_patterns_for_mode(original, "light"), original)
def test_unrelated_anti_patterns_survive_dark_mode(self):
original = "Complex jargon + Tiny tap targets"
self.assertEqual(_filter_anti_patterns_for_mode(original, "dark"), original)
def test_empty_input(self):
self.assertEqual(_filter_anti_patterns_for_mode("", "dark"), "")
class TestEndToEndCoherence(unittest.TestCase):
"""The exact reproduction from issue #428."""
QUERY = "SaaS invoicing fintech B2B professional dark mode"
def test_dark_query_gets_a_dark_background(self):
ds = DesignSystemGenerator().generate(self.QUERY)
background = ds["colors"]["background"]
self.assertTrue(
_palette_is_dark({"Background": background}),
"dark-mode query returned a light background: {}".format(background),
)
def test_dark_query_foreground_is_lighter_than_background(self):
ds = DesignSystemGenerator().generate(self.QUERY)
background = _relative_luminance(ds["colors"]["background"])
foreground = _relative_luminance(ds["colors"]["foreground"])
self.assertIsNotNone(background)
self.assertIsNotNone(foreground)
self.assertGreater(foreground, background)
def test_dark_query_does_not_advise_against_dark_mode(self):
ds = DesignSystemGenerator().generate(self.QUERY)
self.assertNotIn("dark mode", ds["anti_patterns"].lower())
def test_light_query_keeps_a_light_background(self):
ds = DesignSystemGenerator().generate("healthcare clinic booking app")
self.assertFalse(_palette_is_dark({"Background": ds["colors"]["background"]}))
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -3,7 +3,7 @@
"displayName": "Codex", "displayName": "Codex",
"installType": "full", "installType": "full",
"folderStructure": { "folderStructure": {
"root": ".codex", "root": ".agents",
"skillPath": "skills/ui-ux-pro-max", "skillPath": "skills/ui-ux-pro-max",
"filename": "SKILL.md" "filename": "SKILL.md"
}, },

View File

@ -4,13 +4,14 @@
"installType": "full", "installType": "full",
"folderStructure": { "folderStructure": {
"root": ".github", "root": ".github",
"skillPath": "prompts/ui-ux-pro-max", "skillPath": "prompts",
"filename": "PROMPT.md" "filename": "ui-ux-pro-max.prompt.md",
"dataPath": "prompts/ui-ux-pro-max"
}, },
"scriptPath": "prompts/ui-ux-pro-max/scripts/search.py", "scriptPath": "prompts/ui-ux-pro-max/scripts/search.py",
"frontmatter": { "frontmatter": {
"name": "ui-ux-pro-max", "description": "Comprehensive design guide for web, mobile, and desktop applications. Contains 67 styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 22 technology stacks.",
"description": "Comprehensive design guide for web, mobile, and desktop applications. Contains 67 styles, 161 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 22 technology stacks." "mode": "agent"
}, },
"sections": { "sections": {
"quickReference": false "quickReference": false

View File

@ -0,0 +1,21 @@
{
"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"
}