feat: MPS-24 — smaller SERP thumbnails + featured/random right rail

Operator: smaller SERP thumbnails + a right column (featured products,
fallback random; all SERP + shop home).

- Thumbnails: serp-list thumb column 140/200/260/320 -> 88/110/130/150
  across container tiers (frees room for body + the new rail).
- _featured_rail_products(request, shop) in shop.py: shop's curated
  featured_product_ids (public/ready/deduped/capped); random
  public-ready fallback so it's never empty; bounded func.random()
  query (CWE-407-safe). Injected into _build_home_layout_context
  (home/shop/search) + shop_tag_detail ctx.
- Shared _facet_nav.j2:featured_rail macro, rendered as the 3rd child
  of the existing .tag-detail-layout on shop_tag.j2 / home.j2 / shop.j2
  (within show_facet_nav).
- CSS: <1100px rail is full-width beneath results; >=1100px 3-col
  facet | results | sticky rail; <800px stacks. Grid-only, tokens.
  /styleguide#serprail added.
- Tests: +test_serp_featured_rail_renders_with_random_fallback,
  +test_serp_featured_rail_prefers_curated_featured;
  test_tag_detail_price_filter_narrows_grid rescoped to the results
  grid (rail is unfiltered discovery by design). 1139 passed.

Docs: mps-24.md Phase 2.8l, design-system.md.
This commit is contained in:
russell@unturf.com 2026-05-16 16:50:41 -04:00
parent 2301ab33cc
commit 479dc0ed23
No known key found for this signature in database
10 changed files with 314 additions and 15 deletions

View file

@ -289,6 +289,7 @@ All components are documented with live examples at `/styleguide`. The styleguid
| `.tag-chip-add` | `product_edit.j2` | Add-a-tag row, grid `1fr auto`, stacks to one column under 600px |
| `.tag-chip-flash` | `product_edit.j2` | Toast region reusing `.tag-flash-toast` / `.tag-flash-{success,error,info}` |
| `[data-product-tags]` / `[data-product-tags-url]` | `product_edit.j2` | JS hooks for `product_tags.js`: container + the `/p/{id}/tags` endpoint URL |
| `.serp-rail` / `.serp-rail-list` / `.serp-rail-card` | `_facet_nav.j2:featured_rail` macro on `shop_tag.j2` / `home.j2` / `shop.j2` (Phase 2.8l) | Right rail on SERP pages — curated featured products or random fallback. 3rd grid child of `.tag-detail-layout`; full-width below results 8001099px, sticky right column ≥1100px. `/styleguide#serprail` |
| `[data-focus-section]` / `[data-focus-heading]` / `[data-focus-list]` | `shop_tags.j2` (Phase 2.8) | Stable bulk-tagger focus container — always in the DOM, `hidden` until a tag is focused; `tag_bulk.js` swaps the product list in place instead of a full reload |
| `[data-tag-focus-link]` | `shop_tags.j2` (Phase 2.8) | Tag chip in the All-tags list; `tag_bulk.js` intercepts the click and fetches `?focus=<slug>` as JSON |
| `.tag-list-dragging` / `.tag-list-drop-target` | `shop_tags.j2` (Phase 2.8) | Dragged row (dimmed) + active drop position during HTML5 drag-to-reorder; `tag_bulk.js` POSTs `set_order` on drop |

View file

