Operator review on tablet showed two gaps in the 2.6 ship: - Shop home (layout 2 lanes) had no facet sidebar — only tag detail did - Mobile lanes were horizontal Netflix-style tile rows with no description visible at all This batch extends the facet experience across every page where the operator opted into categorization (home_layout >= 1): - New templates/_facet_nav.j2 with three macros (facet_form, sidebar, details). One source of truth for the controls, three variants of the wrapper. shop_tag.j2 refactored to import the macro. - home.j2 + shop.j2 now wrap content in .tag-detail-layout when home_layout >= 1, rendering both the desktop sidebar and the mobile <details> accordion. CSS toggles visibility per viewport. - Each lane in layout 2 now emits BOTH horizontal tiles AND vertical .serp-list-row markup with 6-sentence excerpts. CSS shows tiles >=800px, SERP rows <800px. Tablet / phone shoppers see image + title + price + description excerpt under each tag heading. - views/shop.py: facet_tags is populated for any home_layout >= 1 (was only on ?tag= filter); sort + price now also filter the non-tag-filtered home grid when the shopper applies them. Native HTML. No JS dependency. Same controls everywhere. Test: test_shop_home_lanes_renders_facet_sidebar_and_mobile_rows. Docs: CLAUDE.md MPS-24 section, architecture matrix, ticket Phase 2.6b.
34 KiB
MPS-24: Shop home page overhaul + product categorization
Status
PHASES 1 + 2 SHIPPED (2026-05-15). Tag model + chip strip +
sectioned-lanes layout + bulk tagger live behind an opt-in home_layout
selector (default 0 = flat = unchanged). Phase 2 adds a
title-plus-description auto-tagger surfaced as one-click cluster apply
in the bulk tagger UI + scripts/backfill_tags.py CLI. Phase 3
(uncloseai-backed ML categorization behind a kill switch) follows under
this same ticket per CLAUDE.md "One Feature, One Ticket".
Background: operator feedback on shop.printableprompts.com flagged our
default home page as the reason for considering a move to Shopify. We
needed an opt-in home-page layout overhaul and a way to surface natural
product categories so shoppers can browse a 481-item shop without
scrolling a flat list.
Problem
shop.printableprompts.com is a digital-printables shop with 481 products,
all K-1 classroom materials. Crawled 2026-05-15 from
https://shop.printableprompts.com/sitemap.xml:
- 481 product pages, 2 shop pages, no tag/category pages (none exist).
- Natural groupings are obvious from titles alone: Math (
Addition to 10,Counting to 100), Seasonal/Holiday (Valentine's Day,St. Patrick's Day,Christmas), Literacy (Little Red Hen,Frog and Toad), Science (Life Cycle of a Butterfly,Solar Eclipse), Novel Studies (Stone Fox,Chocolate Touch), Procedural Writing (How to Build a Snowman), Thematic Units (Thanksgiving Writing). - Our home today renders a flat chronological grid with no way to filter, group, or jump to a topic.
Why an operator would reach for Shopify
Shopify shops get collections (operator-defined groups), automated collections (rule-based — "all products with tag X"), a sectioned home page template, collection lanes on home, a faceted product index, and tag-based search. None of that exists in MPS.
Current MPS home page
- Route:
home/shop/shop_slug→views/shop.py:212(home) +views/shop.py:226(shop). - Template:
templates/home.j2(shared by site root + merchant shop). - Data:
get_products_from_a_shop(shop, visibility=1)atmodels/product.py:730— single query, ordered byupdated_timestamp DESC, no grouping, no filtering, no pagination. - Visible features: optional sales-stats banner, flat
.serpgrid, optional subscription CTA. That's it.
What we already have (do not rebuild)
| Capability | Where | Notes |
|---|---|---|
| Visibility (public/private/unlisted) | Product.visibility (product.py:130) |
Already filters home grid |
| Digital vs physical | Product.is_physical (product.py:157) |
Binary, not a category |
| Sellable vs content | Product.is_sellable (product.py:154) |
Blog post vs product |
| Pricing modes | Product.pricing_mode (product.py:137) |
Fixed/auction/offer combos |
| Grid lanes (masonry) | Shop.grid_lanes_enabled (shop.py:71) |
Layout polish only |
| Watch mode SPA | Shop.watch_mode_enabled (shop.py:150) |
Sticky media SPA |
| Discovery ring (circular order) | Shop.json_discovery_ring (shop.py:216) |
For watch mode SPA, not home |
| Full-text title search | views/shop.py:275 → get_products_by_keywords (product.py:740) |
Title ilike, no tags, no description |
| Sandbox mode (creative filters) | Shop.sandbox_mode (shop.py:166) |
Image filters — not categorization |
What we do NOT have
- No
Tagmodel. NoCollectionmodel. NoCategorytable. No tag-style fields onProduct. No tag-aware search. - No LLM or embeddings infrastructure inside MPS (karaoke is audio ML routed
to unsandbox;
lib/sentiment.pyis a rule-based comment scorer). - No featured-item or hero columns on
Shop. - No browse routes beyond
/search?keywords=. No/tag/X,/collection/X,/category/X.
Goals — fewest clicks to a purchase
Our checkout flows are done; what's missing is navigation into our catalog. Every design decision below optimises for: shopper lands → finds a relevant product → opens it → buys. Each extra click, extra page, or extra scroll between "land" and "open" is friction we cut.
- Opt-in. Default
home_layout = 0(flat) keeps every existing shop pixel-identical. - A flipped-on shop with no tagging effort still produces a usable, grouped home page within minutes of opt-in — backfill must work without operator hand-labeling 481 products.
- Shopper sees the grouping on land, not behind a click. Categories live above the fold; chip click filters in place (no page reload, no extra page).
- Operator can correct mistakes — auto-grouping is never final state.
- No new external dependencies on first ship. ML-assisted tagging stays pluggable, off-by-default, last phase.
Two orthogonal design dimensions
We have two independent decisions that combine to form the overhaul. Treating them as one decision is what makes the design feel huge — splitting them lets us ship Phase 1 in a week.
Dimension A — How products get grouped (categorization mechanism)
| Option | Approach | Operator effort | Quality on day 1 | Infra cost | Reversible? |
|---|---|---|---|---|---|
| A1 Manual tags | Operator types comma-separated tags per product (or via bulk admin) | High (481 products × a few seconds) | Perfect — operator picks | Tiny — Tag + product_tag table |
Trivial |
| A2 Manual collections | Operator creates named collections, assigns products | Medium-High | Perfect | Small — Collection + collection_product table |
Trivial |
| A3 Auto-tag from title keywords | Deterministic rules: tokenize title, strip stopwords, group by shared stems, emit top-N tags | Zero on backfill, low on new uploads (operator confirms suggested tag) | Decent — works very well for printableprompts because titles are descriptive | Tiny — pure Python, no external deps | Trivial |
| A4 Auto-tag via embeddings + clustering | Embed each product title+description, cluster via K-means or HDBSCAN, label clusters by centroid keyword | Zero | Better than A3 on shops with cryptic titles | Moderate — embedding lib + model file (~100MB) or external API | Trivial (re-cluster) |
| A5 Auto-tag via LLM | One-shot call per product: "pick 1–3 categories from this taxonomy" | Zero | Best | High — needs LLM API, retry/timeout/cost handling, kill-switch | Trivial (re-run) |
| A6 Hybrid (A3 or A5 → operator approve) | Auto-suggest tags on product edit form; operator one-click accepts | Zero baseline + low correction | Best — operator owns final state | Same as picked auto-method | Trivial |
Reads from CLAUDE.md — "MPS uses one warm sending identity / one source of
truth / one place per fact" — argues we should pick one storage for groupings
and let mechanisms write into it. That storage is a Tag table. A1/A3/A5 all
write tags. A2 (collections) is a different primitive that we may want
on top of tags (a curated subset).
Dimension B — How groups render on home (layout)
| Option | Layout | Shopper benefit | Implementation |
|---|---|---|---|
| B1 Sectioned home (Shopify-style lanes) | One horizontal lane per category, products scroll horizontally within each lane; lanes stacked vertically | Browse by topic at-a-glance, see ≤10 per category | New template; loop tags → query per tag (capped) |
| B2 Filter chips above flat grid | Existing grid stays; chip row at top (Math · Seasonal · Literacy · ...); clicking a chip filters the grid in place (no reload — JS optional) |
Lightest visual change; preserves chronological signal | Existing template + chip strip + JS data-tag filter |
| B3 Featured + flat grid | Operator picks ≤6 "featured" products shown as large cards; rest of catalog underneath in current flat grid | No categorization needed; operator merchandises | New featured_product_ids JSON column; small template addition |
| B4 Sidebar nav | Left rail with category list; main pane shows filtered grid | Familiar pattern; bad on mobile (we have no sidebar pattern today) | Bigger template lift; mobile collapse |
| B5 Tag cloud + grid | Cloud at top sized by tag popularity, grid below | Discovery-flavoured; less directed than chips | Similar to B2 but visual variant |
| B6 Search-first | Big search bar hero, popular searches/tags chips under it, grid below | Best for shops with a known-item search pattern | Promote existing /search UI; needs popular-search data we already log in ShopSearchRequest |
A and B compose. E.g. A6 + B1 = "auto-suggest tags with operator approval, rendered as sectioned lanes." A1 + B2 = "manual tags, filter chips." Both ship.
Phased implementation (all in this ticket)
Per CLAUDE.md "Ticket Scoping" — one feature, one ticket. Phases below land incrementally but live under one MPS-24 thread.
Phase 1 — Foundation: tags + filter chips on flat grid (A1 + B2)
Smallest ship that solves the printableprompts feedback. Cuts shopper clicks from "scroll 481 items" to "click chip, scan ~50, click product."
- New
Tagmodel (id,shop_id,name,slug,created_timestamp). - New
product_tagassociation (composite PKproduct_id+tag_id). - New
Product.tagsrelationship (collection, not lazy=dynamic — small N per product). - New
Shop.home_layoutIntegercolumn, default0:0= flat (current behavior, unchanged)1= filter chips on flat grid2= sectioned lanes (Phase 2)
- New form section
home-layout-settingsinviews/shop.py+shop_settings.j2. - Tag editor: comma-separated input on product edit form (
product_edit.j2) — splits, slugifies, upsertsTagrows scoped to shop. - Bulk tag editor: small admin page at
/s/{shop_id}/tagslisting tags + product counts, click a tag → list of products with checkboxes to add/remove. (Avoids forcing operator into product-by-product.) - Home template: if
home_layout == 1, render chip strip fromshop.tags_by_popularity()(top N, capped); chip click adds?tag=<slug>to URL; server filters grid; JS enhancement does it in-place (zero navigation cost when JS is available). - Tag detail route:
/s/{shop_id}/tag/{slug}for crawlers + no-JS users (capability-driven presentation per CLAUDE.md). - Filter chips also added to
/searchresults so shopper can refine by tag after a keyword query (/search?keywords=X&tag=Y).
Phase 2 — Sectioned lanes shipped in Phase 1; auto-tag from title + description
Phase 1 already shipped sectioned-lane layout (home_layout == 2) — we
brought it forward because rendering the lanes was a one-line template
branch on top of the chip work. What remains for Phase 2 is the
deterministic title + description auto-tagger so an operator with 481
untagged products gets a working categorization in one click.
Inputs:
Product.title— full token weight × 3 (short, decisive, intentional).Product.description— raw markdown stripped of formatting, tokenised, weight × 1, capped at the first ~100 unique tokens per product so long blog posts don't drown short product copy.
Pipeline:
- Tokenize title + description → lowercased words ≥ 3 chars.
- Drop platform-default English stopwords + per-shop
tag_stopwords_jsonoverrides. For printableprompts that addswrite,room,activity,the, etc. - Stem with a simple suffix-strip (no Porter port, no new dep) —
seasonal/seasons/season→season. - Build per-stem product sets across the catalog.
- Drop stems whose slug already exists as a shop tag (we don't re-suggest already-applied categories).
- Keep stems carried by ≥ 2 products; rank by product count desc.
- For each candidate stem, label = most frequent original word for
that stem (so
valentindisplays asValentine's, notvalentin).
Surface:
- New "Suggest categories from titles + descriptions" button on
/s/{shop_id}/tags. Renders a "Suggested categories" well listing each candidate cluster — label, sample product titles, product count. - One-click apply per cluster — creates the tag + bulk-attaches every product in the cluster, all under one form POST.
- One-click dismiss per cluster — adds the stem's label to
tag_stopwords_jsonso it never resurfaces. - Standalone CLI
scripts/backfill_tags.py --shop=<id> [--dry-run]for larger shops that prefer a terminal preview.
A6 hybrid — suggest, never auto-commit. Cluster output is rendered to
the operator; nothing writes Tag / ProductTag rows until the operator
clicks Apply.
No ML, no external deps. Pure Python over Product.title +
Product.description. O(N × tokens) over a shop's catalog.
Phase 3 — ML-assisted categorization via uncloseai (A5)
Optional, off-by-default kill-switch (mirrors MPS-22 pattern):
app.features.ml_categorization.enabled default False.
- Per-product call to
uncloseai.comOpenAI-compatible endpoint we already operate — "pick 1–3 from this taxonomy (provided)." - Cheaper than vendor LLMs because we run the endpoint ourselves.
- Same approve-don't-commit UX as Phase 2 — operator owns final state.
- Backfill script
scripts/ml_tag_suggest.pyruns over a shop's catalog, writes suggestions to a newtag_suggestiontable (notproduct_tag), surfaces them in the bulk tagger for one-click accept. - We do not ship embedding-clustering (A4) — A5 is cheaper to operate given our existing uncloseai infrastructure, and the operator-approval UX is identical so we don't need both.
Phase 3 lands behind the kill-switch even when shipped. Operator opt-in required.
Shop setting toggle (the operator-facing surface)
New form section home-layout-settings, added to the existing 19 sections in
views/shop.py. UI lives in shop_settings.j2 alongside ribbon-settings.
Columns added to Shop
| Column | Type | Default | Purpose |
|---|---|---|---|
home_layout |
Integer |
0 |
0=flat, 1=filter_chips, 2=sectioned_lanes |
home_layout_tag_limit |
Integer |
8 |
Max chips / lanes to show on home |
home_layout_per_lane_limit |
Integer |
10 |
Max products per lane (B1) |
featured_product_ids_json |
UnicodeText |
"" |
JSON list of UUIDs for optional B3 hero strip; nullable, opt-in |
All server_default per CLAUDE.md SQLite migration rule.
Form UI (operator's view)
A single select for home_layout with previewable explanations:
- Flat grid (default) — every product, newest first. Same as today.
- Filter chips on flat grid — flat grid with a clickable category strip on top. Categories come from product tags.
- Sectioned by category — separate lanes per category, like a magazine rack. Best for shops with 50+ products in 4+ categories.
Plus three numeric fields (tag limit, per-lane limit, featured strip on/off). Plus a "Featured products" picker (Phase 1 ships the column + form, the rich picker is Phase 2).
Decisions (resolved at draft time — flag in review if fox disagrees)
- Many tags per product, not single category. Matches printableprompts —
a product can be both
mathandvalentines. - Tags scoped per shop, not platform-wide. Avoids collision between
unrelated shops (a music shop's
blues≠ a gardening shop'sblues). - Tag bulk editor reachable from
/actions/viewas a new.mps-buttoninaction-button-grid. - Mobile: chip strip horizontally scrolls; sectioned lanes stack as single-column below 800px (existing mobile reorder pattern in CLAUDE.md).
- Watch mode uses
discovery_ringonce a shopper enters it — sectioned home is entry-page only, no SPA JSON shape change. /search?keywords=X&tag=Y— tag filter on search results in Phase 1.- Stopwords: per-shop
tag_stopwords_jsonoverride on top of a platform-wide default list. - Phase 2 + 3 are suggest-then-approve only — never auto-commit tags.
- Naming:
TagnotCategory— tags are many-per-product and flat; categories would imply a tree we are not building.
Implementation (Phase 1 — shipped 2026-05-15)
| File | Change |
|---|---|
models/tag.py (new) |
Tag model: id, shop_id, name, slug, created_timestamp; unique (shop_id, slug); helpers get_or_create_tag, tags_by_popularity |
models/product_tag.py (new) |
ProductTag many-to-many association with (product_id, tag_id) unique constraint |
models/product.py |
Add tags association_proxy |
models/shop.py |
Add home_layout, home_layout_tag_limit, home_layout_per_lane_limit, featured_product_ids_json, tag_stopwords_json columns + is_home_flat/is_home_chips/is_home_lanes/home_layout_label/featured_product_ids/tag_stopwords helpers + tags relationship |
models/meta.py |
Register Tag / ProductTag in CLASS_TO_TABLE |
models/__init__.py |
Import tag + product_tag modules |
scripts/alembic/versions/882d68db47fa_mps_24_*.py (new) |
Idempotent migration: creates mps_tag + mps_product_tag + 5 mps_shop columns; guards via _table_exists / _column_exists (CLAUDE.md pattern) |
routes.py |
Add shop_tags + shop_tag_detail before shop_slug catch-all |
views/shop.py |
_build_home_layout_context() helper; home-layout-settings form_section handler; shop_tag_detail + shop_tags (bulk tagger) views; tag filter param on home / shop / search views |
views/product.py |
Tag handling on product edit POST — comma-separated slugify + diff |
templates/home.j2 |
Branch on shop.home_layout for chip strip / sectioned lanes / flat grid |
templates/shop.j2 |
Same branching (used by /s/{id}/{slug}) |
templates/shop_settings.j2 |
New home-layout-settings section |
templates/product_edit.j2 |
Comma-separated tag input |
templates/shop_tag.j2 (new) |
Tag detail page (works without JS) |
templates/shop_tags.j2 (new) |
Bulk tagger UI: list tags + apply/remove per product |
templates/actions_view.j2 |
Add Tags shortcut to operator action grid |
templates/styleguide.j2 |
Live tag-chip + tag-lane examples under #cards |
static/css/common.css |
.tag-chip-strip / .tag-chip / .tag-chip-active / .tag-lane / .tag-list / .tag-product-list styles — tokens only, Grid only |
static/js/tag_filter.js (new) |
Progressive enhancement: in-place chip filter via data-tag-slugs; falls back to server ?tag= |
tests/test_models.py |
TestShopHomeLayout + TestTagModel — 14 unit tests |
tests/test_functional.py |
TestHomeLayoutAndTags — 10 functional tests (settings save, tag editor, attach/detach, chip filter, tag detail) |
docs/architecture.md |
Add MPS-24 to feature matrix + ticket index |
docs/design-system.md |
Document MPS-24 chip + lane component classes |
CLAUDE.md |
New "Shop Home Layout + Tags" section + "Ticket Scoping — One Feature, One Ticket" rule |
~/git/www.makepostsell.com/index.html + pricing.html |
"Categorized Home Page" feature card + pricing list entry |
Phase 2 — shipped 2026-05-15
| File | Change |
|---|---|
lib/tag_suggest.py (new) |
Pure-function clusterer: tokenize, simple_stem, stem_bag, suggest_clusters over title (weight 3) + description (weight 1, capped at 100 unique tokens) |
scripts/backfill_tags.py (new) |
CLI: --shop=<id> previews suggestions; --apply creates tags + attaches products |
views/shop.py |
shop_tags view gained action=suggest, action=apply_suggestion, action=dismiss_suggestion; ?show_suggestions=1 triggers cluster compute |
templates/shop_tags.j2 |
"Suggest categories from titles + descriptions" button + suggestions well with one-click Apply / Dismiss per cluster |
static/css/common.css |
.tag-suggest-list / .tag-suggest-item / .tag-suggest-actions styles |
tests/test_models.py |
TestTagSuggestPureFunctions — 11 unit tests over tokenize / stem / cluster |
tests/test_functional.py |
test_suggest_clusters_renders_candidates, test_apply_suggestion_creates_tag_and_attaches_products, test_dismiss_suggestion_adds_to_stopwords, test_apply_suggestion_rejects_empty_input |
Phase 2.6b — facet nav on shop home (layout 2) + mobile SERP rows under each lane (shipped 2026-05-15)
Follow-up to 2.6 after operator review on tablet: layout 2 (sectioned
lanes) had no facet sidebar and mobile lanes were horizontal tile rows
with no description (image attached in chat shows the issue on
shop.printableprompts.com rendered on a tablet in Firefox).
| Surface | Change |
|---|---|
templates/_facet_nav.j2 (new) |
Reusable Jinja macros: facet_form(...) shared body, sidebar(...) desktop wrapper, details(...) mobile <details> accordion. Single source of truth for the controls |
templates/shop_tag.j2 |
Switched to the macro; both sidebar + details now render |
templates/home.j2 + shop.j2 |
Wrapped lanes content + flat/filtered grid in .tag-detail-layout when shop.home_layout >= 1; both facet variants render. Each lane now emits BOTH horizontal .tag-lane-grid (desktop) AND vertical .serp-list.tag-lane-rows with 6-sentence excerpts (mobile/tablet) |
views/shop.py |
_build_home_layout_context() populates facet_tags for any layout >= 1 (was only on ?tag= filter). Also wires sort + price filter on non-tag-filtered home when shopper applies them |
static/css/common.css |
New .facet-details styles (mobile accordion); aside.facet-nav hidden <800px; .tag-lane-grid hidden <800px; .serp-list.tag-lane-rows hidden ≥800px |
tests/test_functional.py |
test_shop_home_lanes_renders_facet_sidebar_and_mobile_rows |
Mobile rule: shopper opens shop.foo.com on phone, taps "Filter & sort"
to open the <details> accordion (sort, price, every category), then
scrolls a vertical SERP list with description excerpts under each tag
heading. Desktop rule: 220px left sidebar + Netflix-style horizontal
tile lanes. Same controls, same data, viewport-driven presentation.
Phase 2.6 — tag-detail facet sidebar + 6-sentence SERP excerpt (shipped 2026-05-15)
Operator feedback after Phase 2.5: tag-detail SERP rows were truncating at ~200 chars (Google-snippet feel) but printableprompts product descriptions are 4-8 sentences of classroom context that all matter to the shopper. Also missing: a way to narrow within a tag (e.g. "math products under $5") without going back to a flat grid.
| Surface | Change |
|---|---|
models/product.py |
New _strip_markdown(text) module helper. excerpt() now consumes it; new excerpt_sentences(n=6, max_chars=1500) splits on .!? and joins the first N — strips markdown first, caps at 1500 chars as a safety floor for terminator-free descriptions |
views/shop.py |
New _price_range_from_request(request) → (min_cents, max_cents). New _filter_by_price_range(products, min_cents, max_cents) applies inclusive bounds. Wired into shop_tag_detail and _build_home_layout_context (filtered shop home / search). shop_tag_detail now passes facet_tags = tags_by_popularity(...) (all tags, no limit) for the sidebar |
templates/shop_tag.j2 |
Layout split into .tag-detail-layout grid (sidebar 220px + content 1fr at ≥800px, single column below). Sidebar <form method="get"> wraps three sections: Sort dropdown, Price min/max number inputs, full Categories list with .facet-tag-active highlighting. Top .tag-chip-strip-mobile retained for mobile (sidebar hidden <800px). SERP row now calls product.excerpt_sentences(6) |
static/css/common.css |
New .tag-detail-layout + .facet-nav + .facet-section + .facet-tag-list + .facet-price-range + dark-mode overrides. Grid-only per house style |
tests/test_models.py |
New TestProductExcerpt — 13 unit tests over _strip_markdown, excerpt, excerpt_sentences (sentence count, terminator variety, markdown stripping, safety cap) |
tests/test_functional.py |
test_tag_detail_renders_facet_sidebar, test_tag_detail_price_filter_narrows_grid, test_tag_detail_excerpt_renders_six_sentences — all green |
Capability-driven: sidebar is plain HTML + GET form. JS auto-submits the
sort <select> on change; without JS, the same Apply button submits
everything. No new JS file. No breaking change to existing chip filter
flow or the search route.
Phase 2.5 — product page polish: description wrap + price-history toggle (shipped 2026-05-15)
Two product-page bugs surfaced while shopping printableprompts:
- Description text clipping right edge on mobile.
.content-carduses CSS Grid but its grid items had defaultmin-width: auto— they expanded to their content's intrinsic width, pushing the card past the viewport..content'soverflow-x: clipthen silently hid the right side of the text instead of wrapping. Fix:min-width: 0+overflow-wrap: break-wordon.content-card,.content-card- header,.content-card-body, plusword-break: break-wordon the inner<a>/<p>elements so long URLs hyphenate at any character. - Price history shown by default. The price history table
(commit
1e5fe27, 2026-02-11) was always visible to anyone who could edit the shop. Operator feedback: "wait for a sale" psychology hurts conversions; shoppers shouldn't see a timeline of past prices. AddedShop.show_price_historyBoolean (defaultFalse, server- default"0") with idempotent Alembic migrationc792642911e2. Toggle lives inribbon-settingsform. View + watch JSON now gate theprice_historylist on the toggle; template gates rendering separately as belt-and-suspenders. When off (default), nobody sees the table — including the operator on their own product page. Operator can still review history in shop analytics.
Phase 2.4 — SPA bulk tagger + Netflix-style lanes (shipped 2026-05-15)
Two improvements that compound for the operator workflow:
- SPA progressive enhancement on
/s/{shop_id}/tags. Each form (create / delete / attach / detach / apply_suggestion / dismiss_suggestion) still POSTs and 302-redirects without JS, but with JS,static/js/tag_bulk.jsintercepts the submit, sendsX-Requested-With: XMLHttpRequest, and the server returns JSON describing what changed. JS mutates the DOM in place — no full reload while the operator iterates on suggestions, applies a cluster, deletes a tag they don't like, repeats. Flash messages render as toasts via the new.tag-flashregion. Falls back to full submit iffetch()errors. - Netflix-style horizontal-scroll lanes.
.tag-lane-gridis now a horizontal-scrolling row of fixed-width tiles (grid-auto-flow: column; grid-auto-columns: minmax(160px, 200px); overflow-x: auto; scroll-snap-type: x mandatory). Each lane is visually bounded as a category, tiles snap on swipe, mobile-friendly. Tiles drop the.serpclass (the old auto-fit grid layout was fighting the new horizontal flow) but keep.serp-itemfor hover styles. Thumbnails:width: auto; max-width: 100%; max-height: 200pxper CLAUDE.md media-sizing rule. - Companion
serp-thumbnailfix (commite284f88):img.serp-thumbnailgainedwidth: auto; max-width: 100%; height: auto. On printableprompts the 1080×1080 natural thumbnails were forcing grid cells wider than the column template, collapsingauto-fit, minmax(160px, 1fr)to a one-column-per-viewport layout.
Phase 2.3 — multi-bigram supersession + apostrophe labels + top_n 100 (shipped 2026-05-15)
Phase 2.2 surfaced real categories but left residue: Color (119),
Number (100), Day (63), Room (36) — all unigrams that are fully
covered by multiple bigrams (e.g. Day is covered by Valentine's Day
Patrick's Day+ others). And bigram labels likeValentine Day/Patrick Daylost their apostrophes — operators read them as typo-broken. Three fixes:
- Multi-bigram supersession: a unigram drops when the union of
bigrams containing it covers ≥ 80% of its products. Phase 2.2 only
considered single-bigram coverage; now
Daydrops because the combined set ofValentine's Day∪Patrick's Day∪ … covers it. - Apostrophe-preserving labels: tokeniser keeps the possessive /
contraction tail (
valentine's,patrick's); stemmer strips it before matching but the label vote still wins with the readable surface form._MD_PUNCTno longer kills apostrophes. Stopword check uses the apostrophe-less base so possessives can't slip in. top_ndefault 50 → 100 for the long tail of niche categories.
Result on a Valentine's/Patrick's-heavy sample: bigrams render as
Valentine's Day, Patrick's Day (readable possessives), and the
catch-all Day unigram disappears because the two bigrams together
cover all its products.
Phase 2.2 — bigrams + title-required + bigger stopwords (shipped 2026-05-15)
Phase 2.1's max_share=0.4 filter only caught one of printableprompts'
five generic candidates (Students 53%, the others 30–32%). And single
words like First (121 products) were collapsing the natural phrase
First Grade. Three compounding fixes:
- Bigrams in
lib/tag_suggest.py: adjacent non-stopword tokens cluster as phrases.Write the Room→ bigramwrite room,First Grade Math→first grade,Valentine's Day→valentine day. Bigrams getBIGRAM_WEIGHT_MULTIPLIER ×(2×) the unigram score per product — phrases out-rank single words when both cluster equally well. URL toggle:?bigrams=0to disable. - Title-required filter (
min_title_share, default0.3): a candidate must appear in the title of at least 30% of products carrying it. Kills description-only marketing noise that doesn't belong as a category (versions,offered,engaged,during,these,check,right,well,web,help,build,time). URL knob:?min_title=0.5(stricter),?min_title=0(disable). - Expanded English stopword list (~80 → ~200 entries): adds common
filler / generic verbs / marketing fluff / content-medium words like
see,please,way,well,kind,type,set,lot,part,time,version,picture,sheet,page,theme,color,draw,line,cut,learn,teach,offer,engage,check,build,work,play,help,find,see,look,ask,give,take,tell,say— and their inflections.
Result on a printableprompts-like sample: bigrams Write Room,
Novel Study, Valentine Day rise to the top alongside unigrams
Math, Counting, Addition, Literacy. The description-only
noise (Versions, Offered, Engaged, During, These, Check,
Right) gets filtered before reaching the operator's screen.
Phase 2.1 — shop-vocabulary filter + top-N bump (shipped 2026-05-15)
Initial Phase 2 deploy surfaced the wrong candidates on
shop.printableprompts.com: Students, Resource, Activities,
Writing, Practice (each in 30–53% of products). These are shop
vocabulary — words that describe the whole shop, not categories
within it. A stem in 53% of products tells a shopper almost nothing
about which subset a product belongs to. Fix:
max_sharefilter inlib/tag_suggest.py:suggest_clusters— default0.4drops any stem in more than 40% of products as shop vocabulary. Returns a(clusters, filtered_count)tuple so callers can show "auto-dropped N common words."top_ndefault 20 → 50 so the long tail of niche categories surfaces on a 481-product catalog. Backfill CLI default also bumped.- URL knobs on
/s/{shop_id}/tags:?max_share=0.3(stricter),?max_share=1(disable),?top_n=200(show more). No DB column — power users tune in the browser. - Template note under the suggestions well reports how many stems got filtered as shop vocabulary plus the tuning hints.
- CLI flag
--max-share=0.4onscripts/backfill_tags.py. - Tests:
test_suggest_clusters_filters_shop_vocabulary+test_suggest_clusters_max_share_one_disables_filter. ExistingTestTagSuggestPureFunctionstests passmax_share=1.0(their tiny fixtures would otherwise be penalised for being small).
Tests (Phase 1)
Unit (test_models.py)
Tagcreate/slugify/uniqueness-per-shopProduct.tagscollection add/removeShop.home_layoutdefaults to0; integer round-trip 0/1/2Shop.tags_by_popularity()returns shop-scoped tag list ordered by count- Featured product ids JSON parse + roundtrip
Integration (test_integration.py)
- Operator saves tags on product edit →
product_tagrow written; comma split handles whitespace, dedupes, slugifies - Bulk tagger add/remove flow
home-layout-settingsform_section save persists all four columns
Functional (test_functional.py)
- Shop home with
home_layout=0renders.serpflat grid, no chip strip - Shop home with
home_layout=1renders chip strip + filterable grid ?tag=<slug>filters the grid server-side- Tag detail page renders products with that tag only
- Bulk tagger page loads, POSTs persist
- Mobile chip strip horizontally scrolls (CSS check — render at
<800pxviewport via testbench)
Verification
source vars.sh && make test— all pass- Local:
make serve, create a shop with 10 fake products, opt intohome_layout=1, tag productsmath/seasonal, verify chip filter works - Local: opt out (
home_layout=0), verify identical to current behavior - Push → CI green → Salt highstate → verify on my.makepostsell.com
- Send shop link to printableprompts operator for feedback; if positive, plan Phase 2 (auto-tagger) as MPS-25
Out of scope (genuinely separate tickets later)
- Tag-aware search ranking (Phase 1 adds a tag filter to
/search; tuning rank weights for tag matches vs title matches is its own ticket once we have shopper data). - Faceted filtering (price range + tag + type combined) — wait for shopper signal after Phase 1.
- Cross-shop tag discovery (browse all shops by tag) — privacy question, defer.
- Tag-based RSS / sitemap segmentation — defer until tags exist for a few weeks and the segmentation use case is concrete.
- Tag tree / nested categories — explicitly not in scope, see decision #9.
References
shop.printableprompts.comcrawled 2026-05-15 via sitemap (481 products, obvious natural categories surfaced from titles)views/shop.py:212(home),:226(shop),:275(search)models/product.py:730(get_products_from_a_shop),:740(get_products_by_keywords)templates/home.j2(current flat grid)- CLAUDE.md "Feature Kill Switches" pattern (MPS-22) — model for shop toggle
- CLAUDE.md "Capability-Driven Presentation" — tag detail page works without JS