Skip to content

Merchant portal defects, 2026-08-28 - Manual Test Plan โ€‹

FieldValue
Branch / PRfix/merchant-portal-988 (worktree .claude/worktrees/merchant-defects)
EnvironmentBEFORE: http://localhost:3001 (the operator's own stack, main checkout on feat/admin-and-merchant-portals, untouched). AFTER: a throwaway Vite admin portal this session stands up from the worktree on a free port, named in each result block. Both proxy to the DEPLOYED dev APIs via .env.local.
Build flags / confignone. Every item here is layout and render, no flag gates it.
Build (commit)recorded per run in each result block
Testeragent (headless Playwright chromium), operator for the design judgement calls
Date2026-08-28

Summary โ€‹

#ScenarioResultNotes
1Merchant rail footer is pinned to the bottom at a normal window[x] pass367.9px above the rail bottom before, 0 after
2Merchant rail footer stays pinned at a short window where the nav overflows[x] pass1440x500, footer at 500, nav scrolls internally
3Merchant rail footer is pinned when the rail is collapsed[x] pass72px rail, footer at the rail bottom
4Merchant rail footer sits at the bottom of the mobile drawer[x] pass390x844, footer at 844
5CONTROL. The ADMIN rail footer is pinned, before and after[x] passidentical before and after, the admin rail never had it
6FALSIFYING. Putting the suspected cause back reproduces the defect[x] passpredicted 582.1 with the rule, 950 without, both confirmed
7A real merchant login sees the same pinned footer[x] passown profile card, pinned, footerToRailBottom 0
8Regression. M1 to M4 stay fixed[x] passall four still hold on the fixed build
9Survey pass. Every merchant route renders without a console error[x] pass7 routes reached, 5 defects fixed, 8 reported
10One placement reads the same name on all three merchant screens[x] passwas three different names, now "Hero Rail" everywhere
11Account "Member since" resolves to a real date[x] passreads "Aug 2026", was structurally unable to resolve
12FALSIFYING. With no createdAt, the field falls to the shared empty glyph[x] passglyph plus sr-only "Not set", as predicted
13The offer form still uses the project's select wrapper[x] passzero native select elements
14FALSIFYING. The merchant switcher names the business under StrictMode[x] pass#980 closed, and the new unit case fails against the old code
15The offer builder blocks publish on an over-limit inherited title[x] passisolated in a unit test; the browser could NOT settle it, see the scenario
16Archive carries secondary weight and Delete keeps danger[x] passverified live on the offer detail
17The offer title renders once on the detail[x] passone occurrence in the main area, badge moved to the header
18Notes has an empty state matching its sibling[x] passicon plus a sentence, different per reader
19No merchant-facing string uses our internal vocabulary[x] passeight strings rewritten, "geofence radius" reported not changed
20The Overview counter shows the strictest selected limit[x] passfour states including the hero-only falsifying arm
21A placement row has no nested interactive control[x] passaxe: serious before, zero after
22Every merchant surface passes axe except contrast[x] pass19 surfaces, 12 clean, the rest is two shared tokens
23The two failing colour tokens are split[ ] NOT EXERCISEDapproved by the operator, not started. See the scenario
24The shared-token change is verified on the ADMIN portal[ ] NOT EXERCISEDdepends on 23
25The merchant design overhaul direction[ ] NOT EXERCISEDblocked on her pick, three mockups rendered

Scenarios found during the goal-two survey are appended below as they are discovered, and mirrored into this table.

Setup / preconditions โ€‹

  • Do not restart, rebuild or kill anything on http://localhost:3001 or ports 8080 to 8086. They are the operator's. Reads only.
  • The signin rate limit is the trap. /auth/admin/signin and /auth/merchant/signin share one bucket of 10 per 15 minutes per IP, and that bucket is shared with the operator's own browser. Sign in ONCE per run, save the Playwright storageState, and reuse it for every later measurement. A limited run lands on the login screen and reports zeros for every route, which reads as a clean pass.
  • Credentials come from the two 0600 env files, read by the harness, never pasted into a doc, a log or a commit: ~/.lantern-agent-probe.env (admin, Agent Probe) and ~/.lantern-test-accounts.env (TEST_MERCHANT_*).
  • The merchant under test is TEST_MERCHANT_ID from that file. Routes are /merchant/<id>/{overview,offers,venues,notes,photos,account,settings}.
  • Screenshots and raw measurements land in docs/projects/merchant-and-venue-dashboards/harness/, never in /tmp.

