Skip to content

Commit a5aa209

Browse files
feat: browsable template select with high-contrast CPA style (#209)
Replace autocomplete with select+search-filter for CNA-style discovery (↑↓ browse, type to filter). Restore bright category badges and apply a readable blue/green questionary theme across interactive prompts. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 127ada6 commit a5aa209

6 files changed

Lines changed: 155 additions & 57 deletions

File tree

docs/UIUX_BRANDING_HANDOFF.md

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -80,16 +80,17 @@ Flags:
8080

8181
### Questionary prompt flows
8282

83-
Interactive mode uses `questionary` with consistent markers: `qmark="?"`, `pointer=">"`.
83+
Interactive mode uses `questionary` with `CPA_PROMPT_STYLE` (high-contrast blue/green),
84+
`qmark="?"`, and `pointer="❯"`.
8485

85-
#### 1. Template selection (autocomplete)
86+
#### 1. Template selection (select + search filter)
8687

8788
When no `--template` is provided and interactive mode is on:
8889

89-
- Prompt: `Pick a template (type to search)`
90-
- Control: `questionary.autocomplete` with `match_middle=True`
91-
- Choices: built from `build_template_choices()` in `catalog.py`
92-
- Each choice shows a category badge, template name, slug, optional labels, and description
90+
- Prompt: `Pick a template`
91+
- Control: `questionary.select` with `use_search_filter=True` (↑↓ browse, type to filter)
92+
- Choices: `Choice(title, value)` from `build_template_choices()` in `catalog.py`
93+
- Each choice shows a colored category badge, bold name, slug, optional labels, and description
9394
- Final choice: `Use my own template URL` -> `questionary.text` for a custom URL
9495

9596
#### 2. Extension selection (checkbox, two-step)
@@ -110,17 +111,15 @@ From `cpa.config.json` or catalog `customOptions`:
110111

111112
### Category badges
112113

113-
Interactive template choices use a plain fixed-width badge from
114-
`short_category_label()` (strips "Applications", "Application", "Boilerplate";
115-
abbreviates long names to initials). Titles stay plain text because
116-
`questionary.autocomplete` wraps choices in HTML for match highlighting — ANSI
117-
or other markup raises XML parse errors in prompt_toolkit.
114+
Interactive template choices use a fixed-width badge from `short_category_label()`
115+
with bright bold ANSI colors (`prompt_style.color_category`) so they stay readable
116+
on dark terminals. Titles may include ANSI because the picker is
117+
`questionary.select(..., use_search_filter=True)`**not** autocomplete (which
118+
HTML-parses choice text and breaks on ANSI).
118119

119-
`--list-templates` uses Rich tables for color, not ANSI in choice strings.
120+
Respects `NO_COLOR`. `--list-templates` uses Rich tables for color.
120121

121-
Design implication: if terminal category color returns, prefer Rich styling or
122-
a prompt library that does not HTML-parse choice titles (CNA uses `prompts` +
123-
picocolors).
122+
UX: ↑↓ browse the full catalog, type to filter, Enter to pick (CNA-parity discovery).
124123

125124
### Rich semantic color usage
126125

@@ -171,7 +170,7 @@ Completed in `create-python-app`:
171170
- Package README at `packages/create-awesome-python-app/README.md` with minimal hero SVG reference.
172171
- Placeholder hero SVG at `packages/create-awesome-python-app/assets/hero.svg` (dark background, teal text).
173172
- Working brand notes in `docs/BRAND.md` (tagline and story).
174-
- Full interactive CLI with CNA-parity flows (autocomplete templates, checkbox extensions, custom options).
173+
- Full interactive CLI with CNA-parity flows (select+filter templates, checkbox extensions, custom options).
175174
- Rich stderr output and semantic coloring across scaffold and cache commands.
176175
- Catalog integration with `cpa-templates` default URL and `CPA_CATALOG_URL` override.
177176

@@ -191,7 +190,7 @@ The CLI experience is functional and CNA-aligned for catalog flows, but the broa
191190
Current CLI aesthetic:
192191

193192
- Rich semantic colors (red/yellow/green/cyan/dim).
194-
- Plain fixed-width category badges in autocomplete titles (HTML-safe for questionary).
193+
- Bright category badges + high-contrast `CPA_PROMPT_STYLE` on select/checkbox prompts.
195194
- Minimal hero SVG (slate + teal).
196195
- No startup banner or branded prompt chrome beyond questionary defaults.
197196

@@ -214,7 +213,7 @@ The user wants a complete review and improvement cycle for:
214213
- Visual consistency between GitHub, PyPI, docs, CLI, and templates.
215214
- Engagement and adoption.
216215
- A more cozy, attractive, polished, memorable brand.
217-
- CLI prompt flow polish (autocomplete, checkbox, error tone, banner).
216+
- CLI prompt flow polish (select+filter, checkbox, error tone, banner).
218217

219218
The user explicitly wants the new chat to review everything, not only CLI internals or README tweaks.
220219

@@ -289,7 +288,7 @@ Before implementation, produce a complete audit answering:
289288
- What is the current brand personality?
290289
- What should the brand personality become?
291290
- Does the CLI first run explain the product clearly in the first 5 seconds?
292-
- Do autocomplete and checkbox flows feel premium and discoverable?
291+
- Do select+filter and checkbox flows feel premium and discoverable?
293292
- Should website/docs category badges use semantic colors (CLI titles stay plain for questionary)?
294293
- Does the PyPI package README convert visitors into users?
295294
- Does the root GitHub README convert visitors into contributors?
@@ -363,7 +362,7 @@ Visual identity:
363362
- Define illustration/hero style.
364363
- Define voice and tone (match CLI error copy guidelines).
365364
- Define how "cozy" and "developer infrastructure" coexist.
366-
- Define category badge colors for website/docs cards (CLI autocomplete titles stay plain text).
365+
- Define category badge colors for website/docs cards (CLI already uses bright ANSI badges).
367366

368367
## Constraints And Standards
369368

@@ -420,9 +419,9 @@ We need to do a full UI/UX and branding review of the Create Python App ecosyste
420419
421420
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.
422421
423-
The CLI already uses Rich on stderr and questionary autocomplete/checkbox flows
424-
with plain-text category badges (HTML-safe for prompt_toolkit). Evaluate whether
425-
those defaults should evolve into a cohesive brand system. Pay attention to error
422+
The CLI already uses Rich on stderr and questionary select/checkbox flows with
423+
`CPA_PROMPT_STYLE` plus bright ANSI category badges. Evaluate whether those
424+
defaults should evolve into a cohesive brand system. Pay attention to error
426425
message tone, interactive vs CI non-interactive behavior, and the minimal hero SVG.
427426
428427
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.

packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from rich.table import Table
1717

1818
from create_awesome_python_app import __version__
19+
from create_awesome_python_app.prompt_style import bold, color_category, dim
1920

2021
console = Console(stderr=True)
2122

@@ -238,14 +239,17 @@ def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]:
238239
labels = template.get("labels", [])
239240
label_suffix = ""
240241
if isinstance(labels, list) and labels:
241-
label_suffix = " · " + ", ".join(str(label) for label in labels[:3])
242+
label_suffix = dim(" · " + ", ".join(str(label) for label in labels[:3]))
242243
description = str(template.get("description", "")).strip()
243-
description_suffix = f" — {description}" if description else ""
244-
# Plain text only: questionary.autocomplete wraps choices in HTML for
245-
# match highlighting, so ANSI / markup here raises XML parse errors.
244+
# Keep slug + short description in the title so select(use_search_filter)
245+
# can match them (filter scans Choice.title only).
246+
description_suffix = dim(f" — {description}") if description else ""
247+
name = str(template.get("name", slug))
248+
# ANSI is OK here: questionary.select renders titles as terminal text.
249+
# Do not pass these titles to autocomplete (HTML match highlighting).
246250
title = (
247-
f"{badge} "
248-
f"{template.get('name', slug)} ({slug})"
251+
f"{color_category(category_slug, badge)} "
252+
f"{bold(name)} ({slug})"
249253
f"{label_suffix}{description_suffix}"
250254
)
251255
choices.append(
@@ -258,7 +262,7 @@ def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]:
258262

