From 56649db952de06ae660b9f8ba1ce5d5aec5ea139 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Fri, 15 May 2026 14:18:30 -0400 Subject: [PATCH] feat: Google-SERP-style list rows on tag detail (Phase 1 redesign) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of fox's "drop the desktop-card framing, build SERP-style list view that works at every resolution" redesign. Scope: tag detail page only (/s/{shop_id}/tag/{slug}). If this shape lands well it extends to filtered shop home + search + "See all" routes next. templates/shop_tag.j2 — replaces .serp card grid with .serp-list rows. Each row: thumbnail | (title + price + excerpt). Article semantics (
+

) so the page reads correctly to crawlers + screen readers. models/product.py — new Product.excerpt(max_chars=180) method. Strips common markdown markers (headings, emphasis, links, images, blockquotes, code, list bullets) and backtracks to the nearest sentence terminator or word boundary so the snippet doesn't end mid-word. ~Google-snippet feel (default 180 chars). static/css/common.css — mobile-first SERP-list layout. Default grid-template-columns is 80px minmax(0, 1fr) — small thumbnail on the left, text on the right, comfortable on phones. Container queries (container-type: inline-size on .serp-list) bump the thumbnail to 120px at 600px container width and 160px at 900px, so the layout uses as much horizontal real estate as it gets without ever wrapping. Excerpt has max-width: 70ch so the reading band stays comfortable on wide monitors instead of stretching to one-line-per-row. Still TODO (next phases): - left sidebar with categories on wide viewports - apply this same SERP-list to filtered shop home + search - convert lanes to either list rows or keep Netflix-style cards based on fox's call after seeing this phase live --- make_post_sell/models/product.py | 35 +++++++++++ make_post_sell/static/css/common.css | 91 ++++++++++++++++++++++++++++ make_post_sell/templates/shop_tag.j2 | 27 ++++++--- 3 files changed, 143 insertions(+), 10 deletions(-) diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py index c9413e4..78dfe7f 100644 --- a/make_post_sell/models/product.py +++ b/make_post_sell/models/product.py @@ -295,6 +295,41 @@ class Product(RBase, Base): self.description = description self.description_html = markdown_to_html(description, self.shop) + def excerpt(self, max_chars=180): + """Plain-text snippet of the description for SERP-style rows + (Google-snippet feel: ~160-180 chars, ends at a sentence/space + boundary, no markdown chars, no HTML tags). + + Built off the raw markdown rather than description_html so we + don't have to import bleach or html.parser here — markdown's + symbol set is small and easy to strip cheaply. + """ + if not self.description: + return "" + # Strip the common markdown markers: # heading, * / _ emphasis, + # ` code, [text](url) → text, > blockquote, - bullet, + # newlines → space. Order matters: handle links before stars. + import re + text = self.description + text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text) # images + text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) # links + text = re.sub(r"[#>*_`~|]+", "", text) # md symbols + text = re.sub(r"^[\s-]+", "", text, flags=re.M) # leading -/space + text = re.sub(r"\s+", " ", text).strip() + if len(text) <= max_chars: + return text + cut = text[:max_chars] + # Backtrack to last sentence terminator or space so we don't cut + # mid-word. Prefer .!? then space, then hard cut. + for stop in (". ", "! ", "? "): + idx = cut.rfind(stop) + if idx >= max_chars * 0.5: + return cut[: idx + 1].strip() + idx = cut.rfind(" ") + if idx >= max_chars * 0.5: + return cut[:idx].rstrip() + "…" + return cut.rstrip() + "…" + def set_price(self, price): self.updated_timestamp = now_timestamp() try: diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 84a2c80..d9e5111 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -1463,6 +1463,97 @@ form.serp-sort-form select { } } +/* =================================================================== + * SERP-list layout — Google-style horizontal rows (thumb | text-block). + * + * Mobile-first: each row is grid-template-columns 80px 1fr (small + * thumbnail on left, title + price + excerpt on right). Container + * queries scale the thumbnail to 120px at 600px container width and + * 160px at 900px — uses as much horizontal real estate as the layout + * gives us without ever wrapping awkwardly. Excerpt gets max-width: + * 70ch so on huge monitors it stays in the comfortable reading band + * instead of stretching into one line per row. + * ===================================================================*/ +section.serp-list { + display: grid; + gap: var(--space-4, 16px); + container-type: inline-size; + margin: 0 0 var(--space-6, 24px) 0; +} +article.serp-list-row { + display: grid; + grid-template-columns: 80px minmax(0, 1fr); + gap: var(--space-3, 12px); + align-items: start; + padding: var(--space-2, 8px); + border-radius: var(--radius-sm, 4px); + transition: background-color 200ms ease; +} +article.serp-list-row:hover { + background-color: var(--surface-dim, #f9f9fa); +} +a.serp-list-thumb-link { + display: block; + min-width: 0; +} +img.serp-list-thumb { + width: 100%; + max-width: 100%; + height: auto; + display: block; + border-radius: var(--radius-sm, 4px); + min-width: 0; +} +.serp-list-body { + min-width: 0; + display: grid; + gap: var(--space-1, 4px); +} +h3.serp-list-title { + margin: 0; + font-size: var(--type-title-size, 1.125rem); + line-height: 1.3; + word-wrap: break-word; +} +h3.serp-list-title a { text-decoration: none; } +h3.serp-list-title a:hover { text-decoration: underline; } +p.serp-list-price { + margin: 0; + font-weight: 600; + color: var(--color-green, #4d7a1f); +} +p.serp-list-price a { + color: inherit; + text-decoration: none; +} +p.serp-list-excerpt { + margin: 0; + color: var(--text-color, #333); + font-size: var(--type-body-sm-size, 0.875rem); + line-height: 1.5; + max-width: 70ch; +} +[data-theme="dark"] article.serp-list-row:hover { + background-color: var(--dark-button-bg, #2d3748); +} + +/* Container queries — bump the thumbnail column up as the section + grows. The parent .serp-list is the query container. */ +@container (min-width: 600px) { + article.serp-list-row { + grid-template-columns: 120px minmax(0, 1fr); + gap: var(--space-4, 16px); + } + h3.serp-list-title { + font-size: var(--type-title-size, 1.25rem); + } +} +@container (min-width: 900px) { + article.serp-list-row { + grid-template-columns: 160px minmax(0, 1fr); + } +} + /* Sectioned-lanes layout — one lane per top tag. Netflix-style horizontal row with scroll-snap. */ section.tag-lane { diff --git a/make_post_sell/templates/shop_tag.j2 b/make_post_sell/templates/shop_tag.j2 index 61195d4..8efb4c6 100644 --- a/make_post_sell/templates/shop_tag.j2 +++ b/make_post_sell/templates/shop_tag.j2 @@ -34,21 +34,28 @@ -
+
{% for product in products %} {% if product.is_ready %} -
+
+
+

+ {{ product.title }} +

+ {% if product.is_sellable %} +

${{ '{:,.2f}'.format(product.price) }}

+ {% endif %} + {% set snippet = product.excerpt(200) %} + {% if snippet %} +

{{ snippet }}

+ {% endif %} +
+

{% endif %} {% endfor %}