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>, and the view loaded+rendered ALL products on
EVERY GET. On a 481-product catalog every tag click reloaded a
multi-MB page. The forms were AJAX; the dominant workflow was not.
2. Drag-to-reorder never existed: shop_tags.j2 shipped draggable=true +
a handle + help text, but tag_bulk.js had ZERO drag handlers.
Fix:
- shop.py:shop_tags — all_products loads only when focus_tag or
show_suggestions (bare GET is light). New AJAX branch: is_ajax +
?focus=slug -> JSON {focus, products:[{id,title,url,attached}]}.
- shop_tags.j2 — stable [data-focus-section] (always in DOM, hidden
until focused); ?focus= chips carry data-tag-focus-link. No-JS
unchanged (real navigation, server renders the section).
- tag_bulk.js — wireFocusLinks() intercepts chip clicks, fetchFocus()
+ renderFocus() swap the list in place, active-chip + history
pushState/popstate, real-navigation fallback. wireDragAndDrop()
HTML5 DnD -> persistOrder() POSTs action=set_order&tag_slugs=…
(view already supported it) + re-syncs up/down disabled states.
.tag-list-dragging CSS added.
- Tests: TestProductTagsSpa +4 (ajax focus json, unknown-slug null,
set_order persists positions, bare GET no catalog). 1128 passed.
Docs: mps-24.md Phase 2.8, architecture.md, design-system.md, CLAUDE.md.
Deferred: AJAX 'Suggest categories' link (occasional click, not hot path).
Operator report: adding/removing a tag on the product edit page
refreshed the whole screen. Tags lived only as a comma-separated
<input name=tags> inside the big product form, so any tag change
needed a full Save Settings POST + page reload.
- New route/view: product_tags -> /p/{id}/tags (before product_slug
catch-all), @shop_editor_required + @trial_active_required.
action=add (get_or_create_tag + attach) / action=remove (detach).
AJAX (X-Requested-With) -> JSON, no reload; plain POST -> 302 back
to edit (no-JS still works). Rebuilds discovery ring like product_edit.
- Shared is_ajax() in views/__init__.py (single source of truth;
shop.py:_is_ajax delegates — bulk tagger behaviour unchanged).
- product_edit.j2: comma field kept as no-JS path; js-only chip
editor added. product_tags.js reveals chips, demotes raw input to
hidden, keeps it in lock-step so a later full Save is a no-op.
- .tag-chip-removable family in common.css (tokens-only, Grid-only,
always-visible remove button) + /styleguide#tagchips.
- Harden tag_bulk.js: init() binds the delegated submit listener
unconditionally (no early-return that could strand the bulk-tagger
SPA into full reloads).
- Tests: unit (slug dedupe invariant), integration
(TestProductTagAddRemoveIntegration), functional (TestProductTagsSpa
incl. bulk-tagger-AJAX-returns-JSON regression guard). 1124 passed.
Docs: architecture.md, design-system.md, CLAUDE.md, mps-24.md.
Operator review on tablet showed two gaps in the 2.6 ship:
- Shop home (layout 2 lanes) had no facet sidebar — only tag detail did
- Mobile lanes were horizontal Netflix-style tile rows with no
description visible at all
This batch extends the facet experience across every page where the
operator opted into categorization (home_layout >= 1):
- New templates/_facet_nav.j2 with three macros (facet_form, sidebar,
details). One source of truth for the controls, three variants of
the wrapper. shop_tag.j2 refactored to import the macro.
- home.j2 + shop.j2 now wrap content in .tag-detail-layout when
home_layout >= 1, rendering both the desktop sidebar and the mobile
<details> accordion. CSS toggles visibility per viewport.
- Each lane in layout 2 now emits BOTH horizontal tiles AND vertical
.serp-list-row markup with 6-sentence excerpts. CSS shows tiles
>=800px, SERP rows <800px. Tablet / phone shoppers see image +
title + price + description excerpt under each tag heading.
- views/shop.py: facet_tags is populated for any home_layout >= 1
(was only on ?tag= filter); sort + price now also filter the
non-tag-filtered home grid when the shopper applies them.
Native HTML. No JS dependency. Same controls everywhere.
Test: test_shop_home_lanes_renders_facet_sidebar_and_mobile_rows.
Docs: CLAUDE.md MPS-24 section, architecture matrix, ticket Phase 2.6b.
Surface the new tag-detail facet nav (sort + price + categories) and
6-sentence SERP excerpt in two places future readers will look:
- docs/architecture.md feature toggle matrix gets two rows
- styleguide.j2 gets a live demo of the facet sidebar so the pattern
is documented in the single source of truth for components
Two product-page issues surfaced while shopping printableprompts on
mobile. Both fixed in one commit since they're tightly scoped to the
product page experience.
Description text clipping the right edge on mobile:
- .content-card uses CSS Grid but its grid items had default
min-width: auto, so they expanded to their content's intrinsic
width — long unbreakable tokens (URLs, etc.) pushed the card
wider than the viewport. Then .content's overflow-x: clip
silently hid the right side instead of wrapping the text.
- Add min-width: 0 + overflow-wrap: break-word to .content-card,
.content-card-header, .content-card-body. Add word-break:
break-word to inner <a> / <p> so 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.
- New Shop.show_price_history Boolean (default False, server-default
"0") with idempotent Alembic migration c792642911e2.
- Toggle lives in the existing ribbon-settings form section.
- views/product.py (public view) + views/watch.py JSON gate the
price_history list on the toggle. Template product.j2 also gates
rendering as belt-and-suspenders.
- Edit page (also views/product.py:product_edit) intentionally
remains always-on — the operator needs price audit access from
their own admin surface regardless of the shopper-facing toggle.
- New shop matrix entry in docs/architecture.md.
- 2 new functional tests (default-off + toggle round-trip).
1090 tests passing.
Operator with 481 untagged products (printableprompts.com) gets a
one-click path to a usable categorization without hand-tagging each
product. Strictly suggest-then-approve — nothing writes Tag or
ProductTag rows until the operator clicks Apply on a cluster.
- lib/tag_suggest.py: pure-function clusterer. Tokenize title (weight 3)
+ description (weight 1, capped at 100 unique tokens per product),
strip markdown / URLs / HTML, English + per-shop stopwords, simple
suffix-strip stemmer, group by stem, drop stems matching existing
tag slugs, rank by product count, label each cluster with the most
frequent original word for its stem. No new deps, no ML.
- scripts/backfill_tags.py: CLI preview + --apply for a single shop.
- views/shop.py: shop_tags gains action=apply_suggestion (creates tag +
bulk-attaches every product in cluster) and action=dismiss_suggestion
(adds the cluster's words to shop.tag_stopwords_json so it never
resurfaces). ?show_suggestions=1 triggers the cluster compute.
- templates/shop_tags.j2: "Suggest categories from titles + descriptions"
button + suggestions well with per-cluster sample titles, Apply, and
Dismiss buttons.
- 15 new tests (11 unit over tokenize / stem / cluster + 4 functional
over the suggest/apply/dismiss flow). 1064 total passing.
On a printableprompts-style sample the clusterer surfaces Math, Reading,
Literacy, Seasonal, Novel, Activities, Comprehension — matching what an
operator would manually pick.
Operator feedback on shop.printableprompts.com flagged our flat default
home page as the reason for considering a move to Shopify. This adds an
opt-in home_layout selector with the navigation primitives shoppers expect
from a modern catalog — fewer clicks to a relevant product.
Phase 1 shipped (default unchanged for every existing shop):
- New Tag + ProductTag models, shop-scoped, many-per-product, flat (no tree)
- Shop.home_layout (0=flat / 1=chips / 2=lanes) plus tag/lane caps, optional
featured strip, and per-shop tag stopwords for the Phase 2 auto-tagger
- home-layout-settings form section in shop_settings.j2
- Bulk tag editor at /s/{shop_id}/tags with apply/remove per product
- Public tag detail page at /s/{shop_id}/tag/{slug} (works without JS)
- Comma-separated tag input on the product edit form
- home.j2 / shop.j2 branch on layout — chip strip for layout 1, sectioned
lanes for layout 2, flat unchanged for layout 0
- /search results page also receives the chip strip so shoppers can narrow
keyword results by tag
- static/js/tag_filter.js progressively enhances chip clicks into in-place
grid filtering via data-tag-slugs — zero navigation cost, capability-driven
fallback to ?tag= URL nav with no JS
- New chip / lane CSS in common.css — tokens only, Grid only (no flexbox)
- Live tag-chip + tag-lane examples in /styleguide under #cards
- Idempotent Alembic migration creates 2 tables + 5 shop columns with
server_default + _table_exists / _column_exists guards
- 24 new tests across unit + functional layers (1049 total passing)
- New "Ticket Scoping — One Feature, One Ticket" rule in CLAUDE.md;
Phase 2 (deterministic auto-tag from titles) and Phase 3 (uncloseai-
backed ML categorization behind a kill switch) stay under this ticket
Karaoke (MPS-18) and torrent (MPS-19) are broken in production. Adding
two global feature flags off by default so neither feature surfaces in
UI or accepts route traffic until they're fixed.
Pattern mirrors app.features.popout_player.enabled — reified request
properties (request.karaoke_enabled, request.torrent_enabled) read from
ini settings. Templates wrap UI in {% if %}, views return HTTPNotFound
on form sections + routes, view contexts blank out feature-specific
keys when flag off so SPA navigation does not try to render them.
test.ini sets both flags True so existing feature tests keep working.
TestKillSwitches builds a fresh app with both False and verifies the
off path: form_section POSTs return 404, settings page omits sections,
karaoke route 404s, landing page omits karaoke marketing copy.
GET /s/{shop_id}/torrent-backfill-status is shadowed by an earlier
shop_slug catch-all route in production — pre-existing routing defect
that MPS-19 needs to fix when it lands.
MPS-18: diagnose and fix broken karaoke vocal isolation pipeline.
MPS-19: diagnose and fix broken torrent / magnet link distribution.
MPS-20: propose eBay-style auction house mode (bidding, reserve,
soft-close, proxy, buy-now).
MPS-21: propose make-an-offer mode (counter/accept/decline/expire
state machine, auto-accept and auto-decline thresholds).
Each ticket carries proposal, full file list, models, state machine,
GTM plan, and unit/integration/functional test requirements.
architecture.md ticket index extended with MPS-17 (was missing) plus
the four new tickets.
Add docs/karaoke-pipeline.md covering the full streaming pipeline from
MPS through unsandbox API to zerotrust container and back. Includes two
Graphviz dot diagrams (rendered to SVG):
- karaoke-pipeline.dot: full system flow across MPS, API, pool, container
- karaoke-ondemand.dot: watch mode on-demand user flow
Update architecture.md feature toggle matrix and related docs table.
Update CLAUDE.md karaoke section with streaming path and on-demand info.