Commit graph

1128 commits

Author SHA1 Message Date
62e65beff6
bump GIT_HASH to e77784c 2026-05-13 13:06:44 -04:00
e77784cd61
fix: pay-by countdown reads as human time delta, not UTC wall-clock
Replaces the previous "2026-05-14 11:00 UTC" deadline strings and
the secondary "23h 14m 8s remaining" pill with a single in-place
prose delta on both offer and auction pages.

Server (no-JS fallback): ago.human(deadline, future_tense="in {}")
renders "in 23 hours, 14 minutes". Reaches for the same precision
the buyer cares about, in their reading style, without a wall-clock
string to mentally subtract from. Same library Russell Ballestrini
wrote — public domain, already a dep.

Client (JS-enhanced): offer.js / auction.js rewrite the same
<strong data-pay-deadline="..."> element once per second with a
prose delta computed locally ("in 23 hours, 14 minutes, 8 seconds").
The previous fmtRemaining returned compact "23h 14m 8s" which read
as code — switched to prose to match the server fallback.

Auction page also drops the dual element (separate countdown chip +
human deadline span); JS rewrites in place so the markup is half
the size.

Test test_accepted_offer_renders_pay_countdown_for_buyer locks in
the new markup: a `data-pay-deadline="…">in N units` regex match.
2026-05-13 13:06:34 -04:00
61cda404e9
bump GIT_HASH to b27c074 2026-05-13 13:01:18 -04:00
b27c0748a6
feat: live pay-by countdown on offer + auction pages (JS-enhanced)
When JS is available, the buyer now sees a live countdown next to the
absolute pay-by date on both the accepted-offer page and the
auction-won page. Without JS, the existing static "You have until
<date>" copy still renders — the countdown element is .js-only.

Offer (/o/{id}):
- offer_page view now exposes pay_deadline_timestamp_ms alongside the
  human string.
- offer.j2 adds a [data-pay-deadline] span next to the deadline copy
  on both the buyer (pay-now) and seller (awaiting-payment) sides.
- offer.js scans for [data-pay-deadline] every second and writes the
  formatted remaining time into .offer-pay-countdown-value. Reuses the
  same fmtRemaining shape as auction.js (Nd Nh Nm / Nh Nm Ns / Nm Ns).

Auction (/a/{id}):
- _serialize_auction adds payment_deadline_timestamp, user_is_winner,
  and is_settled.
- auction.j2 renders a new "You won this auction!" well when state is
  ENDED, the current user is the winner, the auction isn't SETTLED,
  and payment_deadline_timestamp is set. The well carries the pay
  button + a countdown that auction.js fills in. The human deadline
  is also resolved client-side (toLocaleString) so the buyer sees
  it in their own timezone.
- auction.js adds tickPayCountdown() in addition to the existing
  tickCountdown() that counts auction end.

Test fixture _accepted_offer now sets offer.accepted_timestamp (the
fixture bypasses accept_offer() which would set it for free).
test_accepted_offer_renders_pay_countdown_for_buyer locks in the
markup.
2026-05-13 13:01:11 -04:00
cd1ede638c
bump GIT_HASH to c5e966c 2026-05-13 12:33:55 -04:00
c5e966c33b
perf(tests): boot app + schema once per worker, wipe rows per test
Test suite hit GitLab's 1-hour pipeline timeout (58:41) — the test
step alone exceeded the cap and the deploy step never ran. Root cause
was per-test infrastructure cost:

- FunctionalTests.setUp/tearDown rebuilt the entire WSGI app and ran
  Base.metadata.create_all + drop_all for *every* test. Measured at
  ~640ms of pure DDL per test (36 tables + indexes); the app boot adds
  another ~400ms. ~1s of overhead per test before the test body even
  starts.
- DatabaseIntegrationTests had the same pattern.

Fix: lift app + engine + schema to classmethods that run *once per
worker process*. Per-test setUp now just hands the shared infra to
instance attrs and creates a fresh webtest.TestApp + dbsession.
Per-test tearDown aborts the pyramid_tm txn and wipes every row via
table.delete() in reverse FK order, with PRAGMA foreign_keys OFF
around the wipe so we don't have to compute a safe order for
circular refs.

Class-level state lives on FunctionalTests / DatabaseIntegrationTests
themselves (not `cls`) so all subclasses see the same instances on
attribute lookup. pytest-xdist worker isolation is unchanged — each
worker has its own sqlite file (conftest.py) and its own Python
process, so the class-level cache is per-worker.

