make_post_sell/docs/tickets/mps-24.md
russell@unturf.com 2301ab33cc
fix: MPS-24 auto-suggest — way more clusters, stop missing 'holiday'
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.
2026-05-16 16:19:31 -04:00

49 KiB
Raw Blame History

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_slugviews/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) at models/product.py:730 — single query, ordered by updated_timestamp DESC, no grouping, no filtering, no pagination.
  • Visible features: optional sales-stats banner, flat .serp grid, 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:275get_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 Tag model. No Collection model. No Category table. No tag-style fields on Product. No tag-aware search.
  • No LLM or embeddings infrastructure inside MPS (karaoke is audio ML routed to unsandbox; lib/sentiment.py is 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.

  1. Opt-in. Default home_layout = 0 (flat) keeps every existing shop pixel-identical.
  2. 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.
  3. 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).
  4. Operator can correct mistakes — auto-grouping is never final state.
  5. 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 13 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 Tag model (id, shop_id, name, slug, created_timestamp).
  • New product_tag association (composite PK product_id + tag_id).
  • New Product.tags relationship (collection, not lazy=dynamic — small N per product).
  • New Shop.home_layout Integer column, default 0:
    • 0 = flat (current behavior, unchanged)
    • 1 = filter chips on flat grid
    • 2 = sectioned lanes (Phase 2)
  • New form section home-layout-settings in views/shop.py + shop_settings.j2.
  • Tag editor: comma-separated input on product edit form (product_edit.j2) — splits, slugifies, upserts Tag rows scoped to shop.
  • Bulk tag editor: small admin page at /s/{shop_id}/tags listing 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 from shop.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 /search results 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:

  1. Tokenize title + description → lowercased words ≥ 3 chars.
  2. Drop platform-default English stopwords + per-shop tag_stopwords_json overrides. For printableprompts that adds write, room, activity, the, etc.
  3. Stem with a simple suffix-strip (no Porter port, no new dep) — seasonal/seasons/seasonseason.
  4. Build per-stem product sets across the catalog.
  5. Drop stems whose slug already exists as a shop tag (we don't re-suggest already-applied categories).
  6. Keep stems carried by ≥ 2 products; rank by product count desc.
  7. For each candidate stem, label = most frequent original word for that stem (so valentin displays as Valentine's, not valentin).

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_json so 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.com OpenAI-compatible endpoint we already operate — "pick 13 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.py runs over a shop's catalog, writes suggestions to a new tag_suggestion table (not product_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)

  1. Many tags per product, not single category. Matches printableprompts — a product can be both math and valentines.
  2. Tags scoped per shop, not platform-wide. Avoids collision between unrelated shops (a music shop's blues ≠ a gardening shop's blues).
  3. Tag bulk editor reachable from /actions/view as a new .mps-button in action-button-grid.
  4. Mobile: chip strip horizontally scrolls; sectioned lanes stack as single-column below 800px (existing mobile reorder pattern in CLAUDE.md).
  5. Watch mode uses discovery_ring once a shopper enters it — sectioned home is entry-page only, no SPA JSON shape change.
  6. /search?keywords=X&tag=Y — tag filter on search results in Phase 1.
  7. Stopwords: per-shop tag_stopwords_json override on top of a platform-wide default list.
  8. Phase 2 + 3 are suggest-then-approve only — never auto-commit tags.
  9. Naming: Tag not Category — 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:

  1. 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.
  2. Drag-to-reorder never existed. shop_tags.j2 shipped draggable="true", a ≡ handle, and "drag rows when JS is enabled" help text, but tag_bulk.js had zero drag handlers — only the ↑/↓ buttons worked. The affordance lied.

Fix:

  • View (shop.py:shop_tags): all_products now loads only when focus_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, hidden until focused); ?focus= chips carry data-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 via popstate), graceful real-navigation fallback. wireDragAndDrop() implements HTML5 DnD on the tag rows → persistOrder() POSTs action=set_order&tag_slugs=… (view already supported it) and re-syncs ↑/↓ disabled states. .tag-list-dragging CSS 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}/tags302 → 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 defectshop_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 reorderdoReorder (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 the product_slug catch-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 like product_edit.
  • Shared helper: views/__init__.py:is_ajax() — single source of truth for the capability split; shop.py:_is_ajax now delegates to it (DRY; bulk tagger behaviour unchanged).
  • Template (product_edit.j2): keeps the comma tags input as the no-JS path; adds a js-only chip editor (.product-tag-chips). product_tags.js reveals the chips, demotes the raw input to type=hidden but 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-removable family added to common.css (tokens-only, Grid-only, always-visible remove button)
    • live /styleguide#tagchips section + docs/design-system.md rows.
  • Bulk tagger hardening: tag_bulk.js init() 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

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-card uses CSS Grid but its grid items had default min-width: auto — they expanded to their content's intrinsic width, pushing the card past the viewport. .content's overflow-x: clip then silently hid the right side of the text instead of wrapping. Fix: min-width: 0 + overflow-wrap: break-word on .content-card, .content-card- header, .content-card-body, plus word-break: break-word on 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. Added Shop.show_price_history Boolean (default False, server- default "0") with idempotent Alembic migration c792642911e2. Toggle lives in ribbon-settings form. View + watch JSON now gate the price_history list 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.js intercepts the submit, sends X-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-flash region. Falls back to full submit if fetch() errors.
  • Netflix-style horizontal-scroll lanes. .tag-lane-grid is 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 .serp class (the old auto-fit grid layout was fighting the new horizontal flow) but keep .serp-item for hover styles. Thumbnails: width: auto; max-width: 100%; max-height: 200px per CLAUDE.md media-sizing rule.
  • Companion serp-thumbnail fix (commit e284f88): img.serp-thumbnail gained width: auto; max-width: 100%; height: auto. On printableprompts the 1080×1080 natural thumbnails were forcing grid cells wider than the column template, collapsing auto-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 like Valentine Day / Patrick Day lost 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 Day drops because the combined set of Valentine'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_PUNCT no longer kills apostrophes. Stopword check uses the apostrophe-less base so possessives can't slip in.
  • top_n default 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 3032%). 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 → bigram write room, First Grade Mathfirst grade, Valentine's Dayvalentine day. Bigrams get BIGRAM_WEIGHT_MULTIPLIER × (2×) the unigram score per product — phrases out-rank single words when both cluster equally well. URL toggle: ?bigrams=0 to disable.
  • Title-required filter (min_title_share, default 0.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 3053% 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_share filter in lib/tag_suggest.py:suggest_clusters — default 0.4 drops 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_n default 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.4 on scripts/backfill_tags.py.
  • Tests: test_suggest_clusters_filters_shop_vocabulary + test_suggest_clusters_max_share_one_disables_filter. Existing TestTagSuggestPureFunctions tests pass max_share=1.0 (their tiny fixtures would otherwise be penalised for being small).

Tests (Phase 1)

Unit (test_models.py)

  • Tag create/slugify/uniqueness-per-shop
  • Product.tags collection add/remove
  • Shop.home_layout defaults to 0; integer round-trip 0/1/2
  • Shop.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_tag row written; comma split handles whitespace, dedupes, slugifies
  • Bulk tagger add/remove flow
  • home-layout-settings form_section save persists all four columns

Functional (test_functional.py)

  • Shop home with home_layout=0 renders .serp flat grid, no chip strip
  • Shop home with home_layout=1 renders 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 <800px viewport via testbench)

Verification

  1. source vars.sh && make test — all pass
  2. Local: make serve, create a shop with 10 fake products, opt into home_layout=1, tag products math / seasonal, verify chip filter works
  3. Local: opt out (home_layout=0), verify identical to current behavior
  4. Push → CI green → Salt highstate → verify on my.makepostsell.com
  5. 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.com crawled 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