diff --git a/docs/design-system.md b/docs/design-system.md index 09a54fd..93dc56d 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -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 800–1099px, 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=` 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 | diff --git a/docs/tickets/mps-24.md b/docs/tickets/mps-24.md index 49e436d..7016430 100644 --- a/docs/tickets/mps-24.md +++ b/docs/tickets/mps-24.md @@ -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 diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index b8adf64..430f056 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -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
facet accordion — hidden on desktop where diff --git a/make_post_sell/templates/_facet_nav.j2 b/make_post_sell/templates/_facet_nav.j2 index 98102a1..154fd22 100644 --- a/make_post_sell/templates/_facet_nav.j2 +++ b/make_post_sell/templates/_facet_nav.j2 @@ -137,3 +137,34 @@ {% 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 %} + +{% endif %} +{% endmacro %} diff --git a/make_post_sell/templates/home.j2 b/make_post_sell/templates/home.j2 index b0e5766..3e9fa64 100644 --- a/make_post_sell/templates/home.j2 +++ b/make_post_sell/templates/home.j2 @@ -222,6 +222,8 @@ {% if show_facet_nav %} {# /.tag-detail-content #} + {# Right rail — featured (or random) products, 3rd grid child. #} + {{ facet.featured_rail(featured_rail, request) }} {# /.tag-detail-layout #} {% endif %} diff --git a/make_post_sell/templates/shop.j2 b/make_post_sell/templates/shop.j2 index d19828b..ea64344 100644 --- a/make_post_sell/templates/shop.j2 +++ b/make_post_sell/templates/shop.j2 @@ -129,6 +129,8 @@ {% if show_facet_nav %} {# /.tag-detail-content #} + {# Right rail — featured (or random) products, 3rd grid child. #} + {{ facet.featured_rail(featured_rail, request) }} {# /.tag-detail-layout #} {% endif %} diff --git a/make_post_sell/templates/shop_tag.j2 b/make_post_sell/templates/shop_tag.j2 index 425751d..8a44cc1 100644 --- a/make_post_sell/templates/shop_tag.j2 +++ b/make_post_sell/templates/shop_tag.j2 @@ -61,6 +61,10 @@ + {# Right rail — featured (or random) products. 3rd grid child of + .tag-detail-layout: facet sidebar | results | rail. #} + {{ facet.featured_rail(featured_rail, request) }} + {%- endblock -%} diff --git a/make_post_sell/templates/styleguide.j2 b/make_post_sell/templates/styleguide.j2 index 2c66281..f463151 100644 --- a/make_post_sell/templates/styleguide.j2 +++ b/make_post_sell/templates/styleguide.j2 @@ -193,6 +193,7 @@ Cart Comments Tag Chips + SERP Rail Toggle Ribbon Task Bar @@ -1180,6 +1181,41 @@ The snippet sets inline margin-left for depth-N replies (depth * 20px). + +
+
SERP Rail
+

+ Right-hand rail on SERP pages (tag detail, search, filtered shop + home) — the shop's curated featured products, or a random + rotation when none are set (views/shop.py:_featured_rail_products). + 3rd grid child of .tag-detail-layout: facet sidebar | + results | rail. Full-width below the results at 800–1099px, + a sticky right column ≥1100px. Grid only. +

+
+ +
+
+ +
Toggle / Details
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 59c028b..cf70744 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -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 (