Local timings (8 cores, -n auto):
- test_functional.py: 3:30 → 2:14   (36% faster, 291/291 pass)
- full suite:         ~7m → 3:49    (1012/1012 pass)

On CI (fewer cores), expected to drop from 58:41 to roughly
25-30 min — well under the 1h pipeline cap.
2026-05-13 12:33:44 -04:00
ffdcbf41d5
bump GIT_HASH to 10074a1 2026-05-13 11:06:39 -04:00
10074a1d20
fix: redesign /billing layout with design tokens + grid
The page was using the .two-column class (designed for the product
page mobile stack) which left the Stripe Element floating to the
right with massive whitespace and orphaned Review Order / Checkout
buttons disconnected from the content above. Inline styles all over
the PayPal section meant no token consumption.

Rebuild:
- New .billing-page wrapper, max-width 1000px, centered, CSS Grid.
- Active Card + Add Card now sit in a .settings-form-grid (1 col
  mobile, 2 cols desktop) so they align as equal-width siblings.
  Heading copy adapts: "Add a Card" when no card on file, "Add
  Another Card" when there is one.
- PayPal section moved to its own full-width well below; inline
  styles replaced with tokenized .billing-paypal-card,
  .billing-paypal-header, .billing-meta classes. Grid only.
- Review Order + Checkout buttons land in a .billing-actions well
  centered as a 2-col grid; Checkout is the green primary CTA.
  Collapses to a single stretched column on mobile.

No design system tokens are hard-coded — every color, gap, radius,
and font size pulls from tokens.css. CSS Grid only, no flexbox.
2026-05-13 11:06:33 -04:00
7b7d643b15
bump GIT_HASH to 8633470 2026-05-13 10:59:13 -04:00
8633470a65
fix: hide checkout left column when there's nothing to put in it
The left column used to always render when stripe was enabled and the
shop was stripe-ready, even when the buyer had no card on file —
showing a duplicate "Add a credit card payment method" CTA next to the
right-column "Pay with Credit Card — Add Card" button. Two identical
CTAs in two columns read as a broken page.

Left column now renders only when it has concrete content:
- an Active Card to display, or
- an Active Shipping Address for a physical product in the cart.

Otherwise the page collapses to a centered single column (the CSS
:has(.checkout-left) selector already handled this layout case).

Existing assertion test_cart_checkout_for_shop now also asserts the
left-column "no card configured" copy no longer appears.
2026-05-13 10:59:00 -04:00
c06be6b37d
bump GIT_HASH to 73c36e6 2026-05-13 10:31:49 -04:00
73c36e6f36
feat: buyer can cancel accepted offer; post-accept pay window
Two coupled additions to make-an-offer:

1. Buyer back-out after acceptance. New terminal state
   OFFER_STATE_BUYER_CANCELLED, route POST /o/{id}/cancel, and
   lib/offer.cancel_offer_after_accept(). Distinct from WITHDRAWN
   (which is pre-acceptance buyer pullout) so seller's inbox can
   visually distinguish "they ghosted after acceptance" from "they
   pulled it before I responded." Confirm dialog on the button —
   it's a destructive action.

2. Post-acceptance pay window. New shop-level setting
   offer_acceptance_payment_hours (default 24h, configurable in
   offer-settings form) bounds how long the buyer has to pay
   after acceptance. accept_offer() (both manual and auto-accept)
   stamps offer.accepted_timestamp; offer.acceptance_pay_deadline_ms
   is derived. offer_tick now expires ACCEPTED-but-unpaid offers past
   their deadline alongside the existing PENDING/COUNTERED expiry.

The offer detail page now shows the deadline to both buyer and seller,
and gives the buyer a "Cancel this offer" button alongside the pay-now
CTA.

Migration 632878c8f243 adds the two columns idempotently
(offer_acceptance_payment_hours on mps_shop, accepted_timestamp on
mps_offer). Existing accepted offers have accepted_timestamp = NULL;
the tick treats NULL as "no deadline" so legacy rows aren't
suddenly expired.

Tests:
- test_accepted_offer_expires_after_pay_window (integration)
- test_accepted_offer_inside_pay_window_not_touched (integration)
- test_buyer_cancel_after_accept_flips_state (integration, includes
  the paid-offer-cannot-be-cancelled guard)
- test_buyer_can_cancel_accepted_offer (functional)
- test_seller_cannot_cancel_accepted_offer (functional)
2026-05-13 10:31:43 -04:00
87d054e7e8
bump GIT_HASH to 2e1bde3 2026-05-13 10:20:41 -04:00
2e1bde37d3
fix: single PayPal button on checkout; Stripe stays visible
Cart checkout had two payment-UX defects:

