From eb51124608a8e38de53de82af486d48d78983a51 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sat, 26 Sep 2026 22:20:37 +0800 Subject: [PATCH 1/5] refactor: extract category table row markup into entry_rows macro Co-Authored-By: Claude --- website/templates/category.html | 163 ++++++++++++++++---------------- 1 file changed, 83 insertions(+), 80 deletions(-) diff --git a/website/templates/category.html b/website/templates/category.html index 7325aa2b..6edb3061 100644 --- a/website/templates/category.html +++ b/website/templates/category.html @@ -62,91 +62,15 @@ {% endblock %} {% block content %} - -
-
-
-

Search every project in one place

-
-

- Press / to search. Tap a tag to filter. Click any row for - details. -

-
- -
-

Search and filter

-
- - - - - -
-
- Filtering for - -
-
- -

Results

-
- - - - - - - - - - - - - - {% for entry in entries %} +{% macro entry_rows(entry, index) %} - + {% endif %} - + +{% endmacro %} + +
+
+
+

Search every project in one place

+
+

+ Press / to search. Tap a tag to filter. Click any row for + details. +

+
+ +
+

Search and filter

+
+ + + + + +
+
+ Filtering for + +
+
+ +

Results

+
+
Row number - - - - - - - - Tags - -
@@ -273,6 +197,85 @@
+ + + + + + + + + + + + + {% for entry in entries %} + {{ entry_rows(entry, loop.index) }} {% endfor %}
Row number + + + + + + + + Tags + +
From 9cc721f533c1ff239ccd10c38f785b05c32d02bc Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sat, 26 Sep 2026 23:28:51 +0800 Subject: [PATCH 2/5] fix: match category meta titles to search query word order Category page titles read "ORM Python Libraries", but people search "python orm", so the title word order didn't match the query. Co-Authored-By: Claude --- website/build.py | 2 +- website/tests/test_build.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/website/build.py b/website/build.py index 46985670..fefc2f77 100644 --- a/website/build.py +++ b/website/build.py @@ -222,7 +222,7 @@ def category_meta_title(name: str, parent_name: str | None = None) -> str: if len(title) <= 60: return title return f"{name} - Awesome Python" - title = f"{name} Python Libraries - Awesome Python" + title = f"Python {name} Libraries - Awesome Python" if len(title) <= 60: return title return f"{name} - Awesome Python" diff --git a/website/tests/test_build.py b/website/tests/test_build.py index 7998525f..d32486b3 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -280,7 +280,7 @@ class TestBuild: assert 'href="/categories/widgets/"' in index_html assert 'data-value="Widgets"' in index_html - assert parser.title.strip() == "Widgets Python Libraries - Awesome Python" + assert parser.title.strip() == "Python Widgets Libraries - Awesome Python" assert parser.meta_by_name["description"] == "Widget libraries. Also see awesome-widgets. Explore 2 curated Python projects in Widgets." assert parser.links_by_rel["canonical"] == "https://awesome-python.com/categories/widgets/" assert parser.meta_by_property["og:url"] == "https://awesome-python.com/categories/widgets/" @@ -622,7 +622,7 @@ class TestBuild: assert set(graph) == {"WebSite", "CollectionPage", "BreadcrumbList"} assert graph["WebSite"]["@id"] == "https://awesome-python.com/#website" collection = graph["CollectionPage"] - assert collection["name"] == "Widgets Python Libraries" + assert collection["name"] == "Python Widgets Libraries" assert collection["@id"] == "https://awesome-python.com/categories/widgets/" assert collection["url"] == "https://awesome-python.com/categories/widgets/" assert collection["description"] == "Widget libraries. Explore 2 curated Python projects in Widgets." @@ -672,7 +672,7 @@ class TestBuild: graph = {node["@type"]: node for node in data["@graph"]} collection = graph["CollectionPage"] - assert collection["name"] == "AI & ML Python Libraries" + assert collection["name"] == "Python AI & ML Libraries" assert collection["@id"] == "https://awesome-python.com/categories/ai-ml/" assert collection["url"] == "https://awesome-python.com/categories/ai-ml/" assert collection["description"] == "Explore 1 curated Python projects in AI & ML. Part of the Awesome Python catalog." From 528f9edbb7d5523b381e5c8a5b1f73985c828e7b Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sat, 26 Sep 2026 23:32:20 +0800 Subject: [PATCH 3/5] fix: expose entry description rows to screen readers Every entry description row carried aria-hidden="true", hiding descriptions that sighted readers can see from screen readers. Co-Authored-By: Claude --- website/templates/category.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/templates/category.html b/website/templates/category.html index 6edb3061..9fca1b26 100644 --- a/website/templates/category.html +++ b/website/templates/category.html @@ -149,7 +149,7 @@ → {% if entry.description %} - +
{{ entry.description | safe }}
From 2b9469c0ae1c8f9fd3fb3f47d5fb4e7f2db3c0f1 Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sat, 26 Sep 2026 23:33:57 +0800 Subject: [PATCH 4/5] feat: group category page rows by use case, restructure intro Category pages sorted rows by downloads, which buried editorial leads (the Django ORM showed as row 7 of 7, tkinter as 14 of 14) against CONTRIBUTING.md's "position is the marker"; the full intro in the hero pushed the table 2.5 screens down on a phone; and every page repeated the same "Search every project in one place" H2. Co-Authored-By: Claude --- website/build.py | 61 ++++++++++++++-- website/static/main.js | 35 ++++++++- website/static/style.css | 118 +++++++++++++++++++++++++++++-- website/templates/category.html | 54 +++++++++++++- website/tests/test_build.py | 121 +++++++++++++++++++++++++++++++- 5 files changed, 370 insertions(+), 19 deletions(-) diff --git a/website/build.py b/website/build.py index fefc2f77..9164ca1a 100644 --- a/website/build.py +++ b/website/build.py @@ -66,6 +66,13 @@ class TemplateEntry(TypedDict): also_see: list[AlsoSee] +class EntryGroup(TypedDict): + name: str # empty for a page with a single unnamed group + slug: str + url: str # links the group heading to its own page, empty when it has none + entries: list[TemplateEntry] + + class SyntheticCategory(TypedDict): name: str slug: str @@ -237,13 +244,16 @@ def category_meta_description(name: str, entry_count: int, description: str, par return f"{count_sentence} Part of the Awesome Python catalog." -def load_category_intro(path: Path) -> tuple[str, str]: - """Render a category intro file to HTML, plus its first paragraph as plain text for the meta description. +def load_category_intro(path: Path) -> tuple[str, str, str]: + """Render a category intro file to HTML, split at the end of its "How to choose:" list. - Returns empty strings if the category has no intro file. + Returns the part shown above the table, the guide shown below it, and the + first paragraph as plain text for the meta description. A file without the + list keeps everything above the table. Returns empty strings if the category + has no intro file. """ if not path.exists(): - return "", "" + return "", "", "" md = MarkdownIt("commonmark") tokens = md.parse(path.read_text(encoding="utf-8")) for token in tokens: @@ -252,7 +262,38 @@ def load_category_intro(path: Path) -> tuple[str, str]: child.attrSet("target", "_blank") child.attrSet("rel", "noopener") lead = next(node for node in SyntaxTreeNode(tokens).children if node.type == "paragraph") - return md.renderer.render(tokens, md.options, {}), render_inline_text(lead.children[0].children) + split_at = len(tokens) + for i, token in enumerate(tokens): + if token.type == "inline" and token.level == 1 and token.content == "How to choose:" and i + 2 < len(tokens) and tokens[i + 2].type == "bullet_list_open": + split_at = next(j for j in range(i + 3, len(tokens)) if tokens[j].type == "bullet_list_close" and tokens[j].level == 0) + 1 + break + render = md.renderer.render + return render(tokens[:split_at], md.options, {}), render(tokens[split_at:], md.options, {}), render_inline_text(lead.children[0].children) + + +def group_section_entries(section: ParsedSection, entries_by_key: dict[tuple[str, str], TemplateEntry]) -> list[EntryGroup]: + """Group a section's entries by use case (subcategory), both in README order.""" + groups: dict[str, EntryGroup] = {} + for parsed in section["entries"]: + name = parsed["subcategory"] + group = groups.setdefault(name, EntryGroup(name=name, slug=slugify(name) if name else "", url="", entries=[])) + group["entries"].append(entries_by_key[(parsed["url"], parsed["name"])]) + return list(groups.values()) + + +def group_entries_by_section(sections: Sequence[ParsedSection], entries_by_key: dict[tuple[str, str], TemplateEntry]) -> list[EntryGroup]: + """Group a thematic group's entries by section, both in README order, listing each entry once.""" + placed: set[tuple[str, str]] = set() + groups: list[EntryGroup] = [] + for section in sections: + entries: list[TemplateEntry] = [] + for parsed in section["entries"]: + key = (parsed["url"], parsed["name"]) + if key not in placed: + placed.add(key) + entries.append(entries_by_key[key]) + groups.append(EntryGroup(name=section["name"], slug=section["slug"], url=category_path(section), entries=entries)) + return groups def build_breadcrumb_json_ld(items: Sequence[tuple[str, str]]) -> dict: @@ -692,11 +733,12 @@ def build(repo_root: Path) -> None: page_dir: Path, parent_category: ParsedSection | None = None, group_categories: Sequence[ParsedSection] | None = None, + entry_groups: Sequence[EntryGroup] = (), ) -> None: page_dir.mkdir(parents=True, exist_ok=True) parent_name = parent_category["name"] if parent_category else None category_title = category_meta_title(category["name"], parent_name) - intro_html, intro_lead = load_category_intro(website / "data" / "category_intros" / f"{current_path.removeprefix('/categories/').strip('/')}.md") + intro_html, guide_html, intro_lead = load_category_intro(website / "data" / "category_intros" / f"{current_path.removeprefix('/categories/').strip('/')}.md") category_description = intro_lead or category_meta_description(category["name"], len(entries), category["description"], parent_name) breadcrumbs = [("Awesome Python", SITE_URL)] if parent_category: @@ -713,7 +755,9 @@ def build(repo_root: Path) -> None: category_url=category_url, category_description=category_description, intro_html=intro_html, + guide_html=guide_html, entries=entries, + entry_groups=entry_groups, total_categories=len(categories), category_urls=category_urls, current_path=current_path, @@ -726,6 +770,8 @@ def build(repo_root: Path) -> None: encoding="utf-8", ) + entries_by_key = {(e["url"], e["name"]): e for e in entries} + section_groups = {category["name"]: group_section_entries(category, entries_by_key) for category in categories} for category in categories: render_category( category, @@ -733,6 +779,7 @@ def build(repo_root: Path) -> None: entries=[e for e in entries if category["name"] in e["categories"]], current_path=category_path(category), page_dir=categories_dir / category["slug"], + entry_groups=section_groups[category["name"]], ) for group in parsed_groups: @@ -743,6 +790,7 @@ def build(repo_root: Path) -> None: current_path=group_path(group["slug"]), page_dir=categories_dir / group["slug"], group_categories=group["categories"], + entry_groups=group_entries_by_section(group["categories"], entries_by_key), ) if builtin_entries: @@ -792,6 +840,7 @@ def build(repo_root: Path) -> None: current_path=subcategory_path(cat_slug, sub_slug), page_dir=categories_dir / cat_slug / sub_slug, parent_category=cat_by_slug[cat_slug], + entry_groups=[EntryGroup(name="", slug="", url="", entries=group["entries"]) for group in section_groups[cat_by_slug[cat_slug]["name"]] if group["name"] == sub_name], ) redirects_file = website / "data" / "redirects.json" diff --git a/website/static/main.js b/website/static/main.js index 11d43861..307e3f92 100644 --- a/website/static/main.js +++ b/website/static/main.js @@ -4,8 +4,14 @@ function getScrollBehavior() { return reducedMotion.matches ? "auto" : "smooth"; } +const table = document.querySelector(".table"); +// Category pages list rows in editorial (README) order until a column is sorted +const defaultSort = + table && table.dataset.defaultSort === "editorial" + ? { col: "editorial", order: "asc" } + : { col: "downloads", order: "desc" }; let activeFilter = null; -let activeSort = { col: "downloads", order: "desc" }; +let activeSort = defaultSort; const searchInput = document.querySelector(".search"); const filterBar = document.querySelector(".filter-bar"); const filterValue = document.querySelector(".filter-value"); @@ -14,6 +20,7 @@ const noResults = document.querySelector(".no-results"); const rows = document.querySelectorAll(".table tbody tr.row"); const tags = document.querySelectorAll(".tag"); const tbody = document.querySelector(".table tbody"); +const groupRows = document.querySelectorAll(".table tbody tr.group-row"); function initRevealSections() { const sections = document.querySelectorAll("[data-reveal]"); @@ -111,6 +118,12 @@ document time.textContent = relativeTime(time.getAttribute("datetime")); }); +let currentGroupRow = null; +Array.prototype.forEach.call(tbody ? tbody.rows : [], function (tr) { + if (tr.classList.contains("group-row")) currentGroupRow = tr; + else if (tr.classList.contains("row")) tr._groupRow = currentGroupRow; +}); + rows.forEach(function (row, i) { row._origIndex = i; let next = row.nextElementSibling; @@ -176,6 +189,15 @@ function applyFilters() { } }); + groupRows.forEach(function (groupRow) { + groupRow.hidden = true; + }); + if (activeSort.col === "editorial") { + rows.forEach(function (row) { + if (!row.hidden && row._groupRow) row._groupRow.hidden = false; + }); + } + if (noResults) noResults.hidden = visibleCount > 0; tags.forEach(function (tag) { @@ -211,7 +233,7 @@ function buildQueryString() { const params = new URLSearchParams(); const query = searchInput ? searchInput.value.trim() : ""; if (query) params.set("q", query); - if (activeSort.col !== "downloads" || activeSort.order !== "desc") { + if (activeSort.col !== defaultSort.col || activeSort.order !== defaultSort.order) { params.set("sort", activeSort.col); params.set("order", activeSort.order); } @@ -227,6 +249,8 @@ function updateURL() { } function getSortValue(row, col) { + // +1 keeps the first row above the "no value" cutoff in sortRows + if (col === "editorial") return row._origIndex + 1; if (col === "name") { return row.querySelector(".col-name a").textContent.trim().toLowerCase(); } @@ -282,7 +306,12 @@ function sortRows() { }); const frag = document.createDocumentFragment(); + let lastGroupRow = null; arr.forEach(function (row) { + if (col === "editorial" && row._groupRow && row._groupRow !== lastGroupRow) { + frag.appendChild(row._groupRow); + lastGroupRow = row._groupRow; + } frag.appendChild(row); if (row._descRow) frag.appendChild(row._descRow); if (row._expandRow) frag.appendChild(row._expandRow); @@ -392,7 +421,7 @@ sortHeaders.forEach(function (th) { if (activeSort.col === col) { if (activeSort.order === defaultOrder) activeSort = { col: col, order: altOrder }; - else activeSort = { col: "downloads", order: "desc" }; + else activeSort = defaultSort; } else { activeSort = { col: col, order: defaultOrder }; } diff --git a/website/static/style.css b/website/static/style.css index bdad3835..f7bddd67 100644 --- a/website/static/style.css +++ b/website/static/style.css @@ -438,6 +438,7 @@ kbd { .search:focus-visible, .filter-clear:focus-visible, .tag:focus-visible, +.jump-link:focus-visible, .back-to-top:focus-visible, .no-results-clear:focus-visible, .table a:focus-visible, @@ -459,9 +460,9 @@ kbd { z-index: 1; width: min(100%, calc(var(--shell-max) + (var(--shell-pad) * 2))); margin: 0 auto; - padding: 1.25rem var(--shell-pad) clamp(3.75rem, 8vw, 6.75rem); + padding: 1.25rem var(--shell-pad) clamp(2.25rem, 5vw, 3.5rem); display: grid; - gap: clamp(3rem, 8vw, 5.5rem); + gap: clamp(2rem, 5vw, 3rem); } .category-hero h1 { @@ -497,7 +498,9 @@ kbd { } .category-subtitle a, -.category-intro a { +.category-intro a, +.guide-body a, +.jump-link { color: var(--hero-text); text-decoration: underline; text-decoration-color: oklch(100% 0 0 / 0.32); @@ -505,12 +508,14 @@ kbd { } .category-subtitle a:hover, -.category-intro a:hover { +.category-intro a:hover, +.guide-body a:hover, +.jump-link:hover { text-decoration-color: oklch(100% 0 0 / 0.7); } .category-intro { - margin-top: 1.75rem; + margin-top: 1.25rem; display: grid; gap: 0.9rem; color: var(--hero-muted); @@ -530,10 +535,65 @@ kbd { padding-left: 1.25rem; } -.category-intro code { +.category-intro code, +.guide-body code { font-size: 0.9em; } +.jump-links { + display: flex; + flex-wrap: wrap; + gap: 0.45rem 1.6rem; + margin-top: 1.5rem; +} + +.jump-link { + font-size: var(--text-base); + font-weight: 600; +} + +/* The arrow marks an in-page jump; inline-block keeps it out of the underline */ +.jump-link::after { + content: "\2193"; + display: inline-block; + margin-left: 0.3rem; + color: var(--hero-kicker); +} + +.guide-band { + padding-block: clamp(3rem, 7vw, 5.5rem); + background: linear-gradient(140deg, var(--hero-bg-start) 0%, var(--hero-bg-mid) 58%, var(--hero-bg-end) 100%); + color: var(--hero-text); +} + +.guide-section { + display: grid; + gap: 1.5rem; +} + +.guide-section h2 { + font-family: var(--font-display); + font-size: clamp(2.2rem, 4vw, 3.3rem); + font-weight: 600; + line-height: 0.94; + letter-spacing: -0.03em; +} + +.guide-body { + display: grid; + gap: 1rem; + color: var(--hero-muted); + font-size: var(--text-lg); + line-height: 1.7; + text-wrap: pretty; +} + +.guide-body ul { + display: grid; + gap: 0.35rem; + padding-left: 1.25rem; +} + .sponsor-band { padding-block: clamp(2.5rem, 5.5vw, 4rem); background: @@ -651,6 +711,11 @@ kbd { padding-bottom: 1.75rem; } +.order-note { + color: var(--ink-soft); + font-size: var(--text-base); +} + .results-note { color: var(--ink-soft); font-size: var(--text-sm); @@ -819,6 +884,44 @@ kbd { cursor: pointer; } +.group-row th { + padding-top: 2.25rem; + padding-bottom: 0.85rem; + padding-left: max(var(--shell-pad), calc(50vw - (var(--shell-max) / 2) + var(--shell-pad))); + text-align: left; + border-bottom: 1px solid var(--line-strong); + background: var(--bg-paper); +} + +.group-row h2 { + display: flex; + align-items: baseline; + gap: 0.75rem; + font-family: var(--font-display); + font-size: clamp(1.7rem, 2.8vw, 2.2rem); + font-weight: 600; + line-height: 1; + scroll-margin-top: 4.5rem; +} + +.group-row h2 a { + color: var(--ink); +} + +.group-row h2 a:hover { + color: var(--accent-deep); + text-decoration: underline; + text-decoration-color: var(--accent-underline); + text-underline-offset: 0.2em; +} + +.group-count { + font-family: var(--font-body); + font-size: var(--text-sm); + font-weight: 600; + color: var(--ink-muted); +} + .row:not(.open):hover td { background: var(--row-hover); } @@ -1735,7 +1838,8 @@ th[data-sort].sort-asc::after { } .table thead th:first-child, - .table tbody td:first-child { + .table tbody td:first-child, + .group-row th { padding-left: 0.8rem; } diff --git a/website/templates/category.html b/website/templates/category.html index 9fca1b26..a9618aef 100644 --- a/website/templates/category.html +++ b/website/templates/category.html @@ -40,6 +40,19 @@ {% if intro_html %}
{{ intro_html | safe }}
{% endif %} + {% set named_groups = entry_groups | selectattr("name") | list %} + {% if (named_groups and not group_categories) or guide_html %} + + {% endif %}
{% if group_categories %} @@ -201,9 +214,17 @@
+ {% if entry_groups %} + {% set named_groups = entry_groups | selectattr("name") | list %} +

