diff --git a/docs/UIUX_BRANDING_HANDOFF.md b/docs/UIUX_BRANDING_HANDOFF.md index 83a17d8..524cfb8 100644 --- a/docs/UIUX_BRANDING_HANDOFF.md +++ b/docs/UIUX_BRANDING_HANDOFF.md @@ -80,16 +80,17 @@ Flags: ### Questionary prompt flows -Interactive mode uses `questionary` with consistent markers: `qmark="?"`, `pointer=">"`. +Interactive mode uses `questionary` with `CPA_PROMPT_STYLE` (high-contrast blue/green), +`qmark="?"`, and `pointer="❯"`. -#### 1. Template selection (autocomplete) +#### 1. Template selection (select + search filter) When no `--template` is provided and interactive mode is on: -- Prompt: `Pick a template (type to search)` -- Control: `questionary.autocomplete` with `match_middle=True` -- Choices: built from `build_template_choices()` in `catalog.py` -- Each choice shows a category badge, template name, slug, optional labels, and description +- Prompt: `Pick a template` +- Control: `questionary.select` with `use_search_filter=True` (↑↓ browse, type to filter) +- Choices: `Choice(title, value)` from `build_template_choices()` in `catalog.py` +- Each choice shows a colored category badge, bold name, slug, optional labels, and description - Final choice: `Use my own template URL` -> `questionary.text` for a custom URL #### 2. Extension selection (checkbox, two-step) @@ -110,17 +111,15 @@ From `cpa.config.json` or catalog `customOptions`: ### Category badges -Interactive template choices use a plain fixed-width badge from -`short_category_label()` (strips "Applications", "Application", "Boilerplate"; -abbreviates long names to initials). Titles stay plain text because -`questionary.autocomplete` wraps choices in HTML for match highlighting — ANSI -or other markup raises XML parse errors in prompt_toolkit. +Interactive template choices use a fixed-width badge from `short_category_label()` +with bright bold ANSI colors (`prompt_style.color_category`) so they stay readable +on dark terminals. Titles may include ANSI because the picker is +`questionary.select(..., use_search_filter=True)` — **not** autocomplete (which +HTML-parses choice text and breaks on ANSI). -`--list-templates` uses Rich tables for color, not ANSI in choice strings. +Respects `NO_COLOR`. `--list-templates` uses Rich tables for color. -Design implication: if terminal category color returns, prefer Rich styling or -a prompt library that does not HTML-parse choice titles (CNA uses `prompts` + -picocolors). +UX: ↑↓ browse the full catalog, type to filter, Enter to pick (CNA-parity discovery). ### Rich semantic color usage @@ -171,7 +170,7 @@ Completed in `create-python-app`: - Package README at `packages/create-awesome-python-app/README.md` with minimal hero SVG reference. - Placeholder hero SVG at `packages/create-awesome-python-app/assets/hero.svg` (dark background, teal text). - Working brand notes in `docs/BRAND.md` (tagline and story). -- Full interactive CLI with CNA-parity flows (autocomplete templates, checkbox extensions, custom options). +- Full interactive CLI with CNA-parity flows (select+filter templates, checkbox extensions, custom options). - Rich stderr output and semantic coloring across scaffold and cache commands. - Catalog integration with `cpa-templates` default URL and `CPA_CATALOG_URL` override. @@ -191,7 +190,7 @@ The CLI experience is functional and CNA-aligned for catalog flows, but the broa Current CLI aesthetic: - Rich semantic colors (red/yellow/green/cyan/dim). -- Plain fixed-width category badges in autocomplete titles (HTML-safe for questionary). +- Bright category badges + high-contrast `CPA_PROMPT_STYLE` on select/checkbox prompts. - Minimal hero SVG (slate + teal). - No startup banner or branded prompt chrome beyond questionary defaults. @@ -214,7 +213,7 @@ The user wants a complete review and improvement cycle for: - Visual consistency between GitHub, PyPI, docs, CLI, and templates. - Engagement and adoption. - A more cozy, attractive, polished, memorable brand. -- CLI prompt flow polish (autocomplete, checkbox, error tone, banner). +- CLI prompt flow polish (select+filter, checkbox, error tone, banner). The user explicitly wants the new chat to review everything, not only CLI internals or README tweaks. @@ -289,7 +288,7 @@ Before implementation, produce a complete audit answering: - What is the current brand personality? - What should the brand personality become? - Does the CLI first run explain the product clearly in the first 5 seconds? -- Do autocomplete and checkbox flows feel premium and discoverable? +- Do select+filter and checkbox flows feel premium and discoverable? - Should website/docs category badges use semantic colors (CLI titles stay plain for questionary)? - Does the PyPI package README convert visitors into users? - Does the root GitHub README convert visitors into contributors? @@ -363,7 +362,7 @@ Visual identity: - Define illustration/hero style. - Define voice and tone (match CLI error copy guidelines). - Define how "cozy" and "developer infrastructure" coexist. -- Define category badge colors for website/docs cards (CLI autocomplete titles stay plain text). +- Define category badge colors for website/docs cards (CLI already uses bright ANSI badges). ## Constraints And Standards @@ -420,9 +419,9 @@ We need to do a full UI/UX and branding review of the Create Python App ecosyste Please start with discovery and audit before implementing. Review the root create-python-app repo, the package README, cpa-templates, and docs/BRAND.md. The goal is to improve engagement, attraction, branding, cozy developer experience, visual consistency, and conversion across GitHub, PyPI, docs, CLI, and generated starter UIs. -The CLI already uses Rich on stderr and questionary autocomplete/checkbox flows -with plain-text category badges (HTML-safe for prompt_toolkit). Evaluate whether -those defaults should evolve into a cohesive brand system. Pay attention to error +The CLI already uses Rich on stderr and questionary select/checkbox flows with +`CPA_PROMPT_STYLE` plus bright ANSI category badges. Evaluate whether those +defaults should evolve into a cohesive brand system. Pay attention to error message tone, interactive vs CI non-interactive behavior, and the minimal hero SVG. Previous work established basic READMEs and BRAND.md notes, but now I want a broader review and a stronger cohesive brand direction. Do not assume the current teal-on-slate hero or terminal colors are final. diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py b/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py index 4d52312..ad0169e 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py @@ -16,6 +16,7 @@ from rich.table import Table from create_awesome_python_app import __version__ +from create_awesome_python_app.prompt_style import bold, color_category, dim console = Console(stderr=True) @@ -238,14 +239,17 @@ def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]: labels = template.get("labels", []) label_suffix = "" if isinstance(labels, list) and labels: - label_suffix = " · " + ", ".join(str(label) for label in labels[:3]) + label_suffix = dim(" · " + ", ".join(str(label) for label in labels[:3])) description = str(template.get("description", "")).strip() - description_suffix = f" — {description}" if description else "" - # Plain text only: questionary.autocomplete wraps choices in HTML for - # match highlighting, so ANSI / markup here raises XML parse errors. + # Keep slug + short description in the title so select(use_search_filter) + # can match them (filter scans Choice.title only). + description_suffix = dim(f" — {description}") if description else "" + name = str(template.get("name", slug)) + # ANSI is OK here: questionary.select renders titles as terminal text. + # Do not pass these titles to autocomplete (HTML match highlighting). title = ( - f"{badge} " - f"{template.get('name', slug)} ({slug})" + f"{color_category(category_slug, badge)} " + f"{bold(name)} ({slug})" f"{label_suffix}{description_suffix}" ) choices.append( @@ -258,7 +262,7 @@ def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]: choices.append( TemplateChoice( - title=" " * 12 + "Use my own template URL", + title=" " * 12 + dim("Use my own template URL"), value=CUSTOM_TEMPLATE_SENTINEL, search="custom own template url github file", ) diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py index 1bc6e12..bdc5204 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py @@ -104,6 +104,8 @@ def _prompt_custom_options( ) -> dict[str, str]: import questionary + from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE + source = resolve_source(template, cache_dir=cache_dir) root = download_repository( source, @@ -138,9 +140,12 @@ def _prompt_custom_options( answer = questionary.confirm( message, default=initial.lower() in {"1", "true", "yes", "on"}, + style=CPA_PROMPT_STYLE, ).ask() else: - answer = questionary.text(message, default=initial).ask() + answer = questionary.text( + message, default=initial, style=CPA_PROMPT_STYLE + ).ask() if answer is None: raise typer.Exit(1) answers[option.key] = _stringify_option_value(answer) @@ -230,30 +235,38 @@ def scaffold( if want_interactive and not template: try: import questionary + from questionary import Choice from create_awesome_python_app.catalog import ( CUSTOM_TEMPLATE_SENTINEL, build_template_choices, get_catalog_data, ) + from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE interactive_catalog = get_catalog_data() template_choices = build_template_choices(interactive_catalog) - choice_by_title = { - choice.title: choice.value for choice in template_choices - } - selected_title = questionary.autocomplete( - "Pick a template (type to search)", - choices=list(choice_by_title), - match_middle=True, + # select + type-to-filter: browseable list (CNA-style discovery) + # instead of autocomplete-only. use_jk_keys must be False with search. + selected_template = questionary.select( + "Pick a template", + choices=[ + Choice(title=choice.title, value=choice.value) + for choice in template_choices + ], qmark="?", + pointer="❯", + style=CPA_PROMPT_STYLE, + use_search_filter=True, + use_jk_keys=False, + instruction="(↑↓ browse · type to filter · Enter)", ).ask() - selected_template = choice_by_title.get(str(selected_title), selected_title) if selected_template == CUSTOM_TEMPLATE_SENTINEL: selected_template = questionary.text( "Template URL", default="file://.", validate=lambda value: bool(value) or "Template URL is required", + style=CPA_PROMPT_STYLE, ).ask() template = selected_template if not template: @@ -296,6 +309,7 @@ def scaffold( get_catalog_data, group_extension_choices, ) + from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE interactive_catalog = interactive_catalog or get_catalog_data() extension_choices = build_extension_choices(interactive_catalog, template) @@ -316,7 +330,8 @@ def scaffold( "Which kinds of extensions do you need?", choices=category_choices, qmark="?", - pointer=">", + pointer="❯", + style=CPA_PROMPT_STYLE, ).ask() selected_addons: list[str] = [] for category_slug in selected_categories or []: @@ -330,7 +345,8 @@ def scaffold( for choice in choices ], qmark="?", - pointer=">", + pointer="❯", + style=CPA_PROMPT_STYLE, ).ask() selected_addons.extend(str(item) for item in picked or []) addons = selected_addons diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/prompt_style.py b/packages/create-awesome-python-app/src/create_awesome_python_app/prompt_style.py new file mode 100644 index 0000000..c3806a1 --- /dev/null +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/prompt_style.py @@ -0,0 +1,61 @@ +"""High-contrast questionary styles for CPA interactive prompts.""" + +from __future__ import annotations + +import os + +from questionary import Style + +# Lighter brand blues/greens than docs hex so selected rows stay readable on +# dark terminals (slate backgrounds common in Arch/Ghostty/Alacritty). +CPA_PROMPT_STYLE = Style.from_dict( + { + "qmark": "fg:#60a5fa bold", + "question": "bold fg:#f8fafc", + "answer": "fg:#4ade80 bold", + "pointer": "fg:#60a5fa bold", + "highlighted": "fg:#0f172a bg:#60a5fa bold", + "selected": "fg:#4ade80 bold", + "separator": "fg:#94a3b8", + "instruction": "fg:#94a3b8", + "text": "fg:#e2e8f0", + "disabled": "fg:#64748b italic", + "checkbox": "fg:#60a5fa", + "checkbox-selected": "fg:#4ade80 bold", + } +) + + +def colors_enabled() -> bool: + return not os.environ.get("NO_COLOR") + + +def ansi(code: str, text: str) -> str: + """Wrap *text* in an ANSI SGR sequence when colors are enabled.""" + if not colors_enabled(): + return text + return f"\033[{code}m{text}\033[0m" + + +# Bold bright ANSI — readable on dark terminals; select() renders these safely +# (unlike autocomplete, which HTML-parses choice text). +_CATEGORY_PALETTE = ( + "1;93", # bright yellow + "1;92", # bright green + "1;96", # bright cyan + "1;95", # bright magenta + "1;94", # bright blue +) + + +def color_category(slug: str, label: str) -> str: + idx = sum(ord(char) for char in slug) % len(_CATEGORY_PALETTE) + return ansi(_CATEGORY_PALETTE[idx], label) + + +def bold(text: str) -> str: + return ansi("1", text) + + +def dim(text: str) -> str: + return ansi("2", text) diff --git a/packages/create-awesome-python-app/tests/test_catalog_resolve.py b/packages/create-awesome-python-app/tests/test_catalog_resolve.py index 474f7f4..f9e73d6 100644 --- a/packages/create-awesome-python-app/tests/test_catalog_resolve.py +++ b/packages/create-awesome-python-app/tests/test_catalog_resolve.py @@ -102,17 +102,37 @@ def test_build_template_choices_are_searchable() -> None: assert "FastAPI Starter" in first.title assert "OpenAPI" in first.title assert "uv" in first.title - assert "\033" not in first.title assert "openapi" in first.search assert "backend" in first.search assert "uv" in first.search assert choices[-1].value == CUSTOM_TEMPLATE_SENTINEL -def test_template_choice_titles_are_html_safe_for_questionary() -> None: - """questionary.autocomplete formats choice text as HTML (match underline).""" - from prompt_toolkit.formatted_text import HTML +def test_template_choice_titles_include_bright_category_ansi( + monkeypatch, +) -> None: + """select() can render ANSI; badges use bright bold codes for contrast.""" + monkeypatch.delenv("NO_COLOR", raising=False) + catalog = { + "categories": [ + {"slug": "backend-applications", "name": "Backend Applications"} + ], + "templates": [ + { + "slug": "fastapi-starter", + "name": "FastAPI Starter", + "url": "file:///templates/fastapi", + "category": "backend-applications", + } + ], + } + title = build_template_choices(catalog)[0].title + assert "\033[" in title + assert "FastAPI Starter" in title + +def test_template_choice_titles_respect_no_color(monkeypatch) -> None: + monkeypatch.setenv("NO_COLOR", "1") catalog = { "categories": [ {"slug": "backend-applications", "name": "Backend Applications"} @@ -121,16 +141,14 @@ def test_template_choice_titles_are_html_safe_for_questionary() -> None: { "slug": "fastapi-starter", "name": "FastAPI Starter", - "description": "Async API with OpenAPI docs", "url": "file:///templates/fastapi", "category": "backend-applications", - "labels": ["FastAPI"], } ], } title = build_template_choices(catalog)[0].title - # Must not raise "not well-formed (invalid token)" from ANSI escapes. - HTML("{}{}{}").format(title[:3], title[3:6], title[6:]) + assert "\033" not in title + assert "FastAPI Starter" in title def test_build_extension_choices_filters_by_template_type() -> None: diff --git a/packages/create-awesome-python-app/tests/test_interactive.py b/packages/create-awesome-python-app/tests/test_interactive.py index 7b10fd9..f945a97 100644 --- a/packages/create-awesome-python-app/tests/test_interactive.py +++ b/packages/create-awesome-python-app/tests/test_interactive.py @@ -28,10 +28,10 @@ def test_in_ci_env(monkeypatch) -> None: os.environ.pop("CI", None) -def test_interactive_template_autocomplete_omits_pointer( +def test_interactive_template_select_uses_search_filter( tmp_path: Path, monkeypatch ) -> None: - """questionary.autocomplete rejects ``pointer`` (PromptSession TypeError).""" + """Template pick is a browsable select with type-to-filter (not autocomplete).""" template_dir = tmp_path / "fastapi" template_dir.mkdir() (template_dir / "cpa.config.json").write_text( @@ -60,11 +60,9 @@ def test_interactive_template_autocomplete_omits_pointer( captured_kwargs: dict[str, object] = {} - def fake_autocomplete(*_args, **kwargs): + def fake_select(*_args, **kwargs): captured_kwargs.update(kwargs) - choices = kwargs.get("choices") or [] - # Return a mapped title so choice_by_title resolves to the template URL. - return FakePrompt(choices[0] if choices else template_url) + return FakePrompt(template_url) def fake_checkbox(*_args, **_kwargs): return FakePrompt([]) @@ -75,7 +73,7 @@ async def fake_create_python_app(project_directory, options, *_args, **_kwargs): captured["project_directory"] = project_directory captured["options"] = options - monkeypatch.setattr("questionary.autocomplete", fake_autocomplete) + monkeypatch.setattr("questionary.select", fake_select) monkeypatch.setattr("questionary.checkbox", fake_checkbox) monkeypatch.setattr( "create_awesome_python_app.cli.create_python_app", @@ -89,7 +87,9 @@ async def fake_create_python_app(project_directory, options, *_args, **_kwargs): result = runner.invoke(app, ["--interactive", "--no-install", "api"]) assert result.exit_code == 0, result.stdout + result.stderr - assert "pointer" not in captured_kwargs + assert captured_kwargs.get("use_search_filter") is True + assert captured_kwargs.get("use_jk_keys") is False + assert captured_kwargs.get("style") is not None assert captured["project_directory"] == "api"