@ -363,6 +363,24 @@ Tests (`test_functional.py::TestProductTagsSpa`):
Deferred (occasional click, not the hot path): AJAX-ifying the
"Suggest categories" link — still a full navigation by design.
**Phase 2.8l — SERP right rail (featured / random) + smaller thumbs**
(shipped 2026-05-16): operator wanted smaller SERP thumbnails and a
right column. Thumb column shrunk (140/200/260/320 → 88/110/130/150
across container tiers). New right rail: `_featured_rail_products`
helper (shop.featured_product_ids — public/ready/deduped/capped;
random public-ready fallback so it's never empty; bounded
`func.random()` query — CWE-407-safe), injected into home/shop/search
(`_build_home_layout_context`) + `shop_tag_detail` ctx. Shared
`_facet_nav.j2:featured_rail` macro rendered as the 3rd child of the
existing `.tag-detail-layout` on `shop_tag.j2` / `home.j2` /
`shop.j2`. CSS: 2-col (facet|results) <1100px with the rail
full-width beneath; 3-col (facet|results|sticky rail) ≥1100px; rail
stacks under results <800px. `/styleguide#serprail`. Tests:
`test_serp_featured_rail_renders_with_random_fallback`,
`test_serp_featured_rail_prefers_curated_featured`;
`test_tag_detail_price_filter_narrows_grid` rescoped to the results
grid (the rail is unfiltered discovery by design).
**Phase 2.8k — auto-suggest: way more, stop missing `holiday`**
(shipped 2026-05-16): operator: "100 suggested tags is not enough, we
need way more — missing holiday holidays". Two `100` caps in

View file