259263
choices.append(
260264
TemplateChoice(
261-
title=" " * 12 + "Use my own template URL",
265+
title=" " * 12 + dim("Use my own template URL"),
262266
value=CUSTOM_TEMPLATE_SENTINEL,
263267
search="custom own template url github file",
264268
)

packages/create-awesome-python-app/src/create_awesome_python_app/cli.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ def _prompt_custom_options(
104104
) -> dict[str, str]:
105105
import questionary
106106

107+
from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE
108+
107109
source = resolve_source(template, cache_dir=cache_dir)
108110
root = download_repository(
109111
source,
@@ -138,9 +140,12 @@ def _prompt_custom_options(
138140
answer = questionary.confirm(
139141
message,
140142
default=initial.lower() in {"1", "true", "yes", "on"},
143+
style=CPA_PROMPT_STYLE,
141144
).ask()
142145
else:
143-
answer = questionary.text(message, default=initial).ask()
146+
answer = questionary.text(
147+
message, default=initial, style=CPA_PROMPT_STYLE
148+
).ask()
144149
if answer is None:
145150
raise typer.Exit(1)
146151
answers[option.key] = _stringify_option_value(answer)
@@ -230,30 +235,38 @@ def scaffold(
230235
if want_interactive and not template:
231236
try:
232237
import questionary
238+
from questionary import Choice
233239

234240
from create_awesome_python_app.catalog import (
235241
CUSTOM_TEMPLATE_SENTINEL,
236242
build_template_choices,
237243
get_catalog_data,
238244
)
245+
from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE
239246

240247
interactive_catalog = get_catalog_data()
241248
template_choices = build_template_choices(interactive_catalog)
242-
choice_by_title = {
243-
choice.title: choice.value for choice in template_choices
244-
}
245-
selected_title = questionary.autocomplete(
246-
"Pick a template (type to search)",
247-
choices=list(choice_by_title),
248-
match_middle=True,
249+
# select + type-to-filter: browseable list (CNA-style discovery)
250+
# instead of autocomplete-only. use_jk_keys must be False with search.
251+
selected_template = questionary.select(
252+
"Pick a template",
253+
choices=[
254+
Choice(title=choice.title, value=choice.value)
255+
for choice in template_choices
256+
],
249257
qmark="?",
258+
pointer="❯",
259+
style=CPA_PROMPT_STYLE,
260+
use_search_filter=True,
261+
use_jk_keys=False,
262+
instruction="(↑↓ browse · type to filter · Enter)",
250263
).ask()
251-
selected_template = choice_by_title.get(str(selected_title), selected_title)
252264
if selected_template == CUSTOM_TEMPLATE_SENTINEL:
253265
selected_template = questionary.text(
254266
"Template URL",
255267
default="file://.",
256268
validate=lambda value: bool(value) or "Template URL is required",
269+
style=CPA_PROMPT_STYLE,
257270
).ask()
258271
template = selected_template
259272
if not template:
@@ -296,6 +309,7 @@ def scaffold(
296309
get_catalog_data,
297310
group_extension_choices,
298311
)
312+
from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE
299313

300314
interactive_catalog = interactive_catalog or get_catalog_data()
301315
extension_choices = build_extension_choices(interactive_catalog, template)
@@ -316,7 +330,8 @@ def scaffold(
316330
"Which kinds of extensions do you need?",
317331
choices=category_choices,
318332
qmark="?",
319-
pointer=">",
333+
pointer="❯",
334+
style=CPA_PROMPT_STYLE,
320335
).ask()
321336
selected_addons: list[str] = []
322337
for category_slug in selected_categories or []:
@@ -330,7 +345,8 @@ def scaffold(
330345
for choice in choices
331346
],
332347
qmark="?",
333-
pointer=">",
348+
pointer="❯",
349+
style=CPA_PROMPT_STYLE,
334350
).ask()
335351
selected_addons.extend(str(item) for item in picked or [])
336352
addons = selected_addons
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""High-contrast questionary styles for CPA interactive prompts."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
7+
from questionary import Style
8+
9+
# Lighter brand blues/greens than docs hex so selected rows stay readable on
10+
# dark terminals (slate backgrounds common in Arch/Ghostty/Alacritty).
11+
CPA_PROMPT_STYLE = Style.from_dict(
12+
{
13+
"qmark": "fg:#60a5fa bold",
14+
"question": "bold fg:#f8fafc",
15+
"answer": "fg:#4ade80 bold",
16+
"pointer": "fg:#60a5fa bold",
17+
"highlighted": "fg:#0f172a bg:#60a5fa bold",
18+
"selected": "fg:#4ade80 bold",
19+
"separator": "fg:#94a3b8",
20+
"instruction": "fg:#94a3b8",
21+
"text": "fg:#e2e8f0",
22+
"disabled": "fg:#64748b italic",
23+
"checkbox": "fg:#60a5fa",
24+
"checkbox-selected": "fg:#4ade80 bold",
25+
}
26+
)
27+
28+
29+
def colors_enabled() -> bool:
30+
return not os.environ.get("NO_COLOR")
31+
32+
33+
def ansi(code: str, text: str) -> str:
34+
"""Wrap *text* in an ANSI SGR sequence when colors are enabled."""
35+
if not colors_enabled():
36+
return text
37+
return f"\033[{code}m{text}\033[0m"
38+
39+
40+
# Bold bright ANSI — readable on dark terminals; select() renders these safely
41+
# (unlike autocomplete, which HTML-parses choice text).
42+
_CATEGORY_PALETTE = (
43+
"1;93", # bright yellow
44+
"1;92", # bright green
45+
"1;96", # bright cyan
46+
"1;95", # bright magenta
47+
"1;94", # bright blue
48+
)
49+
50+
51+
def color_category(slug: str, label: str) -> str:
52+
idx = sum(ord(char) for char in slug) % len(_CATEGORY_PALETTE)
53+
return ansi(_CATEGORY_PALETTE[idx], label)
54+
55+
56+
def bold(text: str) -> str:
57+
return ansi("1", text)
58+
59+
60+
def dim(text: str) -> str:
61+
return ansi("2", text)

packages/create-awesome-python-app/tests/test_catalog_resolve.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,17 +102,37 @@ def test_build_template_choices_are_searchable() -> None:
102102
assert "FastAPI Starter" in first.title
103103
assert "OpenAPI" in first.title
104104
assert "uv" in first.title
105-
assert "\033" not in first.title
106105
assert "openapi" in first.search
107106
assert "backend" in first.search
108107
assert "uv" in first.search
109108
assert choices[-1].value == CUSTOM_TEMPLATE_SENTINEL
110109

111110

112-
def test_template_choice_titles_are_html_safe_for_questionary() -> None:
113-
"""questionary.autocomplete formats choice text as HTML (match underline)."""
114-
from prompt_toolkit.formatted_text import HTML
111+
def test_template_choice_titles_include_bright_category_ansi(
112+
monkeypatch,
113+
) -> None:
114+
"""select() can render ANSI; badges use bright bold codes for contrast."""
115+
monkeypatch.delenv("NO_COLOR", raising=False)
116+
catalog = {
117+
"categories": [
118+
{"slug": "backend-applications", "name": "Backend Applications"}
119+
],
120+
"templates": [
121+
{
122+
"slug": "fastapi-starter",
123+
"name": "FastAPI Starter",
124+
"url": "file:///templates/fastapi",
125+
"category": "backend-applications",
126+
}
127+
],
128+
}
129+
title = build_template_choices(catalog)[0].title
130+
assert "\033[" in title
131+
assert "FastAPI Starter" in title
115132

133+
134+
def test_template_choice_titles_respect_no_color(monkeypatch) -> None:
135+
monkeypatch.setenv("NO_COLOR", "1")
116136
catalog = {
117137
"categories": [
118138
{"slug": "backend-applications", "name": "Backend Applications"}
@@ -121,16 +141,14 @@ def test_template_choice_titles_are_html_safe_for_questionary() -> None:
121141
{
122142
"slug": "fastapi-starter",
123143
"name": "FastAPI Starter",
124-
"description": "Async API with OpenAPI docs",
125144
"url": "file:///templates/fastapi",
126145
"category": "backend-applications",
127-
"labels": ["FastAPI"],
128146
}
129147
],
130148
}
131149
title = build_template_choices(catalog)[0].title
132-
# Must not raise "not well-formed (invalid token)" from ANSI escapes.
133-
HTML("{}<b><u>{}</u></b>{}").format(title[:3], title[3:6], title[6:])
150+
assert "\033" not in title
151+
assert "FastAPI Starter" in title
134152

135153

136154
def test_build_extension_choices_filters_by_template_type() -> None:

0 commit comments

Comments
 (0)