1. PayPal Smart Buttons rendered three buttons by default: the yellow
   PayPal button, "Pay Later" financing, and "Debit or Credit Card"
   (PayPal-branded card flow). For MPS, credit-card checkout goes
   through Stripe — the PayPal card button is redundant and pushes a
   competing flow into the same panel.
   Fix: append `disable-funding=paylater,card` to the PayPal SDK URL
   so only the single PayPal Smart Button renders.

2. Stripe lost visibility on the right column when the buyer had no
   card on file yet. The "Add a credit card payment method" CTA only
   appeared in the left panel; next to PayPal + crypto on the right,
   the credit-card path looked unsupported.
   Fix: when stripe is enabled and the shop is stripe-ready but the
   buyer has no active card, surface a "Pay with Credit Card — Add
   Card" CTA in the right column, alongside the PayPal Smart Button.
   /billing then runs the existing card-add flow.

Tests:
- test_cart_checkout_for_shop asserts "Pay with Credit Card" renders
  in the right column when no card is on file.
- test_paypal_smart_buttons_collapsed_to_one is a template-grep
  asserting the SDK URL carries disable-funding=paylater,card.
2026-05-13 10:20:34 -04:00
9c68971971
bump GIT_HASH to 7dc82dd 2026-05-13 10:09:22 -04:00
7dc82dda50
fix: offer checkout is one-shot; seller copy says so
Two coupled fixes on the accepted-offer flow:

1. Single redemption. offer_checkout was creating a fresh cart on
   every POST. While offer.state == ACCEPTED, a buyer could spawn N
   parallel carts on one offer; mark_paid is idempotent on the offer
   but the *other* carts still carried the override and could each
   complete checkout, double-charging the buyer. Now: if any
   cart_offer already exists for the offer, reuse that cart (and
   re-activate it). Only one cart_offer row can ever exist per offer.

2. Seller copy on the "Awaiting payment" panel said "they need to
   sign in and pay from this same page — send them the link", which
   implied the seller had to manually deliver the link. The system
   already emails the buyer on accept (send_offer_accepted_email is
   wired in views/offer.py for both auto-accept and manual paths).
   The copy now reflects that: "We emailed them a one-time checkout
   link — this offer can be redeemed only once." The shareable link
   stays as a fallback for if the buyer asks for it again.

Functional test test_offer_checkout_is_single_redemption asserts
three consecutive POSTs to /o/{id}/checkout redirect to the same
cart URL and produce exactly one cart_offer row. Existing
test_accepted_offer_seller_sees_pay_link_to_share extended to
assert the new copy ("emailed", "one-time checkout link").
2026-05-13 10:09:16 -04:00
6827fb8ac9
bump GIT_HASH to 812e116 2026-05-13 09:55:54 -04:00
812e116f50
feat: buyer dashboards — /u/offers + /u/bids — gated per shop
Two new buyer-side pages mirror /u/purchases — scoped to request.shop,
listing every offer or bid the user has placed within the current shop:

- /u/offers — open offers (pending/countered) on top, terminal below.
  404s when the shop has offer_enabled=False. Wired to user_offers.j2.
- /u/bids — latest bid per auction in this shop. 404s when the shop
  has zero products in auction pricing_mode. Wired to user_bids.j2.

Gating exposed on /u/settings:
- "My Offers" button: visible iff request.shop.offer_enabled is True.
- "My Bids" button:  visible iff request.shop.has_auction_products.

New property Shop.has_auction_products: returns True when the shop
has at least one product with pricing_mode in (1, 2). Auctions have
no shop-level toggle — they're enabled per product — so the buyer
gate is derived. Unit-test coverage in test_integration.py.

Tests:
- TestAuctionFoundation.test_shop_has_auction_products_property
  covers all three pricing-mode transitions on the same shop.
- TestUserOffersBidsDashboards (7 tests) covers 404 paths, page
  renders, and button visibility on plain / offer / auction shops.
2026-05-13 09:55:46 -04:00
759d98a968
bump GIT_HASH to 2c56717 2026-05-13 08:50:47 -04:00
2c56717579
feat: polished negotiation-aware cart + sandbox-gated user storage
Cart polish for offer/auction carts:
- Negotiation card at top of cart-left: eyebrow ("Offer accepted" /
  "Auction won"), list price (strike) vs agreed price (bold green),
  savings line, link to /o/{id} or /a/{id}.
