Operator: '100 suggested tags is not enough, we need way more — missing holiday holidays'. Two separate 100 caps in lib/tag_suggest.py: - DESCRIPTION_TOKEN_CAP 100 -> 400: long teaching-resource descriptions truncated cross-cutting words like holiday/holidays/ seasonal before they were ever counted, so those clusters never surfaced (verified: neither word is a stopword; season/seasonal/ valentine only appear in comments, not ENGLISH_STOPWORDS). - DEFAULT_TOP_N 100 -> 500: a 481-product catalogue has valid niche groups ranking past the old cut. The min_products / max_share / min_title_share filters already strip noise, so a high ceiling surfaces the long tail without resurfacing junk. - views/shop.py ?top_n= clamp 500 -> 5000 for operator headroom. Both caps stay bounded (deduped unique tokens / no unbounded query — CWE-407-safe). Test: +test_deep_description_word_surfaces_after_cap_raise (word past the old 100-token cap now clusters). 1137 passed. Docs: CLAUDE.md Phase 2, mps-24.md Phase 2.8k.
49 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.8 — bulk tagger: AJAX tag-focus + real drag-to-reorder + page-weight fix (shipped 2026-05-16)
Operator (printableprompts.com, 481 products) reported the bulk tagger "still refreshing the whole screen" and "dragging tags doesn't work" after 2.7. Two real defects the 2.7 static audit missed:
- Tag-focus was a full-page navigation. Clicking a tag chip is
<a href="?focus=slug">— a real reload. On a 481-product catalog the view loaded + rendered every product on every GET, so each tag click reloaded a multi-MB page. The forms were AJAX; the dominant workflow action (focus a tag → assign products) was not. - Drag-to-reorder never existed.
shop_tags.j2shippeddraggable="true", a ≡ handle, and "drag rows when JS is enabled" help text, buttag_bulk.jshad zero drag handlers — only the ↑/↓ buttons worked. The affordance lied.
Fix:
- View (
shop.py:shop_tags):all_productsnow loads only whenfocus_tag or show_suggestions(bare GET is light). New AJAX branch:is_ajax + ?focus=<slug>→ JSON{focus:{name,slug}, products:[{id,title,url,attached}]}. - Template (
shop_tags.j2): focus section is now a stable[data-focus-section](always in DOM,hiddenuntil focused);?focus=chips carrydata-tag-focus-link+data-tag-slug. No-JS unchanged: the link is a real navigation, server still renders the section. - JS (
tag_bulk.js):wireFocusLinks()intercepts chip clicks →fetchFocus()→renderFocus()swaps the product list in place, updates the active chip,history.pushState(back/forward viapopstate), graceful real-navigation fallback.wireDragAndDrop()implements HTML5 DnD on the tag rows →persistOrder()POSTsaction=set_order&tag_slugs=…(view already supported it) and re-syncs ↑/↓ disabled states..tag-list-draggingCSS added.
Tests (test_functional.py::TestProductTagsSpa):
test_ajax_focus_returns_product_list_json,
test_ajax_focus_unknown_slug_returns_null_focus,
test_ajax_set_order_persists_tag_positions,
test_bulk_tagger_bare_get_renders_without_products.
Deferred (occasional click, not the hot path): AJAX-ifying the "Suggest categories" link — still a full navigation 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
lib/tag_suggest.py: DESCRIPTION_TOKEN_CAP 100 → 400 (long
teaching-resource descriptions truncated cross-cutting words like
holiday/holidays/seasonal before they were ever counted —
verified neither word is a stopword) and DEFAULT_TOP_N 100 → 500
(481-product catalogue had valid groups ranking past the cut; the
min_products / max_share / min_title_share filters already strip
noise, so a high ceiling surfaces the long tail safely). ?top_n=
URL clamp raised 500 → 5000 for headroom. Both caps stay bounded
(deduped unique tokens / no unbounded query — CWE-407-safe). Test:
test_deep_description_word_surfaces_after_cap_raise.
Phase 2.8j — drop redundant tag-detail header (shipped
2026-05-16): operator: remove the tag title + "← All products" from
the top of the tag SERP. With the always-on chip strip (active
category highlighted + an "All" chip) the tag-detail-header
<h1>/back-link was redundant. Removed the section from
shop_tag.j2 and the dead section.tag-detail-header CSS. The
document <title> (in <head>) still carries the tag name for SEO.
Tests updated to discriminate the tag SERP via tag-detail-content
instead of tag-detail-header (+ assert the header is gone).
Phase 2.8i — chip strip on every SERP page (shipped 2026-05-16):
operator: "leave the chits on screen for all serp pages." The
horizontal tag-chip-strip rendered only on the shop home; drilling
into a category (tag-detail SERP shop_tag.j2) dropped it, so hopping
categories meant going back. Extracted the (duplicated) chip strip
from home.j2/shop.j2 into a single _facet_nav.j2 chip_strip(...)
macro and added it to shop_tag.j2 under the header. The
shop_tag_detail view already supplied home_chips / active_tag /
sort / price, so this was a template-only gap; the active category
chip highlights on the SERP and carries facet_qs. Search SERP
already renders home.j2 so it gets the macro for free. Test:
test_chip_strip_stays_on_tag_detail_serp.
Phase 2.8h — facets compose, not clobber (shipped 2026-05-16):
operator: "switching one breaks it" — picking a category reset the
active Sort + Price. Cause: the facet category links / "All" link /
top chips / lane "See all" all pointed at a bare
{tag_base}/tag/{slug} with no query string, so a click dropped
?sort= / ?price_*. (The Sort <select>/Price form already
preserved the tag via action="" + path, and each other as sibling
fields — only category navigation lost state.) Fix: one shared
facet_qs(sort_key, price_min, price_max) macro in _facet_nav.j2
returning the ?sort=…&price_min=…&price_max=… suffix; appended to
every category/All/chip/See-all href in _facet_nav.j2, home.j2,
shop.j2. URL state, not localStorage (operator's suggestion):
shareable, no-JS, back-button correct, and the destination SERP
already reads those params. Tests:
test_facet_links_preserve_sort_and_price,
updated test_tag_detail_renders_facet_sidebar /
test_facet_category_link_renders_tag_detail_not_home.
Phase 2.8g — facet sidebar nested scrollbar removed (shipped
2026-05-16): ul.facet-tag-list had max-height:60vh; overflow-y:auto → ugly inner scrollbar on the sidebar / mobile
accordion. Dropped; the list flows full-height and the page scrolls.
Phase 2.8f — chips navigate to the tag SERP like the left nav
(shipped 2026-05-16): the top chip strip's category links already
pointed at {tag_base}/tag/{slug}, but tag_filter.js decided
whether to intercept by inspecting chips[0] — the "All" chip,
which points at the shop home (no /tag/) — so it never detected the
real category links and always did the in-place "default cards"
hide/show. Now it scans ALL chips: any /tag/ href → bail → full
navigation, so a chip behaves exactly like its matching left-nav
category (server renders the SERP in the shop's home_layout).
Phase 2.8e — form.action DOM-clobbered by <input name=action> (shipped
2026-05-16): with 2.8d live, the operator's Network panel showed the
proxy-proof ajax=1 working (a real fetch to tags → 200, 0.7 kB
JSON) — but also four requests to a URL literally named
[object HTMLInputElement], and the Payload tab confirmed the body
was correct (action=delete, tag_slug=…, ajax=1). Cause: every
tag form contains <input type="hidden" name="action">. A named form
control clobbers the built-in HTMLFormElement.action property
(DOM clobbering / named-property override), so fetch(form.action)
fetched that <input> element — coerced to the string
"[object HTMLInputElement]" → resolved against the shop, returned
the 25.9 kB shop page (200, non-JSON) → reportFailure (no DOM
change: "closer but nothing changes"). Fix: read
form.getAttribute("action") (content attribute, never clobbered),
never form.action; build programmatic forms with
setAttribute("action", …). tag_bulk.js: submitForm, doReorder,
swapToggleForm. (No browser test harness exists; guarded via
CLAUDE.md note + the ajax=1 server tests from 2.8d.)
Phase 2.8d — the OTHER root cause: proxy strips X-Requested-With
(shipped 2026-05-16): even after 2.8c (fresh JS confirmed loading,
tag_bulk.js?v=<hash> 200 in the operator's Network panel),
Add/reorder/etc still full-reloaded. Operator's Network tab showed the
tell: a document POST /s/{id}/tags → 302 → GET → 200,
and the page rendered the server-side flash banner ("Moved
'Emergent Reader' up.") — which only survives if the view took the
non-AJAX HTTPFound branch, i.e. is_ajax() returned False: the
app never saw X-Requested-With. Custom-domain shops
(shop.printableprompts.com) sit behind a Caddy reverse proxy that
was not forwarding that request header to uWSGI, so the
capability-driven split always chose the 302 path and the JS
await res.json() then fell back to a full submit.
Fix: a second, proxy-proof AJAX signal. views/__init__.py:is_ajax
now returns True for X-Requested-With == XMLHttpRequest OR
request.params.get("ajax") == "1". The param rides in the URL/body,
which no proxy strips. tag_bulk.js (submitForm / doReorder /
persistOrder FormData, fetchFocus URL) and product_tags.js (post
helper) now send ajax=1. The header is kept for back-compat.
Also hardened tag_bulk.js: zero code paths full-reload on
failure anymore — reportFailure() surfaces HTTP status +
content-type + body snippet as a visible banner (the old
form.submit() / location fallbacks were turning every server
hiccup into the "screen keeps refreshing" symptom and hiding the
cause); safeInit() + a window 'error' handler banner make a dead
script visible instead of silent. Tests:
test_ajax_param_signals_ajax_without_header,
test_no_ajax_signal_still_redirects,
test_ajax_focus_via_param_returns_json.
Phase 2.8c — THE root cause (shipped 2026-05-16): every "still
reloads / still not working" report across 2.7 → 2.8 → 2.8b was the
same defect — shop_tags.j2 (tag_bulk.js) and product_edit.j2
(product_tags.js) loaded their <script> without
?v={{ request.git_hash }}. /static is served
cache_max_age=3600, so the operator's browser kept the stale JS for
up to an hour after every deploy: the new SPA code never ran, forms
fell back to native submit = full reload, every time — while
JS-blind server tests passed. Fix: append ?v={{ request.git_hash }}
to all static <script> includes (the existing base.j2 /
offer.js convention), not just the two — same latent bug class
across tag_filter, auction, player, sandbox, watch,
signals, comments, shop-settings. Grep gate:
grep -rnE '<script src="/static/js/[^"?]+\.js"' templates/ must be
empty. (The 2.8/2.8b JS work stands; it just was never being fetched.)
Phase 2.8b (shipped 2026-05-16): the generic data-tag-form
submit-event interception proved unreliable in the field —
operator reported Add / Delete / reorder all still full-reloaded
while the explicit click handlers (focus/drag) worked. Root fix:
one unified capture-phase click handler (onTagFormClick)
on every submit control inside form[data-tag-form]. It
preventDefault()s (native submit never starts → no reload, no
double-handling), runs the delete confirm via data-confirm
(inline onclick="return confirm()" removed from shop_tags.j2
and the JS appendTagRow builder — it fought the interception),
routes reorder → doReorder (in-place swap) and everything else
(create/add, delete, attach/detach, apply/dismiss suggestion) →
submitForm. The submit listener is kept only as the Enter-key
fallback; escapeJs removed (dead after the onclick→data-confirm
switch). Tests: test_ajax_reorder_arrow_returns_json_and_moves,
test_ajax_delete_tag_returns_json,
test_bulk_tagger_delete_uses_data_confirm_not_inline_onclick.
Phase 2.7 — per-product SPA tag chips on product edit (shipped 2026-05-16)
Operator report: adding/removing a tag on the product edit page
"refreshed the whole screen". Root cause: tags lived only as a
comma-separated <input name="tags"> inside the big product form, so
any tag change required a full "Save Settings" POST + page reload. (The
/s/{id}/tags bulk tagger was already a working SPA — separate surface.)
Fix — capability-driven, mirroring the comments/offers/bulk-tagger pattern:
- New route + view:
product_tags→/p/{product_id}/tags(registered before theproduct_slugcatch-all),@shop_editor_required+@trial_active_required.action=add(name) →get_or_create_tag+product.tags.append;action=remove(tag_slug) → detach. AJAX (X-Requested-With) → JSON{status, messages, tag, changed}; plain POST → flash + 302 back to product edit (no-JS still works). Rebuilds the shop discovery ring when watch mode is on, exactly likeproduct_edit. - Shared helper:
views/__init__.py:is_ajax()— single source of truth for the capability split;shop.py:_is_ajaxnow delegates to it (DRY; bulk tagger behaviour unchanged). - Template (
product_edit.j2): keeps the commatagsinput as the no-JS path; adds ajs-onlychip editor (.product-tag-chips).product_tags.jsreveals the chips, demotes the raw input totype=hiddenbut keeps it in lock-step with the chips so a later full "Save Settings" is a no-op, never a stale revert. - JS (
static/js/product_tags.js): fetch +X-Requested-With, add via Enter/button, remove via delegated click, toast flash, JSON content-type guard, graceful non-reloading failure. - CSS / styleguide:
.tag-chip-removablefamily added tocommon.css(tokens-only, Grid-only, always-visible remove button)- live
/styleguide#tagchipssection +docs/design-system.mdrows.
- live
- Bulk tagger hardening:
tag_bulk.jsinit()no longer early-returns before binding the delegated submit listener (a latent way the SPA could silently fall back to full reloads).
Tests:
| Layer | Cases |
|---|---|
test_models.py |
test_same_name_different_case_yields_same_slug (slug dedupe invariant the idempotent add relies on) |
test_integration.py |
TestProductTagAddRemoveIntegration: add/remove round-trip, idempotent-on-slug, remove-unknown-noop |
test_functional.py |
TestProductTagsSpa: AJAX add/remove → JSON, no-JS add → 302 + persists, idempotent re-add, non-editor 302 (no DB change), edit page renders chip editor, bulk-tagger-AJAX-returns-JSON regression guard |
Phase 2.6c — fix sidebar category links falling through to shop-home catch-all (shipped 2026-05-15)
Defect from 2.6b operator review: clicking any category in the desktop
sidebar navigated to a page that "looked exactly like home" (screenshots
in chat). Root cause: the facet macro built category links as
{absolute_url}/tag/{slug} where absolute_url includes the shop slug
(/s/{id}/{shop_slug}). The resulting path /s/{id}/{shop_slug}/tag/{slug}
does not match the tag detail route /s/{shop_id}/tag/{slug} — it
falls through to the shop_slug catch-all (/s/{shop_id}/{slug:.*})
and renders the shop home / lanes, ignoring the tag entirely.
Fix: _facet_nav.j2 macros take a new tag_base arg =
request.shop.absolute_url(request, slug=False) (= /s/{id}, no shop
slug). Category links now build {tag_base}/tag/{slug} which matches
shop_tag_detail exactly. The "All" link still uses the slugged
base_url (shop home). All three call sites (shop_tag.j2, home.j2,
shop.j2) updated. Regression test:
test_facet_category_link_renders_tag_detail_not_home +
hardened assertions in test_tag_detail_renders_facet_sidebar.
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