+ {% if group_categories %}Listed by section, in editorial order.{% elif named_groups %}Listed in editorial order, grouped by use case.{% else %}Listed in editorial order.{% endif %} + Click a column to re-sort the whole list. +

+ {% else %}
-

Search every project in one place

+

{{ entries | length }} {{ category.name }} projects

+ {% endif %}

Press / to search. Tap a tag to filter. Click any row for details. @@ -249,7 +270,7 @@ role="region" aria-label="Libraries table" > - +
@@ -274,9 +295,29 @@ + {% if entry_groups %} + {% set row = namespace(index=0) %} + {% for group in entry_groups %} + {% if group.name %} + + + + {% endif %} + {% for entry in group.entries %} + {% set row.index = row.index + 1 %} + {{ entry_rows(entry, row.index) }} + {% endfor %} + {% endfor %} + {% else %} {% for entry in entries %} {{ entry_rows(entry, loop.index) }} {% endfor %} + {% endif %}
Row number
+

+ {% if group.url %}{{ group.name }}{% else %}{{ group.name }}{% endif %} + {{ group.entries | length }} project{{ "s" if group.entries | length != 1 }} +

+

@@ -290,6 +331,15 @@
+{% if guide_html %} +
+
+

{{ category.name }} guide

+
{{ guide_html | safe }}
+
+
+{% endif %} +
diff --git a/website/tests/test_build.py b/website/tests/test_build.py index d32486b3..cfebc616 100644 --- a/website/tests/test_build.py +++ b/website/tests/test_build.py @@ -17,6 +17,7 @@ from build import ( detect_source_type, extract_entries, extract_github_repo, + load_category_intro, load_downloads, load_pypi_badges, load_stars, @@ -291,7 +292,7 @@ class TestBuild: assert 'href="https://example.com/w1"' in category_html assert "A widget." in category_html assert 'href="https://github.com/owner/w2"' in category_html - assert '' in category_html + assert '
' in category_html assert "42" in category_html assert "2026-01-01T00:00:00+00:00" in category_html @@ -1007,6 +1008,99 @@ class TestBuild: assert "
  • Small apps: w1
  • " in category_html assert 'the docs' in category_html + def test_build_renders_category_guide_below_table(self, tmp_path): + self._copy_real_templates(tmp_path) + (tmp_path / "README.md").write_text(self._REDIRECT_README, encoding="utf-8") + intros_dir = tmp_path / "website" / "data" / "category_intros" + intros_dir.mkdir(parents=True) + (intros_dir / "widgets.md").write_text("Use w1.\n\nHow to choose:\n\n- Small apps: w1\n\nSet up w1 once per process.\n", encoding="utf-8") + build(tmp_path) + + category_html = (tmp_path / "website" / "output" / "categories" / "widgets" / "index.html").read_text(encoding="utf-8") + intro_html = category_html.split('
    ', 1)[1].split("
    ", 1)[0] + assert "
  • Small apps: w1
  • " in intro_html + assert "Set up w1" not in intro_html + guide_html = category_html.split('
    ', 1)[1] + assert "

    Widgets guide

    " in guide_html + assert "

    Set up w1 once per process.

    " in guide_html + assert category_html.index('id="guide"') > category_html.index("
    ") + assert 'Widgets guide' in category_html + + def test_section_page_groups_rows_by_use_case_in_readme_order(self, tmp_path): + readme = textwrap.dedent("""\ + # T + + ## Projects + + **Tools** + + ### Widgets + + - Small + - [w2](https://example.com/w2) - Second. + - [w1](https://example.com/w1) - First. + - Large + - [w3](https://example.com/w3) - Third. + - [sqlite3](https://docs.python.org/3/library/sqlite3.html) - Stdlib. + + # Contributing + + Done. + """) + self._copy_real_templates(tmp_path) + (tmp_path / "README.md").write_text(readme, encoding="utf-8") + build(tmp_path) + + site = tmp_path / "website" / "output" / "categories" + html = (site / "widgets" / "index.html").read_text(encoding="utf-8") + assert 'data-default-sort="editorial"' in html + positions = [html.index(marker) for marker in ('

    ', ">w2w1', ">w3Small' in html + assert '' in html + + subcategory_html = (site / "widgets" / "small" / "index.html").read_text(encoding="utf-8") + assert 'data-default-sort="editorial"' in subcategory_html + assert "group-row" not in subcategory_html + assert subcategory_html.index(">w2w1Machine Learning') + dl_heading = html.index('Deep Learning') + assert ml_heading < html.index(">ml1dl1ml1Use w1 for most apps.

    \n

    How to choose:

    \n
      \n
    • Small apps: w1
    • \n
    • Big apps: w2
    • \n
    \n" + assert guide_html == "

    Configure w1 once.

    \n

    Pin w2.

    \n" + assert lead == "Use w1 for most apps." + + def test_keeps_everything_above_table_without_how_to_choose_list(self, tmp_path): + path = tmp_path / "widgets.md" + path.write_text("Use w1.\n\n- Small apps: w1\n\nConfigure w1 once.\n", encoding="utf-8") + intro_html, guide_html, _ = load_category_intro(path) + assert "Configure w1 once." in intro_html + assert guide_html == "" + + def test_returns_empty_strings_without_intro_file(self, tmp_path): + assert load_category_intro(tmp_path / "missing.md") == ("", "", "") From d5febbea1f99eedbe9a97e5dc662e700d49930eb Mon Sep 17 00:00:00 2001 From: Vinta Chen Date: Sat, 26 Sep 2026 23:34:01 +0800 Subject: [PATCH 5/5] docs: document category page group rows and guide band Co-Authored-By: Claude --- DESIGN.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/DESIGN.md b/DESIGN.md index 1e678f2b..56ea32bc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -140,7 +140,7 @@ Hard-won sizing rules (do not relax): Depth comes from **tonal layers**, not heavy shadows. - The page is a quiet warm canvas (`--bg-page`). The content shell is slightly brighter paper (`--bg-paper`). The sponsor band, CTA backgrounds, and inline decorative blocks step up to `--bg-paper-strong`. -- The hero is the one place that uses real atmosphere: subtle grid, slow sheen, warm radial gradients on a dark earthy ground (`--hero-bg-start` → `--hero-bg-mid` → `--hero-bg-end`). The sheen and any other motion respect `prefers-reduced-motion`. +- The hero and the category guide band are the only places with real atmosphere: warm gradients on a dark earthy ground (`--hero-bg-start` → `--hero-bg-mid` → `--hero-bg-end`). Only the hero adds the subtle grid and slow sheen. The sheen and any other motion respect `prefers-reduced-motion`. - The footer is a single tonal block in `--footer-bg`, no internal gradients. - Two depth treatments are allowed and only these two. The search input combines a 1px inset highlight (`--search-inset`) with a soft warm drop shadow (`--search-shadow`, intensified by `--search-focus-shadow` on focus). The primary CTA button (`.hero-action-primary`) carries a warm drop shadow for press affordance. Both shadows are soft, warm-tinted, and tied to interactive elements. No new drop shadows on cards, panels, rows, or static decoration. - No glassmorphism as default decoration. @@ -160,8 +160,11 @@ The shape language is overwhelmingly **pill on small, zero radius on large**. The component vocabulary is small and table-led. Source of truth: `website/static/style.css`. - **Table-driven index** (the hero of the page). Sticky header, sortable columns, click-to-expand rows that indent under the Name column. Modeled on placestoread.xyz. Not a card grid. +- **Group rows**. Section, subcategory, and group pages list rows in README order. Section pages add one H2 row per use case; group pages add one per section, linking to that section's page. A note replaces the sort arrow until someone sorts a column, which flattens the list and hides the group rows. - **Filter tags** (`.tag`). `--accent-soft` background with `--accent-deep` text. Pill shape. Hover swaps to `--highlight` background with `--tag-hover-border` border and ink text. Active state uses the warm `--tag-active-start` → `--tag-active-end` gradient with hero-ink text. Tag variants (`tag-group`, `tag-source`) inherit the base `.tag` style today and differ only at narrow widths (`tag-group` hides under 960px). Add a new variant only when a real visual difference is needed. - **Hero**. Magazine-cover headline, dark earthy ground, kicker and proof microcopy, primary CTA button using `--hero-btn-start` / `--hero-btn-end`. Subtle grid plus slow sheen. Respects `prefers-reduced-motion`. +- **Jump links**. One per use case in a category hero, plus one for the guide. Underlined like the intro's links, with a trailing ↓. Never tag-styled: tags filter the table or open another page, jump links only scroll. +- **Guide band**. Everything in a category intro after its "How to choose" list. It sits below the table on the hero's dark gradient, with its H2 above the text. - **Sponsor band**. Sits in the README header on `--bg-paper-strong`. Editorial layout, not a logo wall. Sponsor links share the global accent treatment. - **CTA**. Warm `--cta-bg`, full-bleed within shell. The button itself uses accent tokens. - **Footer**. Dark warm charcoal, part of the same system. Footer links share the global hover and focus treatment.