- Shop subtotal honors the override: strikethrough list price, bold
  negotiated price. (is_discounted was rightly False after the prior
  fix, but the visual cue was lost — restored without conflating it
  with coupon discounting.)
- Line item rendering: replaces quantity field + remove button with a
  "quantity locked" pill on the negotiated product, since offers and
  auctions are single-unit transactions buyers cannot edit mid-cart.
- Right column total: shows agreed price big, list-vs-savings line
  beneath. No conflicting strikethrough.
- Gift card apply hidden on negotiated carts (does not stack).

New Cart properties (test coverage in test_integration.py):
- is_negotiated, negotiation_kind, negotiation_path, negotiated_product
- list_total_in_cents / list_total
- savings_in_cents / savings (clamped to >= 0)

CSS: cart-negotiation-card / cart-negotiation-* / cart-negotiation-pill
all live in common.css, Grid only, design tokens only.

Sandbox-gated artifact storage:
The Artifact Storage section on /u/settings (S3-compatible bucket for
the in-browser sandbox export feature) now renders only when the
current shop has sandbox_mode enabled. The bucket has exactly one
consumer (views/user_sandbox.py via static/js/sandbox.js), so when
sandbox is off there is no reason to surface the credential form.
Functional test in test_functional.py asserts both states.
2026-05-13 08:50:35 -04:00
1088805520
bump GIT_HASH to c58b6b2 2026-05-13 07:10:22 -04:00
c58b6b27a7
fix: offer/auction cart charges agreed price, not list price
total_discounted_price_in_cents summed line items and ignored the
cart_offer / cart_auction override. is_discounted then reported True
(agreed ≠ list), so total_in_cents — what the charge path reads —
returned the list price. UI strikethroughed the agreed price and
charged the list price.

Short-circuit the discounted path on override, the same way
total_price_in_cents does. An offer/auction is a negotiated price,
not a discount, so coupons and gift-card balances do not stack on
top of it.

Extended both override tests to assert total_in_cents,
total_discounted_price_in_cents, and is_discounted — the gap that
let this ship.
2026-05-13 07:09:56 -04:00
6119489c40
bump GIT_HASH to ebeb8af 2026-05-13 06:28:26 -04:00
ebeb8afa87
fix: offer page — seller sees a shareable pay link, buyer pay button stands out
When an offer is accepted but unpaid, the seller's view now has an
"Awaiting payment" block: the buyer's name (linked to their profile), the
agreed amount, and the offer URL pre-filled in a read-only input plus a
"Copy link" button (capability-driven — the input is selectable for
manual copy when JS is absent or clipboard API is blocked). The notice
banner also says "waiting on the buyer to pay $X" in the seller's view.

The buyer's pay-now button is now mps-button-green so it's unmistakable
as the call-to-action.

