Commit graph

1243 commits

Author SHA1 Message Date
2e5a8ba496
crypto_watcher: wire wallet_dist.enabled into Monero + Doge factories
Until now the wallet_dist.enabled = true flag in production.ini was
ornamental — only the mps_wallet_dist_health CLI smoke test ever
constructed an ErlangDist client. crypto_watcher's hot polling path
called get_client_from_settings() / get_dogecoin_client_from_settings()
which unconditionally built local HTTP clients against monero.rpc_url
and dogecoin.rpc_url. The toggle did not toggle.

This commit wires both factories to honor wallet_dist.enabled. Three
transports per coin, checked in order:

  1. Mock (monero.mock / dogecoin.mock) — test-only, unchanged
  2. Cluster RPC (wallet_dist.enabled = true) — returns
     ErlangDistMoneroClient / ErlangDistDogecoinClient that forward
     calls to Wallet.Service on cammy via portal's Wallet.Bridge
  3. Local HTTP daemon — current default, backwards-compatible

Existing erldist_clients module already had the dist client classes
(drop-in replacement surfaces matching MoneroClient / DogecoinClient);
they just weren't wired anywhere except the smoke CLI.

wallet_dist_config.py gains load_wallet_dist_config_from_settings()
alongside load_wallet_dist_config() (ini path). Both call shared
_build_from_dict() so validation stays in one place. The factory uses
the settings variant since it has the Pyramid settings dict already
and doesn't want to re-parse the ini.

## Rollback procedure

Two layers; both designed to be safe and reversible.

### Layer 1 — code rollback

Set wallet_dist.enabled = false in production.ini and restart
crypto_watcher. Factories drop back to local-daemon path
transparently. No code revert needed; the toggle IS the rollback.

The local monero-wallet-rpc and dogecoind services on mps-uwsgi1 must
stay running through the experiment window — they are the rollback
target. Do not decommission them in the same change as the cutover.

### Layer 2 — data rollback

CryptoPayment rows created during the cluster-RPC period reference
(account_index, subaddress_index) coordinates in CAMMY'S wallet
address space. If the toggle is flipped back without further action,
those rows would resolve to wrong addresses in MPS's local wallet.

Mitigation: pre-cutover, confirm no in-flight CryptoPayment rows.
During the experiment window, treat any pending rows as
force-expirable. If rollback is needed:

  1. Flip toggle off
  2. Force-expire any CryptoPayment rows created post-cutover (their
     cammy-side addresses become orphaned but no funds are at risk
     since the experiment assumes pre-cutover zero open payments)
  3. Resume local-wallet operation

This rollback path is only viable while the local daemons are still
running. Decommissioning them is a separate, later step taken only
after the experiment has soaked.

## Tests

14 new tests in test_crypto_clients_factory.py covering routing
branches for both coins:

  - Mock overrides everything (precedence pinned)
  - wallet_dist.enabled = true returns dist client + kwargs flow through
  - wallet_dist.enabled = false falls back to local
  - Missing wallet_dist key falls back to local (backwards-compat)
  - Local path errors clearly when rpc_url is also missing
  - load_wallet_dist_config_from_settings parity with ini loader
    (disabled / missing-keys / cookie-reading)

Dist client tests use monkeypatch to stub the constructor — we don't
want the factory test opening a real socket to find a Wallet.Bridge.

All 110 existing crypto_watcher tests still green.

## What this does NOT do

  - Does not flip wallet_dist.enabled in production.ini. Cutover is a
    deliberate ops step taken after a no-open-payments confirmation.
  - Does not migrate any DB rows. The shop-to-subaddress mapping
    stays as-is; new payments simply land on cammy's wallet from the
    cutover point forward.
  - Does not decommission local daemons. They remain as rollback
    targets until the cluster path has soaked.