Scenarios โ€‹

  • Goal: the reported defect is gone at the size the operator actually uses.
  • Preconditions: signed in as Agent Probe, on /merchant/<id>/overview, viewport 1440x950.
  • Steps:
    1. Open the merchant Overview.
    2. Measure the sidebar footer's bottom edge and the rail's bottom edge.
    3. Measure the vertical gap between the last nav item ("Settings") and the footer's top edge.
  • Expected: the footer's bottom edge sits within 1px of the rail's bottom edge. The gap between the last nav item and the footer is whatever the rail's height leaves over, and it is BELOW the nav rather than above the footer.
  • Verify (data/console): getComputedStyle('.sidebar--merchant .sidebar-nav').flexGrow is 1, not 0. .sidebar height equals the viewport height.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS. Before, on the operator stack: nav flex-grow 0, nav height 324.7px, footer bottom 582.1px against a rail bottom of 950px, so the footer floated 367.9px up the rail. After, on this branch: nav flex-grow 1, nav height 692.6px, footer bottom 950px, footerToRailBottom 0.
Evidence:  footer-diag01.json (before, http://localhost:3001, hers), footer-fix01.json (after, http://localhost:3013, mine), footer-diag01-merchant-before.png
Follow-up: none

  • Goal: the fix survives the case that breaks a naive one. A footer pinned with a fixed offset or an absolute position looks right at 950px tall and pushes off screen at 500px.
  • Preconditions: same session, viewport 1440x500.
  • Steps:
    1. Resize to 1440x500 on the merchant Overview.
    2. Measure the footer's bottom edge against the viewport height.
    3. Scroll the nav region and confirm it scrolls internally rather than moving the footer.
  • Expected: the footer is fully visible, its bottom edge within 1px of the viewport bottom, and the nav list scrolls inside its own box.
  • Verify (data/console): .sidebar-nav scrollHeight exceeds its clientHeight, and the footer's getBoundingClientRect().bottom is at most the viewport height.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS. 1440x500 on the fixed build: footer bottom 500 against a rail bottom of 500, footerToRailBottom 0, and .sidebar-nav scrollHeight exceeds clientHeight so the nav scrolls inside its own box.
Evidence:  footer-fix01.json merchantShortAfter, footer-fix01-merchant-short.png
Follow-up: none

  • Goal: the collapsed 72px rail keeps the same contract.
  • Preconditions: same session, viewport 1440x950.
  • Steps:
    1. Click the rail collapse toggle.
    2. Measure the footer's bottom edge against the rail's bottom edge.
  • Expected: footer still pinned, rail 72px wide, no horizontal overflow.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS. Collapsed rail measures 72px wide, footer bottom 950 against rail bottom 950, footerToRailBottom 0.
Evidence:  sweep-0828.json collapsed, sweep-0828-collapsed.png
Follow-up: none

  • Goal: below 900px the rail is a drawer, and the pin has to mean the drawer's bottom rather than the page's.
  • Preconditions: viewport 390x844, drawer opened.
  • Steps:
    1. Resize to 390x844 and open the nav drawer.
    2. Measure the footer's bottom edge against the drawer's bottom edge.
  • Expected: the footer is inside the drawer, at its bottom, fully visible.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS. 390x844 with the drawer open: drawer 310px wide, footer bottom 844 against drawer bottom 844, footerToRailBottom 0, footer fully visible.
Evidence:  sweep-0828.json mobileDrawer, sweep-0828-mobile-drawer.png
Follow-up: none

  • Goal: this is the control, and it carries the opposite prediction to scenario 6. Our account of the cause says the merchant-only override never reached the admin portal, so the admin rail should be pinned BOTH before and after the change.
  • Preconditions: signed in as Agent Probe, on /admin/users, viewport 1440x950 and again at 1440x500.
  • Steps:
    1. Open an admin route with the standard rail.
    2. Measure the footer's bottom edge against the rail's bottom edge at both heights.
    3. Read flexGrow on the admin .sidebar-nav.
  • Expected: pinned at both heights, flexGrow is 1, and the number is IDENTICAL before and after the merchant change.
  • What a surprise means: if the admin footer is also unpinned before the change, the merchant-scoped override is not the whole cause and the diagnosis reopens. If the admin numbers MOVE after a merchant-only change, the change leaked out of .sidebar--merchant and must be reverted.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS, and the control held. The admin rail reads flex-grow 1 and footerToRailBottom 0 at BOTH 950 and 500, and the numbers are byte-identical before and after the merchant change. The admin rail also reports merchantScoped false, so the override never applied there: the admin portal never had this defect and nothing leaked out of the merchant scope.
Evidence:  footer-diag01.json adminControl and adminControlShort, footer-fix01.json same keys, footer-diag01-admin-control.png
Follow-up: none

6. FALSIFYING. Putting the suspected cause back reproduces the defect โ€‹

  • Goal: this is the scenario that can say we are wrong, and its prediction is stated before the run.
  • Prediction: with .sidebar--merchant .sidebar-nav { flex: 0 1 auto } present, the merchant footer's top edge sits directly under the last nav item and its bottom edge is hundreds of pixels above the rail's bottom. With the rule removed, the footer's bottom edge is at the rail's bottom. Scenario 5's admin control does not move in either case.
  • Preconditions: the same page, the same viewport, one measurement each way.
  • Steps:
    1. Measure the footer's bottom edge with the rule active.
    2. Delete that one rule from the live stylesheet and measure again with nothing else changed.
  • Expected: two different numbers, and the difference is roughly the gap the operator is looking at.
  • What a surprise means: if removing that rule does not move the footer, the rule is not the cause, nothing gets committed on this theory, and the investigation restarts from the computed styles.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS, and it behaved exactly as predicted. With .sidebar--merchant .sidebar-nav { flex: 0 1 auto } live, the merchant footer bottom read 582.1px. Deleting that one rule from the live CSSOM, with nothing else touched, moved it to 950px, which is the rail bottom. The 367.9px difference IS the gap the operator is looking at. The admin control did not move in either direction.
Evidence:  footer-diag01.json ruleDeleted plus merchantBefore against merchantAfterRuleDeleted, footer-diag01-merchant-rule-deleted.png
Follow-up: none

  • Goal: the footer renders a different child for a real merchant (their own profile card) than for an admin in merchant context (the switcher plus Back to Admin), so both branches need the measurement.
  • Preconditions: signed in with TEST_MERCHANT_EMAIL on the merchant portal. NOTE: this spends a signin slot from the shared bucket, so run it last.
  • Steps:
    1. Sign in as the test merchant.
    2. Measure the footer's bottom edge against the rail's bottom edge at 1440x950.
  • Expected: pinned, same as scenario 1.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS. Signed in as TEST_MERCHANT_EMAIL, the footer renders the merchant's own profile card ("Round Trip Tester / Merchant") rather than the switcher, and it is pinned: footer bottom 950 against a rail bottom of 950, footerToRailBottom 0, nav flex-grow 1. Confirmed on the Offers route and the Overview route.
Evidence:  verify-merchant.json rail and overviewRail, screens/merch-0828-fix-overview.png
Follow-up: none

8. Regression. M1 to M4 stay fixed โ€‹

  • Goal: yesterday's five merchant fixes live on the same screens this change touches. Four of them must be untouched.
  • Preconditions: merchant Overview, 1440x950.
  • Steps:
    1. Measure every .role-card__metric row height and confirm the "Unique visitors" row matches its siblings (M1).
    2. Confirm every Recent Offers title renders on one line (M2).
    3. Confirm the Ad Views panel renders the compact empty state rather than a flat zero chart, when the visible window has no non-zero day (M3).
    4. Confirm the delivery label reads Clicks rather than Clicks ยท โ€“ CTR when there are no impressions (M4).
  • Expected: all four hold. M5 is deliberately NOT in this list: reversing it is the subject of scenario 1.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS, all four. Every .role-card__metric row measures 20.8px including "Unique visitors", and no sub-metric dt carries an icon (M1). All three Recent Offers titles render on one line at a 230.1px title column (M2). Ad Views renders the compact empty state at a 261.4px panel with no chart (M3). The delivery label reads "Clicks" with no dangling CTR clause (M4).
Evidence:  sweep-0828.json routes.overview.regression, screens/merch-0828-overview.png
Follow-up: none

9. Survey pass. Every merchant route renders without a console error โ€‹

  • Goal: the goal-two sweep. Visit every merchant route, screenshot it, and collect console and page errors.
  • Preconditions: one signed-in session, reused across all seven routes.
  • Steps:
    1. Visit /merchant/<id>/{overview,offers,venues,notes,photos,account,settings} in one session.
    2. Screenshot each at 1440x950 into the project harness folder.
    3. Record every console error and page error per route.
    4. Record any element whose rendered text contains a raw identifier, an unresolved placeholder, or a broken label.
  • Expected: no page errors. Any console error or visible defect is recorded here as its own numbered scenario with a fix or a reason it was not fixed.
  • The shape to distrust: zero findings on every route usually means the run never got past the login screen. Confirm the run reached a signed-in route by asserting a known page-header string before believing a clean result.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS with findings. All seven merchant routes reached a signed-in
           merchant rail (asserted on .sidebar--merchant plus the page h1, so a
           login-screen run could not pass), footer pinned on every one. No page
           errors. Two console errors per route, both benign and both present on
           her stack too: a 403 on one resource and requestStorageAccess denied.
           Five defects found and fixed (N1 to N5), eight found and not fixed
           (N6 to N13), each with its reason. Full write-up in the project
           folder.
Evidence:  sweep-0828.json, verify-admin.json, verify-merchant.json,
           offer-form-0828.json, and the nine screens/merch-0828-*.png captures.
           Record: docs/projects/merchant-and-venue-dashboards/merchant-defect-survey-0828.md
Follow-up: N6 (venue address has no commas, the data is wrong not the render)
           wants its own issue against venue import. N7 is #980 and needs the
           operator's word before anything in apps/admin/src/shared moves.

10. One placement reads the same name on all three merchant screens โ€‹

  • Goal: the offers list, the offer detail and the offer form agree on what a placement is called.
  • Preconditions: signed in, at least one offer on the merchant.
  • Steps:
    1. Open /merchant/<id>/offers and read the placement segment of a row's subtitle.
    2. Click that row and read the Placement field on the detail.
    3. Open Create Offer and read the placement label on the Placements step.
  • Expected: all three read Hero Rail. None reads the raw enum hero, and none reads Dashboard Hero Rail.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS. All four list rows read "Lantern Alpha Venue . Hero Rail . Expires ...", the detail's Placement field reads "Hero Rail", and the form's label comes from the same map. Before the change the list read the bare word "hero" and the detail read "Dashboard Hero Rail".
Evidence:  verify-admin.json offerRows and offerDetail, screens/merch-0828-fix-offers.png, screens/merch-0828-fix-offer-detail.png
Follow-up: none

11. Account "Member since" resolves to a real date โ€‹

  • Goal: the field that could never render a date now does.
  • Preconditions: a merchant whose document has a createdAt.
  • Steps:
    1. Open /merchant/<id>/account.
    2. Read the Member since field.
  • Expected: a month and year, for example Aug 2026, not an empty value.
  • Verify (data/console): the network response for the merchant detail carries createdAt as an ISO string, which is the shape the old code could not read.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS. Reads "Aug 2026". Confirmed under both logins.
Evidence:  verify-admin.json accountFields
Follow-up: none

12. FALSIFYING. With no createdAt, the field falls to the shared empty glyph, not a literal string โ€‹

  • Goal: this is the falsifying arm for scenarios 11 and for the empty-value fix, and its prediction is stated before the run.
  • Prediction: with createdAt nulled in the response, Member since renders the shared empty-value glyph WITH screen-reader text beside it. Before the change the same input produced the literal words "Not set" as a normal value, with no glyph and no screen-reader text, which is why the empty branch of DetailField was unreachable.
  • Steps:
    1. Intercept the merchant detail response in the browser and set merchant.createdAt to null. Do not write to shared dev data.
    2. Reload Account and inspect the Member since cell.
  • Expected: a .detail-field__empty containing an sr-only span reading "Not set".
  • What a surprise means: if the cell still shows the literal words with no sr-only span, the primitive is not what is rendering and the fix did not take.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS as predicted. With createdAt nulled on /api/auth/merchant/me, the cell renders "-" plus an sr-only "Not set", and emptyGlyphHasSrText reads true. The interception happened in the browser context only.
Evidence:  verify-merchant.json accountFields and blankedCreatedAtOn
Follow-up: none

13. The offer form still uses the project's select wrapper, not a native one โ€‹

  • Goal: a regression guard on the biggest merchant surface, since the offer form was opened during this pass.
  • Steps:
    1. Open /merchant/<id>/offers/create.
    2. Count select elements in the document.
  • Expected: zero. Every dropdown is StyledSelect.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS. Zero native select elements. The five-step section rail, the phone preview and the venue StyledSelect all render.
Evidence:  offer-form-0828.json nativeSelects, screens/merch-0828-offer-form.png
Follow-up: none

14. FALSIFYING. The merchant switcher names the business under StrictMode โ€‹

  • Goal: close #980 and prove the fix with a case that can lose.
  • Prediction: under React StrictMode the switcher resolves the business name. With the fix reverted, the SAME case fails while the file's other four cases stay green, which is the whole point: the suite could not see this defect because nothing in it rendered under StrictMode.
  • Preconditions: the dev build, which wraps the app in React.StrictMode in apps/admin/src/main.jsx, so the live dev path IS the failing path.
  • Steps:
    1. Open /merchant/<id>/overview and read the switcher card in the sidebar footer.
    2. Open /merchant/<id>/offers and read it again.
    3. Hard reload and read it a third time, since the defect is about what survives a remount.
    4. In the unit suite, run the new StrictMode case against the fix, then against the original code.
  • Expected: the business name all three times in the browser. The unit case passes with the fix and FAILS without it.
  • What a surprise means: if the unit case passes against the original code too, it is not reproducing the defect and it is not a regression guard.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS, both arms. The card reads "VIEWING Round Trip Cafe 15890656" on
           Overview, on Offers, and after a hard reload, where it read "VIEWING
           Merchant" before. The new unit case passes with the fix (11 files,
           103 tests green) and fails against the original code with the other
           four cases in that file still passing, which is the evidence that the
           suite was blind to this and now is not.
