feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s

- Sidebar: dynamic brand-accent colors, brainstorm section restyled
- AI chat general: popup panel with expand/collapse, hides when contextual AI open
- AI chat contextual: tabs reordered (Actions first), X close button, height fix
- Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.)
- Global color cleanup: emerald/orange hardcoded → brand-accent dynamic
- Brainstorm page: orange → brand-accent throughout
- PageEntry animation component added to key pages
- Floating AI button: bg-brand-accent instead of hardcoded black
- i18n: all 15 locales updated with new AI/billing keys
- Billing: freemium quota tracking, BYOK, stripe subscription scaffolding
- Admin: integrated into new design
- AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
Antigravity
2026-05-16 12:59:30 +00:00
parent 1fcea6ed7d
commit bd495be965
2284 changed files with 395285 additions and 2327 deletions

View File

@@ -0,0 +1,224 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pytest>=7.0"]
# ///
"""Tests for validate-prompt.py"""
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
# Load the script as a module
SCRIPT_PATH = Path(__file__).parent.parent / "validate-prompt.py"
spec = importlib.util.spec_from_file_location("validate_prompt", SCRIPT_PATH)
validate_prompt = importlib.util.module_from_spec(spec)
spec.loader.exec_module(validate_prompt)
class TestValidateStylePrompt:
"""Tests for style prompt validation."""
def test_valid_prompt_passes(self):
"""A well-formed prompt under the limit should pass."""
prompt = "indie folk-rock, melancholic warmth, acoustic guitar over ambient pads, breathy male vocal, intimate lo-fi mix"
findings = validate_prompt.validate_style_prompt(prompt)
critical = [f for f in findings if f["severity"] == "critical"]
assert len(critical) == 0
def test_over_1000_chars_is_critical(self):
"""Prompts over 1,000 chars should produce a critical finding."""
prompt = "rock, " * 200 # ~1200 chars
findings = validate_prompt.validate_style_prompt(prompt)
critical = [f for f in findings if f["severity"] == "critical"]
assert len(critical) == 1
assert "1,000" in critical[0]["issue"]
def test_v4_pro_200_char_limit(self):
"""v4 Pro should have a 200-char limit, not 1,000."""
prompt = "rock, warm vocals, gentle acoustic guitar, melancholic mood, wide stereo field, intimate mix, layered harmonies, subtle percussion" * 2
assert len(prompt) > 200
assert len(prompt) < 1000
findings = validate_prompt.validate_style_prompt(prompt, model="v4 Pro")
critical = [f for f in findings if f["severity"] == "critical"]
assert len(critical) == 1
assert "200" in critical[0]["issue"]
assert "v4 Pro" in critical[0]["issue"]
def test_v5_pro_uses_1000_limit(self):
"""v5 Pro should use 1,000-char limit."""
prompt = "rock " + "x" * 300
findings = validate_prompt.validate_style_prompt(prompt, model="v5 Pro")
critical = [f for f in findings if f["severity"] == "critical"]
assert len(critical) == 0
def test_critical_zone_warning(self):
"""Prompts with substantial content beyond 200 chars should warn about critical zone."""
prompt = "rock, warm vocals, " + "x" * 350
findings = validate_prompt.validate_style_prompt(prompt)
zone_warnings = [f for f in findings if "critical zone" in f.get("issue", "").lower()]
assert len(zone_warnings) == 1
def test_near_limit_is_low(self):
"""Prompts at 90-100% of limit should produce a low finding."""
prompt = "x" * 950
# Add a genre keyword to avoid the front-loading warning
prompt = "rock " + "x" * 945
findings = validate_prompt.validate_style_prompt(prompt)
low = [f for f in findings if f["severity"] == "low" and "near" in f.get("issue", "").lower()]
assert len(low) == 1
def test_empty_prompt_is_critical(self):
"""Empty prompts should be critical."""
findings = validate_prompt.validate_style_prompt("")
critical = [f for f in findings if f["severity"] == "critical"]
assert len(critical) == 1
def test_whitespace_only_is_critical(self):
"""Whitespace-only prompts should be critical."""
findings = validate_prompt.validate_style_prompt(" \n ")
critical = [f for f in findings if f["severity"] == "critical"]
assert len(critical) == 1
def test_no_genre_frontloading_warning(self):
"""Prompts without genre in first 200 chars should warn."""
prompt = "warm and beautiful with layered textures and organic feel throughout the production"
findings = validate_prompt.validate_style_prompt(prompt)
medium = [f for f in findings if f["severity"] == "medium" and "genre" in f["issue"].lower()]
assert len(medium) == 1
def test_genre_present_no_warning(self):
"""Prompts with genre early should not warn about front-loading."""
prompt = "indie rock, melancholic, warm production"
findings = validate_prompt.validate_style_prompt(prompt)
genre_warnings = [f for f in findings if "genre" in f.get("issue", "").lower()]
assert len(genre_warnings) == 0
def test_lyric_metatags_detected(self):
"""Section tags in style prompts should be flagged."""
prompt = "indie rock [Verse] warm vocals [Chorus] big harmonies"
findings = validate_prompt.validate_style_prompt(prompt)
high = [f for f in findings if f["severity"] == "high"]
assert len(high) >= 1
assert "metatag" in high[0]["issue"].lower() or "lyric" in high[0]["issue"].lower()
def test_asterisks_detected(self):
"""Asterisks in style prompts should be flagged."""
prompt = "indie rock, *bold vocals*, warm production"
findings = validate_prompt.validate_style_prompt(prompt)
asterisk = [f for f in findings if "asterisk" in f["issue"].lower()]
assert len(asterisk) == 1
class TestValidateExclusionPrompt:
"""Tests for exclusion prompt validation."""
def test_empty_exclusion_is_info(self):
"""Empty exclusion prompts should produce an info finding (optional)."""
findings = validate_prompt.validate_exclusion_prompt("")
assert len(findings) == 1
assert findings[0]["severity"] == "info"
def test_valid_exclusion_passes(self):
"""A reasonable exclusion prompt should pass cleanly."""
findings = validate_prompt.validate_exclusion_prompt("no autotune, no screaming")
high_or_critical = [f for f in findings if f["severity"] in ("critical", "high")]
assert len(high_or_critical) == 0
def test_very_long_exclusion_is_high(self):
"""Exclusion prompts over 300 chars should produce a high finding."""
prompt = "no " + ", no ".join([f"thing{i}" for i in range(60)])
findings = validate_prompt.validate_exclusion_prompt(prompt)
high = [f for f in findings if f["severity"] == "high"]
assert len(high) >= 1
def test_too_many_items_warns(self):
"""More than 5 exclusion items should produce a medium warning."""
prompt = "no guitar, no piano, no drums, no bass, no synth, no vocals"
findings = validate_prompt.validate_exclusion_prompt(prompt)
medium = [f for f in findings if f["severity"] == "medium" and "many" in f["issue"].lower()]
assert len(medium) == 1
def test_vague_terms_caught(self):
"""Vague exclusion terms should be flagged."""
prompt = "no instruments, nothing bad"
findings = validate_prompt.validate_exclusion_prompt(prompt)
vague = [f for f in findings if "vague" in f["issue"].lower()]
assert len(vague) >= 1
class TestBuildReport:
"""Tests for report generation."""
def test_report_structure(self):
"""Report should have all required fields."""
report = validate_prompt.build_report([], [], "test", "", "/test/path")
assert report["script"] == "validate-prompt"
assert report["version"] == "1.1.0"
assert report["status"] == "pass"
assert "findings" in report
assert "summary" in report
assert "metrics" in report
def test_critical_finding_sets_fail(self):
"""Critical findings should set status to fail."""
findings = [{"severity": "critical", "category": "structure", "issue": "test", "fix": "test"}]
report = validate_prompt.build_report(findings, [], "test", "")
assert report["status"] == "fail"
def test_high_finding_sets_warning(self):
"""High findings (without critical) should set status to warning."""
findings = [{"severity": "high", "category": "structure", "issue": "test", "fix": "test"}]
report = validate_prompt.build_report(findings, [], "test", "")
assert report["status"] == "warning"
def test_metrics_include_char_counts(self):
"""Metrics should include character counts."""
report = validate_prompt.build_report([], [], "hello world", "no guitar")
assert report["metrics"]["style_prompt_chars"] == 11
assert report["metrics"]["exclusion_prompt_chars"] == 9
class TestCLI:
"""Tests for command-line interface."""
def test_help_flag(self):
"""--help should exit 0 with usage info."""
result = subprocess.run(
[sys.executable, str(SCRIPT_PATH), "--help"],
capture_output=True, text=True
)
assert result.returncode == 0
assert "validate" in result.stdout.lower()
def test_style_flag_produces_json(self):
"""--style should produce valid JSON output."""
result = subprocess.run(
[sys.executable, str(SCRIPT_PATH), "--style", "indie rock, warm vocals"],
capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["script"] == "validate-prompt"
assert "findings" in output
def test_model_flag_v4_pro(self):
"""--model 'v4 Pro' should apply 200-char limit."""
prompt = "rock, " * 40 # ~240 chars, over 200 but under 1000
result = subprocess.run(
[sys.executable, str(SCRIPT_PATH), "--style", prompt, "--model", "v4 Pro"],
capture_output=True, text=True
)
output = json.loads(result.stdout)
critical = [f for f in output["findings"] if f["severity"] == "critical"]
assert len(critical) >= 1
assert "200" in critical[0]["issue"]
def test_no_args_exits_2(self):
"""No arguments should exit with code 2."""
result = subprocess.run(
[sys.executable, str(SCRIPT_PATH)],
capture_output=True, text=True
)
assert result.returncode == 2

View File

@@ -0,0 +1,316 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
Validate Suno style prompt output for character limits and structure.
Validates:
- Style prompt character count (model-specific: v4 Pro=200, v4.5+/v5=1,000)
- Critical zone check (first 200 chars should contain all essentials)
- Exclusion prompt character count (recommended max ~200)
- Required fields present in prompt package
- Front-loading check (genre/mood should appear early)
Usage:
python validate-prompt.py <prompt-file-or-text> [options]
# Validate a prompt text directly
python validate-prompt.py --style "indie folk-rock, warm..." --exclude "no autotune"
# Validate with model-specific limits
python validate-prompt.py --style "indie folk-rock..." --model "v4 Pro"
# Validate from a file (expects YAML with style_prompt and exclusion_prompt fields)
python validate-prompt.py prompt-output.yaml
# Output to file
python validate-prompt.py --style "..." -o results.json
"""
import argparse
import json
import sys
import re
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
from suno_constants import STYLE_PROMPT_LIMITS, STYLE_PROMPT_DEFAULT_MAX, CRITICAL_ZONE, EXCLUSION_RECOMMENDED_MAX, EXCLUSION_HARD_MAX
SCRIPT_NAME = "validate-prompt"
VERSION = "1.1.0"
def get_limit_for_model(model: str) -> int:
"""Return the style prompt character limit for a given Suno model."""
return STYLE_PROMPT_LIMITS.get(model, STYLE_PROMPT_DEFAULT_MAX)
def validate_style_prompt(text: str, model: str = "") -> list[dict]:
"""Validate a style prompt and return findings."""
findings = []
char_count = len(text)
limit = get_limit_for_model(model) if model else STYLE_PROMPT_DEFAULT_MAX
# Character limit check (model-specific)
if char_count > limit:
findings.append({
"severity": "critical",
"category": "structure",
"issue": f"Style prompt exceeds {limit:,} character limit for {model or 'default'} ({char_count} chars). Suno will silently truncate.",
"fix": f"Trim {char_count - limit} characters. Cut from the end — genre/mood at the start are most important.",
"data": {"char_count": char_count, "limit": limit, "over_by": char_count - limit, "model": model}
})
elif char_count > limit * 0.9:
findings.append({
"severity": "low",
"category": "structure",
"issue": f"Style prompt is near the {limit:,} character limit ({char_count} chars). Limited room for iteration.",
"fix": "Consider trimming less essential descriptors to leave room for refinement.",
"data": {"char_count": char_count, "limit": limit}
})
# Critical zone check — first 200 chars have strongest influence
if char_count > CRITICAL_ZONE:
first_segment = text[:CRITICAL_ZONE]
remaining = text[CRITICAL_ZONE:]
# Warn if substantial content exists beyond the critical zone
if len(remaining.strip()) > 100:
findings.append({
"severity": "low",
"category": "consistency",
"issue": f"Style prompt has {len(remaining.strip())} chars beyond the critical zone (first {CRITICAL_ZONE} chars). Front-loaded terms have strongest influence on generation. Content beyond ~200 chars is supplementary but not wasted — v5.5 may interpret more of the prompt effectively.",
"fix": "Ensure essential genre, mood, and vocal descriptors appear within the first 200 characters. Content beyond this zone adds nuance. This is a priority guide, not a character limit.",
"data": {"critical_zone": CRITICAL_ZONE, "beyond_zone_chars": len(remaining.strip())}
})
# Empty check
if not text.strip():
findings.append({
"severity": "critical",
"category": "structure",
"issue": "Style prompt is empty.",
"fix": "Provide at minimum a genre and mood description."
})
return findings
# Front-loading check — genre/mood keywords should appear in first 200 chars
first_segment = text[:200].lower()
genre_signals = ["rock", "pop", "folk", "jazz", "blues", "electronic", "hip hop", "r&b",
"country", "classical", "metal", "punk", "indie", "soul", "funk",
"ambient", "lo-fi", "lofi", "dance", "edm", "house", "techno",
"rap", "acoustic", "orchestral", "cinematic", "reggae", "latin",
"alternative", "grunge", "shoegaze", "post-punk", "synth", "disco"]
has_genre = any(g in first_segment for g in genre_signals)
if not has_genre:
findings.append({
"severity": "medium",
"category": "consistency",
"issue": "No obvious genre keyword found in the first 200 characters. Genre should be front-loaded.",
"fix": "Move genre and mood descriptors to the beginning of the style prompt."
})
# Style cue contamination check (things that belong in lyrics, not style prompt)
style_contamination = re.findall(r'\[(?:Verse|Chorus|Bridge|Intro|Outro|Pre-Chorus)\]', text, re.IGNORECASE)
if style_contamination:
findings.append({
"severity": "high",
"category": "structure",
"issue": f"Lyric metatags found in style prompt: {style_contamination}. These belong in lyrics, not the style prompt.",
"fix": "Remove all section tags ([Verse], [Chorus], etc.) from the style prompt. These go in the lyrics input."
})
# Asterisk check
if '*' in text:
findings.append({
"severity": "medium",
"category": "structure",
"issue": "Asterisks found in style prompt. Suno does not use markdown formatting in style prompts.",
"fix": "Remove all asterisks from the style prompt."
})
return findings
def validate_exclusion_prompt(text: str) -> list[dict]:
"""Validate an exclusion prompt and return findings."""
findings = []
if not text.strip():
findings.append({
"severity": "info",
"category": "structure",
"issue": "No exclusion prompt provided. This is optional but can improve results.",
"fix": "Consider adding 2-3 specific exclusions to prevent unwanted elements."
})
return findings
char_count = len(text)
if char_count > EXCLUSION_HARD_MAX:
findings.append({
"severity": "high",
"category": "structure",
"issue": f"Exclusion prompt is very long ({char_count} chars). Too many negatives can confuse the model.",
"fix": "Trim to 2-3 most important exclusions. Prioritize the elements you most want to avoid.",
"data": {"char_count": char_count, "recommended_max": EXCLUSION_RECOMMENDED_MAX}
})
elif char_count > EXCLUSION_RECOMMENDED_MAX:
findings.append({
"severity": "low",
"category": "structure",
"issue": f"Exclusion prompt is above recommended length ({char_count} chars, recommended ~{EXCLUSION_RECOMMENDED_MAX}).",
"fix": "Consider trimming to the most impactful exclusions.",
"data": {"char_count": char_count, "recommended_max": EXCLUSION_RECOMMENDED_MAX}
})
# Count exclusion items
items = [i.strip() for i in re.split(r'[,;]', text) if i.strip()]
if len(items) > 5:
findings.append({
"severity": "medium",
"category": "consistency",
"issue": f"Too many exclusion items ({len(items)}). More than 3-5 exclusions can confuse the model.",
"fix": "Reduce to 2-3 most critical exclusions."
})
# Vagueness check
vague_terms = ["no music", "no sound", "no instruments", "no singing", "nothing bad"]
for term in vague_terms:
if term.lower() in text.lower():
findings.append({
"severity": "medium",
"category": "consistency",
"issue": f"Vague exclusion term found: '{term}'. Be specific about what to exclude.",
"fix": "Replace with specific terms: 'no electric guitar' instead of 'no instruments'."
})
return findings
def build_report(style_findings: list, exclusion_findings: list, style_text: str, exclusion_text: str, skill_path: str = "") -> dict:
"""Build the standard output report."""
all_findings = []
for f in style_findings:
f["location"] = {"field": "style_prompt"}
all_findings.append(f)
for f in exclusion_findings:
f["location"] = {"field": "exclusion_prompt"}
all_findings.append(f)
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
for f in all_findings:
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
status = "pass"
if severity_counts["critical"] > 0:
status = "fail"
elif severity_counts["high"] > 0:
status = "warning"
return {
"script": SCRIPT_NAME,
"version": VERSION,
"skill_path": skill_path,
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": status,
"metrics": {
"style_prompt_chars": len(style_text),
"style_prompt_limit": STYLE_PROMPT_DEFAULT_MAX,
"critical_zone": CRITICAL_ZONE,
"exclusion_prompt_chars": len(exclusion_text) if exclusion_text else 0,
"exclusion_recommended_max": EXCLUSION_RECOMMENDED_MAX
},
"findings": all_findings,
"summary": {
"total": len(all_findings),
**severity_counts
}
}
def main():
parser = argparse.ArgumentParser(
description="Validate Suno style prompt output for character limits and structure.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --style "indie folk-rock, warm analog..." --exclude "no autotune"
%(prog)s prompt-output.yaml
%(prog)s --style "..." -o results.json --verbose
"""
)
parser.add_argument("file", nargs="?", help="YAML file with style_prompt and exclusion_prompt fields")
parser.add_argument("--style", help="Style prompt text to validate")
parser.add_argument("--exclude", default="", help="Exclusion prompt text to validate")
parser.add_argument("--model", default="", help="Suno model name for model-specific limits (e.g., 'v4 Pro', 'v5 Pro')")
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
parser.add_argument("--verbose", action="store_true", help="Include debug information")
parser.add_argument("--skill-path", default="", help="Skill path for report context")
args = parser.parse_args()
style_text = ""
exclusion_text = ""
if args.file:
# Read from YAML file
file_path = Path(args.file)
if not file_path.exists():
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(2)
try:
import yaml
except ImportError:
# Fallback: simple key-value parsing for basic YAML
content = file_path.read_text()
for line in content.splitlines():
if line.startswith("style_prompt:"):
style_text = line.split(":", 1)[1].strip().strip('"').strip("'")
elif line.startswith("exclusion_prompt:"):
exclusion_text = line.split(":", 1)[1].strip().strip('"').strip("'")
else:
data = yaml.safe_load(file_path.read_text())
style_text = data.get("style_prompt", "")
exclusion_text = data.get("exclusion_prompt", "")
elif args.style:
style_text = args.style
exclusion_text = args.exclude
else:
parser.print_help()
sys.exit(2)
if args.verbose:
print(f"Validating style prompt ({len(style_text)} chars)...", file=sys.stderr)
if exclusion_text:
print(f"Validating exclusion prompt ({len(exclusion_text)} chars)...", file=sys.stderr)
model = args.model
if not model and args.file:
# Try to extract model from YAML file
try:
if 'data' in dir() and isinstance(data, dict):
model = data.get("model", "")
except Exception:
pass
style_findings = validate_style_prompt(style_text, model=model)
exclusion_findings = validate_exclusion_prompt(exclusion_text)
report = build_report(style_findings, exclusion_findings, style_text, exclusion_text, args.skill_path)
output_json = json.dumps(report, indent=2)
if args.output:
Path(args.output).write_text(output_json)
if args.verbose:
print(f"Report written to {args.output}", file=sys.stderr)
else:
print(output_json)
sys.exit(0 if report["status"] == "pass" else 1)
if __name__ == "__main__":
main()