2026-06-17 18:54:45 -04:00
7ca9073ef6
1.2.1: wallet_dist clients honor cfg.registered_name (was hardcoded)
ErlangDistMoneroClient / _DistConn had WALLET_SERVICE_NAME = "Elixir.Wallet.Service"
hardcoded as the REG_SEND target, ignoring the registered_name read from
production.ini's [app:main] wallet_dist.registered_name.

Smoke-test against production portal failed for hours with
'peer closed after 0/4 bytes' — the bytes WERE arriving at portal, dist
handshake succeeded, but portal had no process registered as
:'Elixir.Wallet.Service' (the actual registered name is
:'Elixir.Wallet.Bridge', because the bridge GenServer forwards from MPS
to wallet@cammy.foxhop.net). REG_SEND to an unregistered name is
silently dropped by the dist driver — exactly the symptom we saw.

Confirmed by direct Python call to :'Elixir.Wallet.Bridge' from the
same MPS host succeeding while the CLI failed; only difference was the
hardcoded target name.

  WalletDistConfig.client_kwargs() now includes registered_name.
  _DistConn / ErlangDistMoneroClient / open_wallet_conn accept it as
  an optional kwarg (defaults to legacy WALLET_SERVICE_NAME for
  backwards-compat with callers that don't pass it).

This was NOT a TLS 1.3 bug — earlier debugging hypothesized that but
was misled by the symptom. Locking TLS 1.2 max made the direct test
work because the direct test was already calling the right name.
2026-06-17 10:58:45 -04:00
7bb263c046
deps: erldistpy >= 0.1.7 — OTP 26 wallet_dist compatibility
erldistpy 0.1.6 silently dropped its first REG_SEND against OTP 26
peers (handshake "succeeded" then the link closed with no response).
0.1.7 declares DFLAG_MANDATORY_25_DIGEST in our advertised flag set
and accepts dist-header-framed replies, which lets the call round-trip.

Bumps requirements.py3.txt floor + regenerates requirements-prod.lock
so the next CI artifact carries the fix into env.tar.gz.
2026-06-17 08:53:27 -04:00
2905827c3f
CLAUDE.md: document the requirements.py3.txt ↔ requirements-prod.lock relationship
Two-file dependency setup tripped me twice in one session:
  1. Added erldistpy to requirements.py3.txt without running make pins-lock.
     Result: env.tar.gz shipped without erldistpy, prod crypto_watcher
     hit ModuleNotFoundError at runtime.
  2. Added click as a CLI dep that wasn't in any pin file at all.
     Result: 516 import errors in CI tests across unrelated modules.

Documents the two-file model + the obligation to run `make pins-lock`
on every requirements.py3.txt edit. Notes the CLI-convention point too
(stdlib argparse, not click).
2026-06-17 07:45:29 -04:00
dd701fb319
deps: regenerate requirements-prod.lock to include erldistpy + transitive deps
The wallet_dist work added erldistpy to requirements.py3.txt (b6268a9)
but requirements-prod.lock was never regenerated. CI's
install-source-prod uses --require-hashes against the lock, so the
runtime venv shipped without erldistpy regardless of which py3.txt
line was added.

Re-ran `make pins-lock` (uv pip compile --generate-hashes). New lock
includes erldistpy==0.1.6 and whatever transitive deps it pulls.
Verified mps-uwsgi1 was missing the module after 1.2.0 highstate;
this lock regen unblocks the wallet_dist smoke test.
2026-06-17 07:44:35 -04:00
c97a7c6ee1
wallet_dist_health: argparse instead of click — match repo convention
Every other CLI in the repo (crypto_watcher/__init__, digest_sender,
offer_tick, fix_s3_acls, lib/un.py) uses stdlib argparse. The
mps_wallet_dist_health module imported click out of habit, breaking
that pattern and forcing a new install_requires entry that wasn't
declared (516 ModuleNotFoundError test errors on master CI).

Swap to argparse, drop the click dep. Same interface:
  mps_wallet_dist_health [config_path] [--timeout SECS]
Same exit codes (0 OK, 2 config, 3 RPC failure). Defaults preserved.
2026-06-17 07:30:28 -04:00
e96c32502e
deps: add click — required by mps_wallet_dist_health CLI
CI on 02f55a9 + 3f1a092 failed with 516 ModuleNotFoundError: No module
named 'click' across test_functional / test_integration. wallet_dist_
health.py imports click at module-top; pytest collection imports
crypto_watcher → wallet_dist_health → click, which isn't installed in
the CI test env.

click is usually a transitive dep of pyramid in most installs, but the
CI runner's env is stricter. Explicit declaration unblocks 1.2.0
artifact build.
2026-06-17 07:27:54 -04:00
3f1a092d2e
release: 1.2.0 — wallet_dist transport (erldistpy) + mps_wallet_dist_health CLI
Bumps version to force the CI artifact to rebuild with the new
source_hash so salt-managed MPS deployments re-fetch env.tar.gz.

Includes (since 1.1.6):
  - eae627e crypto_watcher: dist clients for XMR/BTC/LTC/DOGE via erldistpy
  - b6268a9 deps: erldistpy >=0.1.6 (PyPI) — drop-in for new dist clients
  - 02f55a9 crypto_watcher: wallet_dist config + mps_wallet_dist_health CLI

mps_wallet_dist_health is the standalone CLI used to smoke-test the
TLS-dist transport against wallet@cammy.foxhop.net independent of
the crypto_watcher daemon.
2026-06-17 07:19:07 -04:00
02f55a9b66
crypto_watcher: wallet_dist config + mps_wallet_dist_health CLI
Joining make_post_sell prod to our unsandbox Erlang cluster as a
hidden Python node so the watcher can route wallet RPC through
Wallet.Service on cammy via erldistpy. Two pieces in this commit:

  wallet_dist_config.py
    Reads wallet_dist.* keys from production.ini's [app:main]:
      - enabled
      - our_node_name / peer_name / peer_host / registered_name
      - cert / key / ca (all three or none — partial config errors)
      - cookie_file (path; value read at load time, never logged)
    Returns a frozen WalletDistConfig dataclass with .client_kwargs()
    that maps to ErlangDist*Client constructor signatures.

  wallet_dist_health.py + mps_wallet_dist_health console_script
    Single-shot smoke test: load config, open dist connection, call
    Wallet.Service.monero_get_height + is_synced, print result. Exit
    code 0 = wire works; 2 = config problem; 3 = dist call failed.
    Lets ops verify on prod via:
      mps_wallet_dist_health /opt/make_post_sell/production.ini

erldistpy imported lazily inside the connection helper — deploys
that stay on direct-HTTP wallet clients don't need erldistpy
resolvable.

11 new tests for wallet_dist_config covering enabled/disabled,
TLS-all-or-none, missing-required-key, unreadable cookie file.
Existing 44 erldist_clients tests still green (total 55 dist-related
tests, plus the 110 existing crypto_watcher tests untouched).
2026-06-16 15:51:57 -04:00
b6268a95ee
deps: erldistpy >=0.1.6 (PyPI) — drop-in for new dist clients
The erldist_clients module (lib/crypto_watcher/erldist_clients.py)
imports erldistpy lazily, so deploys that stay on the existing
direct-HTTP wallet clients are unaffected. Shops opting in to the
dist transport will resolve erldistpy via pip on next deploy.

Not bumped to a new MPS tag — that comes after a prod soak confirms
the dependency installs cleanly and existing crypto_watcher behavior
is unchanged.
2026-06-16 15:23:40 -04:00
753642c70f
ci: pin twine<6 — classic ~/.pypirc auth on build runner
twine 6 (Sep 2025) auto-detects GitLab CI and refuses to fall back to
~/.pypirc, requiring PYPI_ID_TOKEN (Trusted Publishing OIDC). Pin <6
to keep the runner's ~/.pypirc fallback working until we migrate all
python/* repos to Trusted Publishing as a coordinated change.
2026-06-16 14:11:59 -04:00
cd11eefb38
ci: twine check before pypi upload, non-interactive
twine check validates the built artifacts' metadata (long_description
rendering, required fields) so a broken render hits CI logs instead of
landing on PyPI as a malformed project page.

--non-interactive guards against a wedged prompt hanging the pipeline.
2026-06-16 13:55:25 -04:00
eae627e065
crypto_watcher: dist clients for XMR/BTC/LTC/DOGE via erldistpy
Drop-in alternatives to MoneroClient + DogecoinClient (plus first-class
BTC + LTC clients) that route wallet RPC through Wallet.Service on
cammy via Erlang dist instead of hitting daemons directly.

Why both transports coexist:
  - Direct-HTTP clients keep working unchanged — no shop is forced over
  - Each shop opts in by config; once a shop's on dist, its daemons
    don't have to live on the MPS droplet
  - End goal: drain wallet daemons off prod MPS, right-size hosting

What's here:
  - ErlangDistMoneroClient mirrors MoneroClient's public surface
    (create_subaddress, get_transfers_for_subaddr, get_height,
     is_synced, get_sync_status, get_balance, sweep_subaddress,
     transfer, plus new sweep_subaddress_with_details +
     get_tx_confirmations helpers)
  - ErlangDistBitcoinClient / ErlangDistLitecoinClient /
    ErlangDistDogecoinClient share a UTXO base class, identical to
    DogecoinClient's surface (getnewaddress, getreceivedbyaddress,
    sendmany, sendtoaddress, gettransaction, listtransactions,
    is_synced, get_sync_status, plus new get_tx_confirmations)
  - refund_with_fee_split — one transaction for customer refund plus
    optional shop fee output, covers all four coins
  - refund_economically_viable — pure helper matching
    Wallet.Service.refund_economically_viable?/3 default ($0.069)

Wire shape against Wallet.Service handle_call clauses:
  {:monero, :function_name, [args...]}
  {:utxo, :coin_atom, :function_name, [args...]}
  {:refund_with_fee_split, coin, refund_addr, refund_amt, shop_addr, shop_amt, opts}

Lazy import: erldistpy is only imported when a dist client is
constructed, so MPS deploys that stay on HTTP transport don't need it
installed. Secrets discipline matches existing clients — cookie + TLS
material are paths the caller resolves; no PEM bytes enter Python.

44 unit tests covering each client's tuple shape + reply decoding +
error mapping (CallTimeout → WalletDistError, NodeError →
WalletDistError). The cross-stack wire contract is already verified
in wallet.unsandbox.com's test/functional suite (Python erldistpy →
Erlang dist → Wallet.Service → mock daemons → reply back), so MPS-side
tests focus on the adapter.

Existing 110 crypto_watcher tests untouched and still green.

Not in this commit:
  - No watcher orchestration changes (still uses direct clients)
  - No CryptoProcessor model column / Alembic migration
  - No factory wiring per-shop transport selection
That's the next discrete ticket so each piece is reviewable on its own.
2026-06-16 13:39:16 -04:00
6e37c8b233
product edit: restore no-JS comma tags input in bottom Tags well
CI caught a regression from the Tags-well move (c5d53c0): the no-JS
<input type='text' name='tags'> field that the settings POST handler
reads (views/product.py: 'if "tags" in request.params') was dropped
when the manual-mode block left the parent form.

Restored the field inside the manual_tags_enabled branch and added id
='product-settings-form' to the title/description/visibility form, then
used HTML5 form='product-settings-form' on the input so it lives in the
bottom Tags well visually but submits with Save Settings semantically.
test_product_edit_renders_chip_editor green; full TestProductTagsSpa
class (19 tests) green.
2026-06-15 20:55:19 -04:00
c5d53c084a
product edit: Tags is its own well at bottom of page (below Price History)
Pulled the Tags block out of the settings form entirely and made it a
standalone <section class='product-tags well2 edit-card-full'> placed
after Price History — the very last well on the page. Default explainer
(manual_tags off) reads as a pure tip about title/description-driven
categorization. Manual-mode kept the chip editor which already AJAXes to
/p/{id}/tags, so it no longer relies on the parent settings-form submit.
2026-06-15 20:30:00 -04:00
70c3e647d1
product edit: move Tags well to bottom of form, just above Save Settings
The 'Tags are derived from your title & description' explainer was
crowding the visibility radios. Moved it to sit immediately above the
Save Settings submit so the form reads top-down as input → settings →
explain → save.
2026-06-15 19:54:26 -04:00
7c4959946b
supply-chain: hash-pin prod dependencies (requirements-prod.lock)
Prod previously installed via 'pip install .' (reads unbounded requirements.py3.txt)
plus 'pip install --upgrade -r requirements-prod.txt' — every deploy re-resolved to
whatever PyPI served, unverified. A poisoned release of any dep (stripe, boto3,
sqlalchemy, pyramid...) would land in a payment app.

- requirements-prod.in: source (runtime + prod-server deps)
- requirements-prod.lock: 68 pkgs pinned to exact versions + SHA256 (854 hashes),
  generated with uv pip compile --generate-hashes
- install-source-prod now: pip install --require-hashes -r requirements-prod.lock
  then pip install --no-deps . — no floating resolve at deploy time
- make pins-lock regenerates the lock deliberately

Validated locally: lock installs under --require-hashes, app imports, 791 logic
tests pass under resolved versions (functional tests need MPS_TEST_* env, gated in CI).
2026-05-21 08:55:33 -04:00
64bc435a00
fix: MPS-24 — systemic dark-mode token sweep (close the whole bug class)
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).
2026-05-18 13:21:40 -04:00
b39efcad4e
fix: MPS-24 — Suggest-categories cards broken in dark mode
Operator screenshot: candidate-category cards render as WHITE cards
with invisible light labels in dark mode.

Root cause (same class as the well bugs): li.tag-suggest-item used
non-existent tokens var(--color-surface-2)/var(--color-border) ->
fell back to light literals in dark mode (white card), while the
label text inherited the dark theme's light colour -> invisible.

- li.tag-suggest-item: --surface-container bg + --border-light border
  + explicit --text-body colour (all carry dark values).
- div.tag-suggest-samples + span.tag-list-count: --color-text-muted
  (not a token) -> --text-muted (theme-aware). Fixes the per-cluster
  AND per-tag (All-tags list) counts in dark mode too.
CSS-only, cache-busted; no test pins these.
2026-05-18 13:01:38 -04:00
4a47b716ac
fix: MPS-24 — checksum 'Asset' column nowrap so 'Description' fits
Operator: 'Description' label was wrapping (Descriptio / n). Asset
column -> 7.5rem + white-space:nowrap (same fix as Algorithm column).
CSS-only, cache-busted.
2026-05-18 12:41:38 -04:00
be7c491a6a
fix: MPS-24 — checksum table: SHA-256 label nowrap + hash actually wraps
Operator screenshot: 'Algorithm'/'SHA-256' wrapped to two lines and
the SHA hash was cut off.

- Algorithm column: width 6rem + white-space:nowrap so the header and
  'SHA-256' stay on one line.
- copy-hash grid: minmax(0,1fr) track + min-width:0 on the button and
  the <code> (grid items default to min-width:auto, which forced the
  64-char SHA to overflow/cut instead of wrapping). word-break +
  overflow-wrap:anywhere now wrap the full hash inside the cell.
CSS-only, cache-busted.
2026-05-18 12:21:48 -04:00
60f5472546
style: MPS-24 — checksum report asset label 'Product file' -> 'Product'
Operator: the row label should just say 'Product', not 'Product file'.
Template copy only; no test pins the string.
2026-05-18 09:19:08 -04:00
5888848999
feat: MPS-24 — click-to-copy checksum hashes + styled report table
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.
2026-05-18 09:10:34 -04:00
52e6c57145
fix: MPS-24 — dark-mode wells washed pale by trans-blue.png overlay
Operator: the tag page's wells aren't coloured for dark mode.

[data-theme=dark] .well set background-color: #2d3748 but then painted
background-image: url(/static/img/trans-blue.png) — an 81-byte 1x1
tiled pixel — OVER it, washing the dark colour out everywhere generic
.well is used (tag page, etc.). The edit-page wells looked right only
because their more-specific rule uses the background: shorthand, which
incidentally cleared the image.

Fix: dark .well/.well2 now use background: shorthand (clears the
legacy overlay) -> --surface-container (#21262d, a distinct elevated
card vs the #0d1117 body) + a token border + soft elevation,
consistent with the fixed edit-page wells. No test pins the old
styling. CSS-only, cache-busted.
2026-05-18 06:33:17 -04:00
bc1c727a11
feat: MPS-24 — Checksums as a verifiable page report
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.
2026-05-17 13:41:45 -04:00
3467909b80
feat: MPS-24 — manual tags are ghost metadata; hide behind a flag
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).
2026-05-17 12:08:55 -04:00
d6ae00073f
fix: MPS-24 — edit-page well dark-mode color (was glaring white)
Operator: a .well on the product edit page lost its dark-mode color in
a recent upgrade that improved light mode.

div.edit-page > section.well2 used var(--surface, #fff) and
var(--color-border, #eee) — but neither --surface nor --color-border
is a real token in tokens.css, so BOTH fell back to the light literals
(#fff/#eee) in dark mode too -> white cards on the dark edit page.
Switched to --surface-base (#FFFFFF light / #0d1117 dark) and
--border-light (#eeeeee light / #5599dd dark): light mode is byte-for-
byte identical, dark mode now correct. CSS-only, cache-busted.
2026-05-17 11:13:36 -04:00
2bc1e20958
feat: MPS-24 — auto-hydrate products into existing tags on create/edit
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.
2026-05-17 11:13:36 -04:00
60a93de763
fix: MPS-24 auto-suggest — keep umbrella unigrams (surface 'holiday' standalone)
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.
2026-05-17 10:28:28 -04:00
bda90119d8
style: MPS-24 — widen SERP right rail further (340 -> 420px)
Operator follow-up: rail still not fat enough. >=1100px rail column
340 -> 420px; rail card thumb 76 -> 96px to match. CSS-only,
cache-busted.
2026-05-17 10:10:47 -04:00
8b36fd0310
style: MPS-24 — drop the 'Featured' rail heading (it lies on random fallback)
Operator: don't label the rail 'Featured' — it falls back to a random
rotation when nothing is curated, so the heading is dishonest there.

- Removed the <h2>Featured</h2> from the featured_rail macro; cards
  stand on their own. aria-label 'Featured products' -> neutral
  'More from this shop' (accurate in both curated + random states).
- Removed the now-dead h2.serp-rail-title CSS rule + styleguide
  heading.
- Test asserts the heading is gone (serp-rail-title / >Featured<
  absent) while the rail itself still renders. 1139 passed.

CSS/templates cache-busted.
2026-05-17 09:14:33 -04:00
66a76b5d50
style: MPS-24 — fatter SERP right rail + slightly larger thumbnails
Operator tuning after 479dc0e:
- SERP right rail column 260px -> 340px (>=1100px); rail card thumb
  56px -> 76px to match the wider rail.
- serp-list thumbnail column bumped up a notch (still well below the
  original 140/200/260/320): 88->110, 110->145, 130->175, 150->210
  across the container tiers.
CSS-only; common.css is ?v={{ request.git_hash }} cache-busted.
2026-05-17 08:56:21 -04:00
479dc0ed23
feat: MPS-24 — smaller SERP thumbnails + featured/random right rail
Operator: smaller SERP thumbnails + a right column (featured products,
fallback random; all SERP + shop home).

- Thumbnails: serp-list thumb column 140/200/260/320 -> 88/110/130/150
  across container tiers (frees room for body + the new rail).
- _featured_rail_products(request, shop) in shop.py: shop's curated
  featured_product_ids (public/ready/deduped/capped); random
  public-ready fallback so it's never empty; bounded func.random()
  query (CWE-407-safe). Injected into _build_home_layout_context
  (home/shop/search) + shop_tag_detail ctx.
- Shared _facet_nav.j2:featured_rail macro, rendered as the 3rd child
  of the existing .tag-detail-layout on shop_tag.j2 / home.j2 / shop.j2
  (within show_facet_nav).
- CSS: <1100px rail is full-width beneath results; >=1100px 3-col
  facet | results | sticky rail; <800px stacks. Grid-only, tokens.
  /styleguide#serprail added.
- Tests: +test_serp_featured_rail_renders_with_random_fallback,
  +test_serp_featured_rail_prefers_curated_featured;
  test_tag_detail_price_filter_narrows_grid rescoped to the results
  grid (rail is unfiltered discovery by design). 1139 passed.

Docs: mps-24.md Phase 2.8l, design-system.md.
2026-05-16 16:50:41 -04:00
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
68a1309c83
style: MPS-24 — drop redundant tag-detail header from the SERP top
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.
2026-05-16 15:53:11 -04:00
09f5a68428
feat: MPS-24 — chip strip stays on every SERP page
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.
2026-05-16 11:46:16 -04:00
3bb05e4b5d
fix: MPS-24 — facets compose; sort+price survive switching category
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 &amp; 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.
2026-05-16 11:22:23 -04:00
bcd8c2471b
fix: MPS-24 — top chips navigate to the tag SERP like the left nav
Operator: the top chips should do what the new left-nav category links
do (navigate to the per-tag SERP rendered in the shop's configured
home_layout), not the in-place 'default cards' hide/show.

Root cause: tag_filter.js decided whether to intercept by checking
chips[0].href for '/tag/'. chips[0] is the 'All' chip, which points at
the shop home (shop_url, no '/tag/'), so the category chips' real
{tag_base}/tag/{slug} hrefs were never detected → tag_filter.js always
intercepted → in-place card filter. Now scan ALL chips: if any links
to /tag/<slug>, bail and let full navigation happen, so a chip behaves
exactly like its matching left-nav category link. JS-only defect fix;
tag_filter.js is ?v={{ request.git_hash }} cache-busted.
2026-05-16 11:02:30 -04:00
98939fbc80
style: MPS-24 — remove nested scrollbar on facet sidebar category list
ul.facet-tag-list had max-height:60vh + overflow-y:auto, producing an
ugly inner scrollbar on the shop-home facet sidebar (and the mobile
details accordion). Drop the constraint so the category list flows at
full height and the PAGE scrolls — no nested scrollbar. CSS-only;
common.css is already ?v={{ request.git_hash }} cache-busted.
2026-05-16 10:54:46 -04:00
7836035d1a
fix: MPS-24 Phase 2.8e — form.action DOM-clobbered by <input name=action>
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.
2026-05-16 10:32:50 -04:00
8e31124ca8
fix: MPS-24 Phase 2.8d — proxy-proof AJAX signal (the actual root cause)
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).
2026-05-16 10:16:37 -04:00
cd5ea68fe3
fix: MPS-24 Phase 2.8c — cache-bust ALL static JS (THE root cause)
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).
2026-05-16 09:39:17 -04:00
b15c0a0f38
fix: MPS-24 Phase 2.8b — one unified AJAX click handler for all bulk-tagger actions
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.
2026-05-16 09:05:14 -04:00
155f7ff66f
fix: MPS-24 Phase 2.8 — bulk tagger AJAX tag-focus + real drag-to-reorder
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).
2026-05-16 08:31:03 -04:00
f591620424
feat: MPS-24 Phase 2.7 — per-product SPA tag chips on product edit
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.
2026-05-16 07:58:25 -04:00
48a5eb715d
fix: link tag chip strip to the facet left-nav (same targets)
The chip strip linked to ?tag=<slug> (in-place filtered shop home)
while the facet sidebar/details Categories list linked to
{tag_base}/tag/<slug> (the canonical tag detail page). Clicking the
same category in the two navs took you to two different pages with
independently-computed active states — they were never in sync.

Point the chip strip at the same targets the facet nav uses:
  - "All"      -> shop home ({{ shop_url }})  (unchanged)
  - category   -> {{ tag_base }}/tag/{{ slug }}  (was ?tag={{ slug }})
Lane "See all ->" links moved the same way for consistency.

tag_filter.js already keeps full navigation for /tag/ hrefs (it bails
on init when the chip href contains /tag/), so this needs no JS
change — chips and the left nav now land on the identical page with
the identical active highlight. Server still resolves active_tag from
both /tag/<slug> and any legacy ?tag= param, so old links keep working.

33 tests in the home-layout / chip / facet / tag-detail / lane slice
pass.
2026-05-16 05:33:58 -04:00
2329dc8f4a
style: SERP list uses full width, bigger thumbs, category hit counts
Three fixes from fox's screenshot of the deployed tag detail page:

1. Dead right-hand space — .serp-list-excerpt had max-width: 70ch, so
   the snippet capped at ~600px while the row was full width, leaving
   ~40% of the viewport empty next to the facet sidebar. Removed the
   cap; the excerpt now fills the row body. Product.excerpt_sentences
   already bounds the block at 6 sentences so it can't run unbounded.

2. Bigger thumbnails — .serp-list-row thumbnail column goes 80→140px
   base, and the container-query steps go 120→200 (≥600), 160→260
   (≥900), plus a new 320px step at ≥1200. The facet sidebar makes
   the content container wide, so the larger steps actually fire.

3. Category hit counts — facet sidebar each category now shows a
   muted tabular-nums pill with Tag.product_count next to the name.
   a.facet-tag becomes a 1fr/auto grid (name | count); the count
   chip inverts on the active row so it stays legible on the themed
   background. No view change — Tag.product_count is an existing
   on-demand property, ~one indexed COUNT per sidebar row.

6-sentence excerpt was already wired (excerpt_sentences(6) via the
facet-nav work). 42 tag/facet/serp functional tests pass.
2026-05-15 21:02:38 -04:00
2640b035bc
fix: MPS-24 2.6c — sidebar category links hit tag route, not home catch-all
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.
2026-05-15 20:00:02 -04:00
8970e960fb
feat: MPS-24 Phase 2.6b — facet nav on shop home + mobile SERP lanes
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.
2026-05-15 16:30:23 -04:00
0f84e6871b
fix: split Subscription / Comments / Gift Cards into separate cards
Three sections shared a single <section class="shop-settings well">
wrapper — Subscription and Comments were nested *inside the same
card*, and Gift Cards lived in a bare <section class="well"> right
after Comments inside the same outer .one-column. So:

  - Subscription's body bled directly into Comment System Settings,
    no margin, no card break (fox's screenshot).
  - Gift Cards picked up only the legacy flat .well treatment — no
    elevation, no margin-bottom — because it lacked .shop-settings.

Split each into its own <section class="one-column">→<section
class="shop-settings well"> so the margin-bottom + elevation rules
from common.css land on every one. Gift Cards now also carries
.shop-settings so it matches the rest of the page.

Markup is otherwise unchanged. 18 tests in the shop_settings /
gift_card / styleguide / subscription / comment_settings slice green.
2026-05-15 15:58:16 -04:00