Tests: TestOfferCheckout gains two render tests — seller sees the
awaiting-payment block + URL but not a pay-now form; buyer sees the
$75.00 pay-now form but not the awaiting block. 9 in class pass.
2026-05-13 06:28:18 -04:00
8b40e90d58
bump GIT_HASH to 157e6f7 2026-05-12 21:04:01 -04:00
157e6f769a
feat: bounded SSE feeds for offer & auction state machines
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.
2026-05-12 21:03:50 -04:00
93c0facbbc
bump GIT_HASH to 5b44132 2026-05-12 20:37:13 -04:00
5b44132058
fix: offer/auction checkout — land in the new cart, not a stale empty one
offer_checkout and auction_checkout were creating a cart and hand-setting
cart.active = True, which left the user with two active carts for the
shop; /cart then resolved to the older, empty one — so "Pay $X" appeared
to do nothing. Both now use shop.create_new_cart_for_user() (deactivates
the user's other carts for that shop, activates the new one) and redirect
to /cart/{cart_id} directly, so it works regardless of which shop the
request is scoped to.

Tests: TestOfferCheckout / TestAuctionCheckout now assert there is exactly
one active cart, it carries the cart_offer/cart_auction association, it is
non-empty, and the redirect targets that cart by id. 990 passed.
2026-05-12 20:37:07 -04:00
adf6175d04
bump GIT_HASH to 87ac9d9 2026-05-12 20:10:27 -04:00
87ac9d90ca
fix: confirm prompt on terminal offer actions (accept / decline / withdraw)
These end the negotiation, so the forms now carry onsubmit="return
confirm(...)". offer.js bails when the prompt is cancelled — it checks
event.defaultPrevented before firing the AJAX request — so the JS path
respects the confirmation too, and the no-JS path gets the native dialog
before the POST+redirect.
2026-05-12 20:10:13 -04:00
c43336925c
bump GIT_HASH to 2886a42 2026-05-12 19:53:58 -04:00
2886a4255d
fix: crossfade clamps both tracks to active tab's current volume
Crossfades (user-initiated and DJ auto-transitions) faded the incoming
track up to volume 1.0 regardless of how loud the playing tab actually
was, producing a jarring jump in loudness mid-transition. Read
activeMedia.volume when a fade begins and use it as the ceiling for both
the outgoing and incoming tracks; restore that same level if a DJ
crossfade is cancelled.
2026-05-12 19:53:42 -04:00
31f8449c7d
bump GIT_HASH to 6f52af0 2026-05-12 18:40:27 -04:00
6f52af0c2c
fix: actions hub — cap at two columns, restore gap, always show Offers button
The previous .action-button-grid used auto-fit/minmax(15rem,1fr) which
produced four columns on a wide well, and .mps-button's `min-width: 100%`
+ auto margins fought grid track sizing so the gap collapsed and buttons
touched. Now: single column on narrow, exactly two equal columns at
≥720px (auto-flow keeps them balanced), explicit column/row gap, and the
grid resets .mps-button min-width/margins. The "🤝 Offers" button is now
always shown to shop owners (was gated on shop.offer_enabled) so the
offers inbox is always reachable.
2026-05-12 18:40:22 -04:00
ff6a35b887
bump GIT_HASH to e97d18b 2026-05-12 17:47:29 -04:00
e97d18bccd
MPS-21: public profile page, offer-history identity, shop offers inbox, actions hub rebuild
- 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.
2026-05-12 17:47:24 -04:00
cb61227b2c
bump GIT_HASH to 227fc4e 2026-05-12 16:34:25 -04:00
227fc4e564
MPS-21: offer-page state notice + design-system Make-an-Offer settings form
- 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.
2026-05-12 16:34:21 -04:00
38b5739ce7
bump GIT_HASH to 2cad248 2026-05-12 12:04:28 -04:00
2cad2482b8
MPS-20/MPS-21: capability-driven presentation for auction & offer actions
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.
2026-05-12 12:04:19 -04:00
981b1316f2
bump GIT_HASH to a90979a 2026-05-12 11:13:11 -04:00
a90979a46c
MPS-23: single warm sending identity for transactional mail
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.
2026-05-12 11:13:11 -04:00
0f19da5ba8
docs: add MPS-23 ticket — consolidated transactional sender identity 2026-05-12 11:07:38 -04:00
f4be4f4612
bump GIT_HASH to 9a0782e 2026-05-12 08:14:57 -04:00
9a0782e61f
verification-challenge: center on login-card, add 'try again' link to login page 2026-05-12 08:14:57 -04:00
4b96acae0c
bump GIT_HASH to f9b73bb 2026-05-11 18:14:12 -04:00
f9b73bb9de
product offer form: breathing room around bid + optional message
Per fox: "a touch more whitespace around the bid and optional message".
The expanded Make-an-offer form had the label, amount input, message
input, and submit button stacked flush against each other inside the
$42.00 well — no vertical rhythm.

New .product-offer-form CSS:
- display: grid; gap: var(--space-3, 12px)
- top margin var(--space-3) separates it from the summary button
- inputs get box-sizing: border-box, width: 100%, padding 8px 12px so
  they fill the column with comfortable internal spacing

Grid only.
2026-05-11 18:13:53 -04:00
9c00d52dfd
bump GIT_HASH to 5cad5c3 2026-05-11 17:30:11 -04:00
5cad5c3a0c
product page: 280px floor on the purchase column (two-column grid)
Per fox: the price / Add-To-Cart / Make-an-offer box gets squeezed too
narrow on mid-width viewports (960px–1200px) where the 2fr/1fr split
gives the 1fr column only ~250-300px, crushing the buttons.

section.two-column grid columns: minmax(0, 2fr) minmax(280px, 1fr)
- the images column can shrink (minmax(0, 2fr)) so the purchase
  column always gets its 280px minimum first
- once the viewport is wide enough, the columns return to the 2:1
  ratio up to the 1200px max-width

Grid only, no flex.

Tests pass (10 in target slices).
2026-05-11 17:29:55 -04:00
46dd2cb929
bump GIT_HASH to 2630b78 2026-05-11 12:55:34 -04:00