Evidence:  switcher-980.json, screens/merch-0828-fix-switcher.png,
           apps/admin/src/shared/components/__tests__/MerchantSwitcher.test.jsx
Follow-up: none

15. The offer builder blocks publish on an over-limit inherited title โ€‹

  • Goal: an Overview title too long for a selected placement must stop publish, not just warn.
  • Expected: canPublish false at 40 characters on an inline placement, true at 30, with nothing else changing.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    PASS, in a unit test on validateAll. THE BROWSER COULD NOT SETTLE
           THIS and the attempt is recorded because it looked like it had:
           Publish came back disabled in both arms, but Schedule was empty in
           both, so the run could not tell "blocked by the title" from "blocked
           by the missing date". Two arms failing for the same unrelated reason
           is a test that cannot WIN. The unit test isolates the title alone.
Evidence:  apps/admin/src/merchant/offers/state/__tests__/validators.test.js,
           and builder-submit-0828.json for the browser run that could not.
Follow-up: none

23. The two failing colour tokens are split โ€‹

  • Goal: --muted-dark-2 renders text at 2.46 to 2.91 against a required 4.5, and --secondary-accent-blue-500 at 4.26 on a card. Both need their TEXT uses moved to a passing value while their border, dot, background and hover-ring uses stay where they are.
  • Why a split rather than a swap: both tokens are used in two directions. Text needs them lighter; the four non-text uses of --muted-dark-2 (step beads, sub-step dots, input hover rings, placement checkboxes) and the two of the blue need them where they are. The design skill's rule is that a token which has to move in two directions is two tokens.
