feat: Google-SERP-style list rows on tag detail (Phase 1 redesign)

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 (<article class="serp-list-row"> + <h3>) 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
This commit is contained in:
russell@unturf.com 2026-05-15 14:18:30 -04:00
parent 095d91bc68
commit 56649db952
No known key found for this signature in database
3 changed files with 143 additions and 10 deletions

View file

@ -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:

View file

@ -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 {

View file

@ -34,21 +34,28 @@
<noscript><button type="submit" class="mps-button mps-button-small">Apply</button></noscript>
</form>
<section class="serp{% if request.shop.grid_lanes_enabled %} grid-lanes-enabled{% endif %}" data-tag-grid>
<section class="serp-list" data-tag-grid>
{% for product in products %}
{% if product.is_ready %}
<div class="serp-item">
<article class="serp-list-row">
{% if "thumbnail1" in product.extensions %}
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
<a href="{{ product.absolute_url(request) }}" rel="nofollow" class="serp-list-thumb-link">
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-list-thumb" loading="lazy" />
</a>
{% endif %}
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>
{% if product.is_sellable %}
<br>
<a href="{{ product.absolute_url(request) }}" rel="nofollow">${{ '{:,.2f}'.format(product.price) }}</a>
{% endif %}
</div>
<div class="serp-list-body">
<h3 class="serp-list-title">
<a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a>
</h3>
{% if product.is_sellable %}
<p class="serp-list-price"><a href="{{ product.absolute_url(request) }}" rel="nofollow">${{ '{:,.2f}'.format(product.price) }}</a></p>
{% endif %}
{% set snippet = product.excerpt(200) %}
{% if snippet %}
<p class="serp-list-excerpt">{{ snippet }}</p>
{% endif %}
</div>
</article>
{% endif %}
{% endfor %}
</section>