After fixing the same dark-mode bug 4x one surface at a time, swept
it system-wide. Root pattern: var(--name, fallback) where --name is
NOT a token in tokens.css -> the light fallback applied in BOTH themes
-> dark broken. Offenders: --color-surface*, --color-border*,
--color-text*, --text-color, --surface* (none are tokens).
- Remapped all 53 occurrences in common.css to the real theme-aware
tokens (--surface-base/-dim/-container, --border-default,
--text-primary/-body/-muted), KEEPING each fallback literal
(comma-boundary sed). Diff verified: exactly 53/53 var-name-only
swaps, no fallback/structure change, line count unchanged.
- Light mode: identical where token==fallback (#fff, off-whites);
minor canonical nudges where they differ (muted #888->#666, body
#333->#515151, primary #111->#333, borders ->#e0e0e0) — the design
system's intended values, the 'light looks better' direction.
- Dark mode fixed app-wide (wells, suggest cards, counts, checksum
table, and every other surface using these vars).
- Excluded (not the bug): --shop-theme-*, --color-accent, --pico-*,
--primary-color, the --dark-* family, theme-neutral font/size vars.
- CLAUDE.md: DARK-MODE TRAP rule + pre-commit grep gate. mps-24.md
Phase 2.8r. 1151 passed (CSS-only).
Operator: make the checksum report hashes click-to-copy (they were
unstyled and overflowing the column).
- New reusable static/js/copy.js: delegated [data-copy] handler, async
Clipboard API + hidden-textarea/execCommand fallback, transient
'Copied!' feedback. Generic (not checksum-specific) so other ad-hoc
inline copy buttons can migrate later. Cache-busted (?v=git_hash).
- content.j2: each hash wrapped in button.copy-hash via a local Jinja
macro (DRY). Capability-driven: no-JS the hash text is still visible
+ selectable.
- New .checksum-table / .copy-hash CSS: table-layout:fixed +
word-break:break-all so the 64-char SHA wraps fully visible instead
of truncating; theme-aware tokens only (dark-mode correct, no
[data-theme] override that could regress like the trans-blue bug).
- /styleguide#copyhash added. Test:
test_checksum_report_is_click_to_copy. 1151 passed.
- Docs: mps-24.md Phase 2.8q.
Operator: the content page's Checksums panel should cover the whole
page (product/content file + thumbnail1 + title + description) so a
human or agent can re-hash what they see and confirm provenance.
- The async checksum infra is already generic: compute_checksums_async
hashes ANY uploaded key incl. thumbnail1, recomputed on re-upload
(no separate thumbnail auto-gen pipeline exists) — so the stored
file + thumbnail checksums are already kept current.
- Product.content_checksums(): LIVE SHA-256+MD5 of title + description
(computed on read, not stored) so it always matches the visible
text — exactly what an agent/human re-hashes to verify.
- content.j2 Checksums <details> is now a 4-asset report table
(Asset / Algorithm / Hash), SHA-256 + MD5 per asset.
- Tests: TestContentChecksums (3, no-DB). 1150 passed.
- Docs: mps-24.md Phase 2.8p.
Operator direction: stop hand-attaching tags ('ghost metadata'
invisible to the humans and agents reading the page). Derive tags
from title + description (auto-hydrate + suggest engine) instead.
- New MPS-22-style kill switch: app.features.manual_tags.enabled
(request.manual_tags_enabled, DEFAULT FALSE, env
MPS_FEATURES_MANUAL_TAGS_ENABLED, =True in test.ini so the existing
tag suite keeps passing).
- product_edit.j2: hides the chip editor + comma tags field +
product_tags.js; shows a 'tags are derived from your title &
description' note (lists current auto-derived tags read-only).
- shop_tags.j2: hides 'Create a tag' + the per-product apply (focus)
section; shows a 'How tags work' note. Suggest categories + the
category overview stay (the blessed linguistic path).
- Endpoints remain functional -> flipping the flag On is instant and
lossless ('until further notice').
- CLAUDE.md: 'Tag Philosophy' section + manual_tags row in the
kill-switch matrix. mps-24.md Phase 2.8o.
- Tests: TestManualTagsKillSwitch (fresh app, flag False; mirrors
TestKillSwitches). 1147 passed; existing tag suite green under
test.ini (flag True).
Operator: new products / edited descriptions should auto-file into the
shop's existing categories without manual tagging.
- lib/tag_suggest.py:auto_hydrate_tags(dbsession, product) — stem-match
title+description against the shop's EXISTING tag names (reuses
tokenize/simple_stem; every stem of the tag name must be in the
product stem set, so unigram 'Holiday' and phrase 'First Grade' both
work). ADDITIVE (never removes), IDEMPOTENT, never CREATES tags
(inventing categories stays suggest-then-approve).
- views/product.py:_auto_hydrate_and_flash wired into product_new
(create), product_edit_description (markup desc editor), and
product_edit (when title/desc changed, AFTER the explicit comma-tag
sync so it's purely additive).
- Tests: TestAutoHydrateTags (3, integration) +
test_new_product_auto_hydrates_existing_tag /
test_edit_description_auto_hydrates_existing_tag (functional).
1145 passed.
- Docs: CLAUDE.md (auto-apply-existing vs never-auto-CREATE
distinction), mps-24.md Phase 2.8n.
Operator: sees 'December Holiday' but not 'Holiday' on its own as a
recommended tag.
Bigram supersession dropped a unigram when the UNION of all bigrams
containing it covered >=0.8 of its products. 'holiday' spans
'december holiday' + 'winter holiday' + 'christmas holiday' -> union
covered it -> 'holiday' hidden as redundant. Changed: supersede only
when a SINGLE bigram covers >=0.8 (a true fragment, e.g.
'write'->'write room'). An umbrella unigram covered only by the union
of DISTINCT bigrams is now KEPT as its own category.
Also answered operator Q in docs: the stem engine NEVER auto-applies
to new products — suggest-then-approve only (/s/{id}/tags button or
operator-run backfill_tags CLI). Adding a product does not
auto-categorize it.
Tests: test_suggest_clusters_multi_bigram_supersedes_unigram renamed
to ..._keeps_umbrella_unigram_over_multi_bigrams (behaviour
intentionally flipped per operator); +..._surfaces_holiday_with_phrase_bigrams;
single-dominant-bigram supersession test unchanged + still green.
1140 passed. Docs: mps-24.md Phase 2.8m.
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.
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 class=tag-detail-header> from shop_tag.j2 and
the now-dead section.tag-detail-header CSS rule.
- Document <title> (in <head>) still carries the tag name for SEO.
- Tests discriminate the tag SERP via tag-detail-content instead of
tag-detail-header, and assert the header is gone. 1136 passed.
Docs: mps-24.md Phase 2.8j.
Operator: 'leave the chits on screen for all serp pages.' The
horizontal tag-chip-strip only rendered on the shop home; drilling
into a category (tag-detail SERP) dropped it, so hopping categories
meant going back.
- Extracted the chip strip (duplicated verbatim in home.j2 + shop.j2)
into a single _facet_nav.j2 chip_strip(...) macro — DRY, one source
of truth — and added it to shop_tag.j2 under the header.
- shop_tag_detail view already supplied home_chips / active_tag / sort
/ price, so this was a template-only gap. Active category chip
highlights on the SERP and carries facet_qs (sort/price compose).
- Search SERP renders home.j2 so it gets the macro for free.
Test: +test_chip_strip_stays_on_tag_detail_serp. 1136 passed.
Docs: mps-24.md Phase 2.8i.
Operator: 'switching one breaks it' — picking a category reset the
active Sort + Price. Cause: 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 nav 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. The & is HTML-escaped
to & in hrefs (Jinja autoescape) — browsers decode it fine.
Tests: +test_facet_links_preserve_sort_and_price; updated
test_tag_detail_renders_facet_sidebar +
test_facet_category_link_renders_tag_detail_not_home for the new
(correct) query-carrying behavior. 1135 passed.
Docs: mps-24.md Phases 2.8e–2.8h.
With 2.8d live the operator's Network panel proved the proxy-proof
ajax=1 signal works (real fetch to /tags -> 200, 0.7kB JSON) but also
showed 4 requests to a URL literally named [object HTMLInputElement],
with a CORRECT payload (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), so fetch(form.action) fetched that <input>
element -> 'String([object HTMLInputElement])' -> resolved to the shop
page (200 HTML, 25.9kB) -> reportFailure, no DOM change ('closer but
nothing changes on screen').
Fix: read form.getAttribute('action') (content attribute, never
clobbered) in submitForm + doReorder; build the programmatic toggle
form with setAttribute('action', ...) instead of form.action =.
No bare form.action reads remain. product_tags.js unaffected (posts
to data-product-tags-url). node --check clean; JS-only defect fix,
no Python/template/test impact.
This closes the chain: 2.8c stale cache -> 2.8d proxy-stripped
X-Requested-With -> 2.8e clobbered form.action. Docs: mps-24.md
Phase 2.8e, CLAUDE.md DOM-clobbering note.
Operator DevTools (custom domain shop.printableprompts.com) showed the
tell: bulk-tagger actions did a DOCUMENT POST -> 302 -> 200 and the
page rendered the SERVER-SIDE flash banner. That banner only survives
if the view took the non-AJAX HTTPFound branch — i.e. is_ajax() was
False: the app never saw X-Requested-With. Custom-domain shops sit
behind a Caddy reverse proxy that was not forwarding that request
header to uWSGI, so the capability-driven split ALWAYS chose 302 and
the page full-reloaded. Canonical host worked, so it looked fine.
- views/__init__.py:is_ajax() now returns True for
X-Requested-With == XMLHttpRequest OR request param ajax=1. The param
rides in the URL/body — no proxy strips it. Header kept for back-compat.
- tag_bulk.js (submitForm/doReorder/persistOrder FormData, fetchFocus
URL) and product_tags.js (post helper) now send ajax=1.
- 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 turned
every server hiccup into 'the screen keeps refreshing' and hid the
cause). safeInit() + window 'error' handler make a dead script
visible (transient '✓ Tag editor interactive' proof-of-life banner)
instead of failing silently.
- Tests: +test_ajax_param_signals_ajax_without_header,
+test_no_ajax_signal_still_redirects,
+test_ajax_focus_via_param_returns_json. 1134 passed.
Docs: mps-24.md Phase 2.8d, CLAUDE.md (is_ajax dual signal).
THE root cause of the entire 'still reloads / still not working' saga
across 2.7 -> 2.8 -> 2.8b: shop_tags.j2 (tag_bulk.js) and
product_edit.j2 (product_tags.js) loaded their <script> WITHOUT the
?v={{ request.git_hash }} cache-bust. routes.py serves /static with
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 executed, forms
fell back to native submit = full page reload, every time. Server-side
functional tests passed throughout because they have no browser cache.
Fix: append ?v={{ request.git_hash }} to EVERY static <script> include
(the established base.j2 / offer.js / pay-countdown.js convention) —
not just the two at fault but the whole latent class: tag_bulk,
product_tags, tag_filter, auction, player, sandbox, watch, signals,
comments, shop-settings. request.git_hash shifts every deploy -> URL
changes -> fresh fetch, no hard-refresh ever needed again.
Gate (must be empty):
grep -rnE '<script src="/static/js/[^"?]+\.js"' make_post_sell/templates/
The 2.8/2.8b JS (onTagFormClick unified click handler, AJAX focus,
drag-to-reorder) stands — it just was never being fetched by the
browser. 1131 tests pass. Docs: mps-24.md Phase 2.8c, CLAUDE.md
(new mandatory cache-bust convention section).
Operator: 'same with the delete button. and add' — i.e. Add / Delete
(and reorder) still full-reloaded. The generic data-tag-form
submit-EVENT interception is unreliable in the field; the explicit
click handlers (focus/drag) work. Root fix instead of patching each
button: one capture-phase CLICK handler (onTagFormClick) on every
submit control inside form[data-tag-form].
- onTagFormClick preventDefault()s so the native submit never starts
(no reload, no double-handling), runs the delete confirm via
data-confirm, routes reorder -> doReorder (in-place swap), everything
else (create/add, delete, attach/detach, apply/dismiss suggestion)
-> submitForm.
- Removed inline onclick="return confirm()" from shop_tags.j2 AND the
JS appendTagRow builder — it fought the interception; now data-confirm.
- submit listener kept only as the Enter-key fallback. Standalone
wireReorderButtons folded into onTagFormClick. Dead escapeJs removed.
- Tests: +test_ajax_delete_tag_returns_json,
+test_bulk_tagger_delete_uses_data_confirm_not_inline_onclick,
+test_ajax_reorder_arrow_returns_json_and_moves. 1131 passed.
Docs: mps-24.md Phase 2.8b.
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 of 2.6b: clicking any sidebar category landed on a
page that looked exactly like the shop home (lanes), ignoring the
tag filter.
Root cause: _facet_nav.j2 built category links as
{absolute_url}/tag/{slug}. absolute_url() includes the shop slug
(/s/{id}/{shop_slug}), so the link became
/s/{id}/{shop_slug}/tag/{slug}. The tag detail route is
/s/{shop_id}/tag/{slug} — no shop-slug segment — so that path missed
shop_tag_detail and fell through to the shop_slug catch-all
(/s/{shop_id}/{slug:.*}), rendering the shop home.
Fix: macros now take a tag_base arg =
request.shop.absolute_url(request, slug=False) (= /s/{id}).
Category links build {tag_base}/tag/{slug} — matches
shop_tag_detail exactly. The All link keeps the slugged base_url
(shop home). All three callers (shop_tag.j2, home.j2, shop.j2)
pass both.
Regression coverage:
- test_facet_category_link_renders_tag_detail_not_home (new)
- test_tag_detail_renders_facet_sidebar (asserts slug-less link,
asserts NOT slugged link)
Docs: CLAUDE.md facet-nav note, ticket Phase 2.6c.
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
Last batch promoted .shop-settings.well to a content-card and along
the way overrode the background to --surface-base (white) — fox
prefers the familiar light-gray slab. Drop the background-color +
border + dark-mode overrides; .well already sets --surface-dim and
its dark-mode rule, both of which I'm now letting through unchanged.
Kept: the rhythm + shape upgrades that actually fixed the "wells
butting together" problem — radius-lg, elevation-1 shadow,
var(--space-5) padding, var(--space-5) margin-bottom between
sections. Styleguide entry note updated to match.
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.
Two operator-workflow improvements that compound. With Phase 1+2+2.3
shipped the categorisation works; with this phase iterating on tag
suggestions feels like one fluid screen instead of a stack of POST/
redirect cycles, and the lanes home actually looks like categorised
shelves.
SPA progressive enhancement on /s/{shop_id}/tags:
- Every form (create / delete / attach / detach / apply_suggestion /
dismiss_suggestion) still POSTs and 302-redirects without JS — the
no-JS user path is unchanged. With JS, static/js/tag_bulk.js
intercepts submits, POSTs via fetch with X-Requested-With:
XMLHttpRequest, and the server returns JSON describing what
changed. Capability-driven per CLAUDE.md.
- New _is_ajax() + _tag_ajax_response() helpers in views/shop.py
pop the Pyramid flash queue into the JSON payload so JS can render
toasts (.tag-flash / .tag-flash-toast / .tag-flash-{success,error,
info}). Falls back to a full form submit if fetch() errors.
- Template gained data-tag-form="<action>" attributes for delegation
and data-tag-row / data-suggest-row / data-product-row hooks for
DOM mutation. Re-attach pass on inserted rows.
- 6 new functional tests cover each AJAX action plus the no-JS
fallback (POST without the header still 302-redirects).
Netflix-style horizontal-scroll lanes:
- .tag-lane-grid is now display: grid + 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.
- Tiles drop the .serp class (the auto-fit grid was fighting the
new horizontal flow) but keep .serp-item for hover styles.
- Thumbnails inside lane tiles use width:auto + max-width:100% +
max-height:200px per CLAUDE.md media-sizing rule.
- Mobile (≤ 800px): tiles narrow to 140-160px, swipe-friendly.
- /styleguide updated with a 5-tile lane example so future operators
see the new pattern.
1088 tests passing.
Phase 2.2 surfaced real categories but left noise:
- Color (119), Number (100), Day (63), Room (36) — unigrams fully covered
by multiple bigrams, but the prior supersession only considered one
bigram at a time so "Day" stayed even though "Valentine's Day" +
"Patrick's Day" + … collectively cover all its products.
- "Valentine Day" / "Patrick Day" labels read as typo-broken because
apostrophes were stripped during cleaning.
- 50 candidates wasn't long-tail enough on a 481-product catalog.
Three fixes:
- Multi-bigram supersession: a unigram drops when the UNION of bigrams
containing it covers ≥ 80% of its product set. Iterates all bigrams
for the unigram's stem, unions their product sets, computes coverage
once.
- Apostrophe-preserving tokeniser + stemmer: `_MD_PUNCT` no longer
strips `'`; `_WORD` regex accepts a trailing `(?:'[a-z]+)?` so
"valentine's" and "patrick's" survive as surface forms.
`simple_stem` drops the apostrophe tail before suffix-stripping so
"valentine's" stems to "valentine" — the cluster groups correctly
while the label vote wins with the readable surface form. Stopword
check uses the apostrophe-less base so possessives can't slip past
the list.
- top_n default 50 → 100. CLI default also bumped.
Tested with a Valentine's/Patrick's-heavy sample: bigrams render as
"Valentine's Day", "Patrick's Day" with proper apostrophes; the bare
"Day" unigram drops because the bigrams together cover all its
products. 1080 tests passing.
Phase 2.1's max_share=0.4 filter only caught Students (53%); the other
four printableprompts generics (Resource / Activities / Writing /
Practice, each 30-32%) slipped through. And single-word "First" was
collapsing the real phrase "First Grade" into noise. Three compounding
fixes plus a dedup pass:
- Bigram detection: adjacent non-stopword tokens cluster as phrases.
"Write the Room" → bigram "write room"; "First Grade Math" →
"first grade"; "Valentine's Day Color" → "valentine day"; "Novel
Study" → "novel study". Bigrams get 2× unigram weight per product —
phrases out-rank single words when both cluster equally well.
- Title-required filter (min_title_share, default 0.3): candidate must
appear in title of at least 30% of carrier products. Kills
description-only marketing noise like "versions", "engaged",
"offered", "during", "these", "check", "right", "well", "help",
"time" — words that live in body copy but never in product titles.
- Expanded English stopword list (~80 → ~200): adds generic verbs
("see", "ask", "give", "tell", "show"), marketing fluff ("perfect",
"best", "lovely", "amazing", "favorite"), content-medium nouns
("version", "sheet", "page", "draw", "line", "color", "theme",
"graphic", "answer", "picture"), and their inflections.
- Bigram supersession: when a bigram and one of its component
unigrams overlap ≥ 80% of products, drop the unigram. Operator sees
"Write Room" once, not "Write" + "Room" + "Write Room" three times.
URL knobs: ?max_share=0.3 / ?max_share=1 / ?min_title=0.5 /
?min_title=0 / ?bigrams=0 / ?top_n=200. CLI: --min-title-share,
--no-bigrams flags on scripts/backfill_tags.py.
On a printableprompts-shaped fixture the new defaults surface
Write Room, Novel Study, Valentine Day as bigram phrases plus Math,
Counting, Addition, Literacy, Fall — 13 clean candidates instead of
the original 50 noisy ones.
1078 total tests passing; 5 new pure-function tests cover bigrams,
title-required filter, and supersession dedup.
First Phase 2 deploy surfaced the wrong candidates on
shop.printableprompts.com: Students (53%), Resource (32%), Activities
(32%), Writing (31%), Practice (30%). These are shop vocabulary —
words that describe the whole shop, not categories within it. A stem
in 53% of products gives a shopper almost no information about which
subset a product belongs to.
- lib/tag_suggest.py: new max_share filter (default 0.4). Stems whose
product share exceeds this fraction auto-drop as shop vocabulary.
suggest_clusters now returns (clusters, filtered_count) so the UI
can show how many stems were filtered.
- top_n default 20 → 50 so the long tail of niche categories surfaces.
- views/shop.py: ?max_share=0.3 (stricter), ?max_share=1 (disable),
?top_n=200 URL knobs on the suggestions endpoint — power users tune
in the browser without redeploying. Floats over 1.0 are interpreted
as percentages (40 → 0.4) so the URL accepts either form.
- templates/shop_tags.j2: filtered-count hint with copy-paste tuning
knobs ("?max_share=0.3 stricter, ?max_share=1 to disable").
- scripts/backfill_tags.py: --max-share=0.4 CLI flag.
- Tests: test_suggest_clusters_filters_shop_vocabulary +
test_suggest_clusters_max_share_one_disables_filter. Existing pure-
function tests pass max_share=1.0 since their tiny fixtures would
otherwise be penalised for being small. 1067 total 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
New text/event-stream endpoints — /o/{offer_id}/events (buyer/seller only)
and /a/{auction_id}/events (public). Each polls the row ~every 1.5s, emits
a `data: {json}` frame on connect and whenever the state-machine state
changes, sends a heartbeat comment, then closes after ~25s so the browser
EventSource reconnects — "bounded" because uWSGI is sync (~16 worker
threads) and a long-lived SSE would starve the pool. Shared helper
lib/sse.py (sse_response / event_stream); it uses its own short-lived DB
session per poll (request.dbsession is already closed by pyramid_tm by the
time the streaming generator runs). Timings come from settings
(app.sse.hold_seconds / app.sse.poll_interval_seconds; test.ini sets them
tiny so the streaming tests finish in ~0.06s).
Client: auction.js opens the EventSource and feeds each frame into its
existing applyState(); it falls back to polling /a/{id}.json every 5s
where EventSource is unavailable. offer.js opens the EventSource on the
offer page and reload()s on a state change (the whole layout depends on
state / can_act). offer.j2 carries data-offer-state. Caddy auto-detects
text/event-stream and stops buffering — no Salt change.
Tests: 4 new functional tests (both endpoints stream the right
content-type + a state frame; 404 for outsiders / unknown ids). 994 passed.
- Offer history & offer page show the buyer's display name (User.display_name
= the public `name` handle; `full_name` is private) linked to a profile
page — never the email. _serialize_offer drops buyer_email; events carry
actor_name/actor_handle/actor_id, header carries buyer_name/buyer_handle.
- New public profile page: GET /profile/{handle} (views/user.py:user_profile,
template profile.j2). Shows gravatar (User.gravatar_url(size) — forced
identicon unless the user opted into Gravatar), member-since, owned/edited
shops, and a <details> "Show email" that is server-gated: only the user
themselves, or a shop owner/editor viewing in that shop's context
(?shop={shop_id}) when the profile user has transacted there (an offer or
an invoice).
- New operator offers inbox: GET /s/{shop_id}/offers (@shop_editor_required,
shop_offers.j2) — open offers first, each row links to /o/{id} and the
buyer's profile. Reachable from /actions/view via a new "Offers" button
(shown when shop.offer_enabled).
- /actions/view rebuilt: one flat .action-button-grid (Grid auto-fit,
minmax(15rem,1fr)) inside a properly-padded .action-columns well — fixes
the off-balance two-column layout and buttons overflowing the well; no
<br> spacers. Styleguide gains profile-card and action-button-grid
patterns.
- offer.j2: buyer name shown (linked to profile); "Buyer:"/"Seller:" message
lines renamed "Buyer note:"/"Seller note:" to disambiguate.
Tests: 11 new functional tests (profile render + email gating, offers inbox,
actions button, styleguide). 989 passed.
- offer.j2 renders a state-aware notice (.offer-state-notice, .alert
variants) above the action forms: declined / withdrawn / expired /
accepted (+ pay-now hint for buyer) / your-turn / waiting — so the
viewer always understands the offer state without depending on a flash
a JS redirect would skip. _serialize_offer now exposes is_declined,
is_withdrawn, is_expired, is_pending, is_countered, is_accepted.
- Shop-settings Make-an-Offer section restyled with the new
.settings-form / .settings-form-grid / .settings-field /
.settings-field-hint system (two-up grid, per-field hints, submit
pinned right). Added a styleguide entry under #forms.
- Reworded the section blurb: auto-declined offers are NOT silent — the
buyer is told their offer was too low; only the seller isn't pinged.
- Fixed --color-text-muted typo (→ --text-muted) on .offer-js-flash-info.
Tests: TestOfferRoutes gains 3 state-notice render tests;
TestSettingsFormStyleguide covers the styleguide + live shop-settings
markup and asserts the old wording is gone. 978 passed.
Every bid/buy-now/watch and offer open/counter/accept/decline/withdraw
POST now works as a plain browser submit: flash + 302 redirect to the
auction/offer page. JSON is returned only when the request carries
X-Requested-With: XMLHttpRequest. Adds offer.js progressive-enhancement
layer (mirrors auction.js); pay-now CTA on accepted offers; .offer-js-flash
styling; grid layout for offer/action forms. offer_accept emails the
buyer only on the transition into ACCEPTED.
Tests: TestOfferRoutes/TestAuctionRoutes now drive the JSON path via an
AJAX helper; new TestOfferNoJsFallback/TestAuctionNoJsFallback cover the
plain-POST redirect path. 973 passed.
All transactional mail now sends From app.email.sender (default
no-reply@origin.makepostsell.com) instead of per-shop no-reply@<domain>,
with the shop name (or email.from_name) as the display name. The origin
identity is DKIM-signed (d=makepostsell.com) and SPF-authorized and
relays via mx1's warm IP, so operator custom-domain shops stop getting
spam-foldered. format_from_header() builds the From; send_email() gained
a from_name kwarg. Reply-To / per-shop contact email still TODO.
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.
Adds a public/private key pair authentication system and REST API endpoints
for programmatic product and content management. Designed for CI/CD pipelines
(permacomputer.com image hosting).
Auth: HMAC-SHA256 signed requests using public/private key pairs.
The secret key never travels over the wire. Replay window: 300 seconds.
Endpoints:
POST /api/v1/products create product (fiat/crypto priced)
POST /api/v1/content create content (free)
GET /api/v1/products/{id} get product
GET /api/v1/content/{id} get content
POST /api/v1/products/{id}/upload-url presigned S3 POST for direct upload
POST /api/v1/content/{id}/upload-url presigned S3 POST for direct upload
POST /api/v1/products/{id}/files/confirm confirm upload, register metadata
POST /api/v1/content/{id}/files/confirm confirm upload, register metadata
Key management UI in shop settings. Secret shown once on generation.
Migration: mps_api_key table (id, shop_id, public_key, secret_key, label,
created_timestamp, last_used_timestamp, is_active)
Tests: 12 MpsApiKey unit tests, 8 REST API functional tests (269 total passing)
Add Security section to CLAUDE.md documenting both CWE-407 surfaces:
- Search/feed endpoints (fixed, commit f9cbebb)
- Bleach HTML sanitization: O(2^N) on crafted HTML, no input cap in MPS
Add docs/poc-cwe407.py: proof-of-concept timing harness covering
rbox-search, rbox-page, rbox-dump, mps-search, mps-sitemap vectors.
Authorized use only — run against own staging/dev instance.
Design doc for ingesting WordPress sites into MPS shops. Covers two
input modes (REST API + WXR XML), 4-phase HTML conversion pipeline,
content/media/comment mapping, CLI interface, competitive analysis,
and future enhancements. Includes rendered dot diagrams for the
architecture overview and HTML conversion detail flow.
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.
Variable-amount gift cards purchasable with any payment method.
Code-based redemption at checkout (applied to cart like coupons).
Partial use across multiple purchases, never expire. Shop owners
control min/max amounts and can disable individual cards.
Models: GiftCard, GiftCardTransaction, CartGiftCard + migration.
Views: purchase page, cart apply/remove, shop admin manage/detail/toggle.
Templates: gift_card.j2, gift_card_manage.j2, gift_card_detail.j2.
Cart integration: gift cards deduct after coupons in all checkout paths.
Tests: 10 new unit tests covering model logic (677 total pass).
Architecture diagrams, export pipeline, face detection pipeline,
filter preset reference table, CORS requirements, localStorage keys,
mobile behavior, and stacking with shop color filter.