@ -1583,7 +1583,9 @@ section.serp-list {
}
article.serp-list-row {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
/* Smaller thumbnails (was 140/200/260/320 across the tiers below)
frees horizontal room for the body text + a right rail. */
grid-template-columns: 88px minmax(0, 1fr);
gap: var(--space-4, 16px);
align-items: start;
padding: var(--space-3, 12px);
@ -1645,7 +1647,7 @@ p.serp-list-excerpt {
grows. The parent .serp-list is the query container. */
@container (min-width: 600px) {
article.serp-list-row {
grid-template-columns: 200px minmax(0, 1fr);
grid-template-columns: 110px minmax(0, 1fr);
gap: var(--space-5, 20px);
}
h3.serp-list-title {
@ -1654,12 +1656,12 @@ p.serp-list-excerpt {
}
@container (min-width: 900px) {
article.serp-list-row {
grid-template-columns: 260px minmax(0, 1fr);
grid-template-columns: 130px minmax(0, 1fr);
}
}
@container (min-width: 1200px) {
article.serp-list-row {
grid-template-columns: 320px minmax(0, 1fr);
grid-template-columns: 150px minmax(0, 1fr);
}
}
@ -1938,14 +1940,100 @@ div.tag-detail-layout {
}
@media (min-width: 800px) {
div.tag-detail-layout {
/* facet | results. The featured rail (3rd child) flows to a
full-width row beneath until there's room for a 3rd column. */
grid-template-columns: 220px minmax(0, 1fr);
gap: var(--space-5, 20px);
}
div.tag-detail-layout > aside.serp-rail {
grid-column: 1 / -1;
}
/* The sidebar replaces the top chip strip on wide viewports. */
nav.tag-chip-strip-mobile {
display: none;
}
}
@media (min-width: 1100px) {
div.tag-detail-layout {
/* facet | results | featured rail */
grid-template-columns: 220px minmax(0, 1fr) 260px;
}
div.tag-detail-layout > aside.serp-rail {
grid-column: auto;
align-self: start;
position: sticky;
top: var(--space-4, 16px);
}
}
/* ===================================================================
* SERP right rail featured products (or a random rotation). Compact
* cards so it complements, not competes with, the result list. MPS-24.
* ===================================================================*/
aside.serp-rail {
min-width: 0;
display: grid;
gap: var(--space-3, 12px);
align-content: start;
}
h2.serp-rail-title {
margin: 0;
font-size: var(--type-body-size, 1rem);
font-weight: 600;
color: var(--text-color, #333);
letter-spacing: 0.02em;
}
ul.serp-rail-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-3, 12px);
}
li.serp-rail-card {
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
gap: var(--space-2, 8px);
align-items: start;
padding: var(--space-2, 8px);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: var(--radius-sm, 4px);
background: var(--surface, #fff);
}
a.serp-rail-thumb-link {
display: block;
min-width: 0;
}
img.serp-rail-thumb {
width: 100%;
max-width: 100%;
height: auto;
display: block;
border-radius: var(--radius-sm, 4px);
}
.serp-rail-body {
min-width: 0;
display: grid;
gap: var(--space-1, 4px);
}
a.serp-rail-card-title {
font-size: var(--type-body-sm-size, 0.875rem);
line-height: 1.3;
text-decoration: none;
word-wrap: break-word;
}
a.serp-rail-card-title:hover {
text-decoration: underline;
}
span.serp-rail-card-price {
font-size: var(--type-body-sm-size, 0.8125rem);
font-weight: 600;
color: var(--color-green, #4d7a1f);
}
[data-theme="dark"] li.serp-rail-card {
background: var(--dark-button-bg, #2d3748);
border-color: var(--dark-border, #475569);
}
/* ===================================================================
* Mobile/tablet <details> facet accordion hidden on desktop where

View file

@ -137,3 +137,34 @@
</nav>
{% endif %}
{% endmacro %}
{# Right rail for SERP pages — the shop's featured products, or a
random rotation when none are curated (server decides; see
views/shop.py:_featured_rail_products). Compact cards so it sits
beside the results without competing. Sibling of .tag-detail-content
inside .tag-detail-layout. #}
{% macro featured_rail(products, request) %}
{% if products %}
<aside class="serp-rail" aria-label="Featured products">
<h2 class="serp-rail-title type-title">Featured</h2>
<ul class="serp-rail-list">
{% for p in products %}
<li class="serp-rail-card">
{% if "thumbnail1" in p.extensions %}
<a href="{{ p.absolute_url(request) }}" class="serp-rail-thumb-link" rel="nofollow">
<img src="{{ request.shop_cdn_endpoint }}/{{ p.s3_path }}/thumbnail1?ts={{ p.updated_timestamp }}"
class="serp-rail-thumb" loading="lazy" alt="" />
</a>
{% endif %}
<div class="serp-rail-body">
<a href="{{ p.absolute_url(request) }}" class="serp-rail-card-title shop-theme-link-color">{{ p.title }}</a>
{% if p.is_sellable %}
<span class="serp-rail-card-price">${{ '{:,.2f}'.format(p.price) }}</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</aside>
{% endif %}
{% endmacro %}

View file

@ -222,6 +222,8 @@
{% if show_facet_nav %}
</div> {# /.tag-detail-content #}
{# Right rail — featured (or random) products, 3rd grid child. #}
{{ facet.featured_rail(featured_rail, request) }}
</div> {# /.tag-detail-layout #}
{% endif %}

View file

@ -129,6 +129,8 @@
{% if show_facet_nav %}
</div> {# /.tag-detail-content #}
{# Right rail — featured (or random) products, 3rd grid child. #}
{{ facet.featured_rail(featured_rail, request) }}
</div> {# /.tag-detail-layout #}
{% endif %}

View file

@ -61,6 +61,10 @@
</div>
{# Right rail — featured (or random) products. 3rd grid child of
.tag-detail-layout: facet sidebar | results | rail. #}
{{ facet.featured_rail(featured_rail, request) }}
</div>
{%- endblock -%}

View file

@ -193,6 +193,7 @@
<a href="#cart">Cart</a>
<a href="#comments">Comments</a>
<a href="#tagchips">Tag Chips</a>
<a href="#serprail">SERP Rail</a>
<a href="#toggle">Toggle</a>
<a href="#ribbon">Ribbon</a>
<a href="#taskbar">Task Bar</a>
@ -1180,6 +1181,41 @@ The snippet sets inline margin-left for depth-N replies (depth * 20px).</div>
</div>
<!-- ======== SERP RAIL ======== -->
<div class="sg-section" id="serprail">
<div class="sg-section-title">SERP Rail</div>
<p style="max-width:600px;line-height:1.6;">
Right-hand rail on SERP pages (tag detail, search, filtered shop
home) &mdash; the shop's curated featured products, or a random
rotation when none are set (<code>views/shop.py:_featured_rail_products</code>).
3rd grid child of <code>.tag-detail-layout</code>: facet sidebar |
results | rail. Full-width below the results at 800&ndash;1099px,
a sticky right column &ge;1100px. Grid only.
</p>
<div class="sg-demo">
<aside class="serp-rail" aria-label="Featured products" style="max-width:280px;">
<h2 class="serp-rail-title type-title">Featured</h2>
<ul class="serp-rail-list">
<li class="serp-rail-card">
<span class="serp-rail-thumb-link"><span class="serp-rail-thumb" style="display:block;aspect-ratio:1;background:var(--surface-dim,#f3f4f6);border-radius:var(--radius-sm,4px);"></span></span>
<div class="serp-rail-body">
<a href="#serprail" class="serp-rail-card-title shop-theme-link-color">Valentine's Day Color by Number</a>
<span class="serp-rail-card-price">$3.00</span>
</div>
</li>
<li class="serp-rail-card">
<span class="serp-rail-thumb-link"><span class="serp-rail-thumb" style="display:block;aspect-ratio:1;background:var(--surface-dim,#f3f4f6);border-radius:var(--radius-sm,4px);"></span></span>
<div class="serp-rail-body">
<a href="#serprail" class="serp-rail-card-title shop-theme-link-color">Stone Fox Novel Study</a>
<span class="serp-rail-card-price">$5.00</span>
</div>
</li>
</ul>
</aside>
</div>
</div>
<!-- ======== TOGGLE ======== -->
<div class="sg-section" id="toggle">
<div class="sg-section-title">Toggle / Details</div>

View file

@ -8987,6 +8987,70 @@ class TestHomeLayoutAndTags(_AuthenticatedBase):
# not a replacement).
self.assertIn('facet-nav', body)
def _make_ready_tagged_product(self, shop_name, title, tag="Math"):
"""A content product (always is_ready) + public + tagged, so it
shows in both the SERP grid and the featured rail."""
shop = self._create_shop_helper(
user_creds=self.user1_creds,
shop_params={**self.shop1_params, "name": shop_name},
)
params = dict(self.product1_params)
params["title"] = title
params.pop("is_sellable", None) # content product → is_ready
self.testapp.post(f"/p/new?shop_id={shop.id}", params)
from ..models.product import get_all_products
product = get_all_products(self.dbsession).all()[-1]
self.testapp.post(
f"/p/{product.id}/edit",
{
"title": product.title,
"description": "One. Two. Three. Four. Five. Six.",
"visibility": "1",
"tags": tag,
},
)
return shop, product
def test_serp_featured_rail_renders_with_random_fallback(self):
"""SERP pages get a right rail. With no featured set configured
it falls back to a random rotation of the shop's public ready
products, so the rail is never empty."""
shop, product = self._make_ready_tagged_product(
"rail-shop", "RandomRailItem"
)
res = self.testapp.get(f"/s/{shop.id}/tag/math")
body = res.body.decode()
self.assertIn('class="serp-rail"', body)
self.assertIn('serp-rail-list', body)
self.assertIn('>Featured<', body)
rail = body.split('class="serp-rail"', 1)[1]
self.assertIn(product.title, rail)
def test_serp_featured_rail_prefers_curated_featured(self):
"""When the operator curates featured_product_ids, the rail
shows those."""
from ..models.shop import get_shop_by_id
shop, product = self._make_ready_tagged_product(
"rail-feat-shop", "CuratedRailItem"
)
# Snapshot scalars BEFORE the commit detaches the ORM objects.
shop_id = str(shop.id)
pid = str(product.id)
title = product.title
import json as _json
import transaction
self.dbsession.expire_all()
s = get_shop_by_id(self.dbsession, shop_id)
s.featured_product_ids_json = _json.dumps([pid])
self.dbsession.add(s)
self.dbsession.flush()
transaction.manager.commit()
res = self.testapp.get(f"/s/{shop_id}/tag/math")
body = res.body.decode()
self.assertIn('class="serp-rail"', body)
rail = body.split('class="serp-rail"', 1)[1]
self.assertIn(title, rail)
def test_facet_links_preserve_sort_and_price(self):
"""Operator report: 'switching one breaks it' — changing category
reset sort/price. Every category link / All link / chip / lane
@ -9074,23 +9138,31 @@ class TestHomeLayoutAndTags(_AuthenticatedBase):
import transaction
transaction.manager.commit()
# Upper bound — only cheap_widget passes.
# Scope price assertions to the RESULTS grid only — the new
# featured/random rail (<aside class="serp-rail">) intentionally
# shows shop products regardless of the price filter (it's a
# discovery rail, not filtered), so assert against the portion
# of the page before the rail.
def _grid(b):
return b.split('class="serp-rail"')[0]
# Upper bound — only cheap_widget passes (in the results grid).
res = self.testapp.get(f"/s/{shop_id}/tag/tools?price_max=10")
body = res.body.decode()
self.assertIn("cheap_widget", body)
self.assertNotIn("pricey_widget", body)
grid = _grid(res.body.decode())
self.assertIn("cheap_widget", grid)
self.assertNotIn("pricey_widget", grid)
# Lower bound — only pricey_widget passes.
res = self.testapp.get(f"/s/{shop_id}/tag/tools?price_min=10")
body = res.body.decode()
self.assertIn("pricey_widget", body)
self.assertNotIn("cheap_widget", body)
grid = _grid(res.body.decode())
self.assertIn("pricey_widget", grid)
self.assertNotIn("cheap_widget", grid)
# No bound — both visible.
# No bound — both visible in the grid.
res = self.testapp.get(f"/s/{shop_id}/tag/tools")
body = res.body.decode()
self.assertIn("cheap_widget", body)
self.assertIn("pricey_widget", body)
grid = _grid(res.body.decode())
self.assertIn("cheap_widget", grid)
self.assertIn("pricey_widget", grid)
def test_shop_home_lanes_renders_facet_sidebar_and_mobile_rows(self):
"""Layout 2 (lanes) shop home gets facet sidebar + SERP rows for

View file

@ -336,6 +336,48 @@ def _sort_products(products, sort_key, dbsession=None, shop_id=None):
return sorted(products, key=lambda p: -(p.created_timestamp or 0))
def _featured_rail_products(request, shop, limit=6):
"""MPS-24: right-rail products for SERP pages.
The shop's curated featured set (`shop.featured_product_ids`),
filtered to public + ready products of this shop, deduped, capped.
If the operator hasn't curated any, fall back to a random rotation
of public ready products so the rail is never empty (discovery).
Bounded no unbounded query (CWE-407-safe).
"""
if shop is None:
return []
import random
from sqlalchemy import func
from ..models.product import get_product_by_id, Product
out = []
seen = set()
for pid in shop.featured_product_ids:
if len(out) >= limit:
break
p = get_product_by_id(request.dbsession, pid)
if (
p is not None
and p.shop_id == shop.id
and p.visibility == 1
and p.is_ready
and p.id not in seen
):
seen.add(p.id)
out.append(p)
if not out:
pool = (
shop.products.filter(Product.visibility == 1)
.order_by(func.random())
.limit(limit * 4)
.all()
)
out = [p for p in pool if p.is_ready][:limit]
return out
def _build_home_layout_context(request, shop, products):
"""
MPS-24: derive home-layout context (chips/lanes/featured) for a shop home
@ -368,9 +410,11 @@ def _build_home_layout_context(request, shop, products):
"sort_options": SORT_OPTIONS,
"price_min": min_cents,
"price_max": max_cents,
"featured_rail": [],
}
if shop is None:
return ctx
ctx["featured_rail"] = _featured_rail_products(request, shop)
layout = shop.home_layout or 0
tag_limit = int(shop.home_layout_tag_limit or 8)
@ -2144,6 +2188,7 @@ def shop_tag_detail(request):
"sort_options": SORT_OPTIONS,
"price_min": min_cents,
"price_max": max_cents,
"featured_rail": _featured_rail_products(request, shop),
}
return ctx