Result:    [x] pass (--muted-dark-2 only)   [ ] fail   [ ] NOT EXERCISED
Actual:    DONE for --muted-dark-2 on 2026-08-30. The token no longer exists.
           Its 23 direct uses were re-read one at a time and sorted into four
           jobs, not two:
             14 text            -> --text-secondary (#8a94a0)
              5 decorative mark -> --glyph-dim      (#52585f, value unchanged)
              4 control ring    -> --border-strong  (#52585f, value unchanged)
             chart series       -> --chart-neutral  (#52585f, value unchanged)
           The earlier count of "19 text, 5 non-text" folded five aria-hidden
           marks (the unlit wick flame, the child dot, the optional review
           bullet, the member-card icon, the select chevron) into "text". They
           are not text, they have no contrast floor, and giving them their own
           token is what stops the next person lifting them to pass a rule that
           does not apply to them.
           All four indirection sites handled: --lc-text-mute (all five uses are
           text) points at --text-secondary; --ldr-muted-2 was doing text AND a
           hover border, so it split the same way into --ldr-text-quiet and
           --ldr-border-strong; the two hardcoded '#52585f' literals in
           chartCommon.jsx and BillingReports.jsx stay literal (recharts reads
           them in JS and never resolves a CSS var) with comments naming
           --chart-neutral.
           --secondary-accent-blue-500 is NOT done. This scenario covers only
           --muted-dark-2.
Evidence:  Live, against the operator's own admin dev server on
           http://localhost:3001 (hers, already running; the pass only
           navigated and measured).
           harness/token-split-0830.mjs + .json: five admin routes, no text
           under 4.5 and nothing left at #52585f except the two aria-hidden
           wick flames, which are --glyph-dim by design. --muted-dark-2 reads
           (unresolved) on the live root, so nothing still points at it.
           harness/token-split-0830-selectors.json: measured by name in the
           running offer builder, char-counter--ok 2.46 -> 5.76 on #18181b,
           page-tabs__tab--soon 2.91 -> 6.82 on #000000, page-tabs__soon 6.82.
           harness/token-split-0830-builder.png and -account.png.
Not exercised: the Review step, the section eyebrow, the placement tabs and
           checkbox, the account "needs add" line, the member card and the
           taxonomy labels were NOT reached. The probe merchant has zero venues
           and zero offers, so the builder stops at step 1 and those states do
           not render. They are the same two rules from the same sheet and they
           built clean, but they were not seen. Scenario 24 stays open for them.

24. The shared-token change is verified on the ADMIN portal โ€‹

  • Goal: a shared change lands on both portals, so both get checked.
Result:    [ ] pass   [ ] fail   [x] PARTIAL
Actual:    Five admin routes were walked live on 2026-08-30 after the
           --muted-dark-2 split (dashboard, api, config overview, financials
           costs, users) and measured clean: no text under 4.5, nothing left at
           the old value except the two aria-hidden wick flames.
What would close it: the merchant states scenario 23 could not reach (Review
           step, placements, account detail) need a merchant with a venue and an
           offer. Also still open for --secondary-accent-blue-500, which has not
           been split at all.

25. The merchant design overhaul direction โ€‹

  • Goal: the operator picks a direction so the overhaul can be built.
Result:    [ ] pass   [ ] fail   [x] NOT EXERCISED
Actual:    Blocked on her, deliberately and correctly. Three directions were
           rendered with the app's real CSS at a real 1440x950 window and served
           at http://localhost:8099/nav.html. She has not picked, and this
           session did not pick for her.
Evidence:  the four-panel render in the scratchpad mock folder, and
           merchant-design-brief.md for the measurements behind it.
What would close it: one answer from her, A, B or C.

Built with VitePress