Merchant portal defects, 2026-08-28 - Manual Test Plan โ
| Field | Value |
|---|---|
| Branch / PR | fix/merchant-portal-988 (worktree .claude/worktrees/merchant-defects) |
| Environment | BEFORE: 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 / config | none. Every item here is layout and render, no flag gates it. |
| Build (commit) | recorded per run in each result block |
| Tester | agent (headless Playwright chromium), operator for the design judgement calls |
| Date | 2026-08-28 |
Summary โ
| # | Scenario | Result | Notes |
|---|---|---|---|
| 1 | Merchant rail footer is pinned to the bottom at a normal window | [x] pass | 367.9px above the rail bottom before, 0 after |
| 2 | Merchant rail footer stays pinned at a short window where the nav overflows | [x] pass | 1440x500, footer at 500, nav scrolls internally |
| 3 | Merchant rail footer is pinned when the rail is collapsed | [x] pass | 72px rail, footer at the rail bottom |
| 4 | Merchant rail footer sits at the bottom of the mobile drawer | [x] pass | 390x844, footer at 844 |
| 5 | CONTROL. The ADMIN rail footer is pinned, before and after | [x] pass | identical before and after, the admin rail never had it |
| 6 | FALSIFYING. Putting the suspected cause back reproduces the defect | [x] pass | predicted 582.1 with the rule, 950 without, both confirmed |
| 7 | A real merchant login sees the same pinned footer | [x] pass | own profile card, pinned, footerToRailBottom 0 |
| 8 | Regression. M1 to M4 stay fixed | [x] pass | all four still hold on the fixed build |
| 9 | Survey pass. Every merchant route renders without a console error | [x] pass | 7 routes reached, 5 defects fixed, 8 reported |
| 10 | One placement reads the same name on all three merchant screens | [x] pass | was three different names, now "Hero Rail" everywhere |
| 11 | Account "Member since" resolves to a real date | [x] pass | reads "Aug 2026", was structurally unable to resolve |
| 12 | FALSIFYING. With no createdAt, the field falls to the shared empty glyph | [x] pass | glyph plus sr-only "Not set", as predicted |
| 13 | The offer form still uses the project's select wrapper | [x] pass | zero native select elements |
| 14 | FALSIFYING. The merchant switcher names the business under StrictMode | [x] pass | #980 closed, and the new unit case fails against the old code |
| 15 | The offer builder blocks publish on an over-limit inherited title | [x] pass | isolated in a unit test; the browser could NOT settle it, see the scenario |
| 16 | Archive carries secondary weight and Delete keeps danger | [x] pass | verified live on the offer detail |
| 17 | The offer title renders once on the detail | [x] pass | one occurrence in the main area, badge moved to the header |
| 18 | Notes has an empty state matching its sibling | [x] pass | icon plus a sentence, different per reader |
| 19 | No merchant-facing string uses our internal vocabulary | [x] pass | eight strings rewritten, "geofence radius" reported not changed |
| 20 | The Overview counter shows the strictest selected limit | [x] pass | four states including the hero-only falsifying arm |
| 21 | A placement row has no nested interactive control | [x] pass | axe: serious before, zero after |
| 22 | Every merchant surface passes axe except contrast | [x] pass | 19 surfaces, 12 clean, the rest is two shared tokens |
| 23 | The two failing colour tokens are split | [ ] NOT EXERCISED | approved by the operator, not started. See the scenario |
| 24 | The shared-token change is verified on the ADMIN portal | [ ] NOT EXERCISED | depends on 23 |
| 25 | The merchant design overhaul direction | [ ] NOT EXERCISED | blocked 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:3001or ports 8080 to 8086. They are the operator's. Reads only. - The signin rate limit is the trap.
/auth/admin/signinand/auth/merchant/signinshare 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 PlaywrightstorageState, 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_IDfrom 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 โ
1. Merchant rail footer is pinned to the bottom at a normal window โ
- 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:
- Open the merchant Overview.
- Measure the sidebar footer's bottom edge and the rail's bottom edge.
- 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').flexGrowis1, not0..sidebarheight 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: none2. Merchant rail footer stays pinned at a short window where the nav overflows โ
- 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:
- Resize to 1440x500 on the merchant Overview.
- Measure the footer's bottom edge against the viewport height.
- 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-navscrollHeightexceeds itsclientHeight, and the footer'sgetBoundingClientRect().bottomis 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: none3. Merchant rail footer is pinned when the rail is collapsed โ
- Goal: the collapsed 72px rail keeps the same contract.
- Preconditions: same session, viewport 1440x950.
- Steps:
- Click the rail collapse toggle.
- 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: none4. Merchant rail footer sits at the bottom of the mobile drawer โ
- 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:
- Resize to 390x844 and open the nav drawer.
- 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: none5. CONTROL. The ADMIN rail footer is pinned, before and after โ
- 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:
- Open an admin route with the standard rail.
- Measure the footer's bottom edge against the rail's bottom edge at both heights.
- Read
flexGrowon the admin.sidebar-nav.
- Expected: pinned at both heights,
flexGrowis1, 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--merchantand 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: none6. 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:
- Measure the footer's bottom edge with the rule active.
- 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: none7. A real merchant login sees the same pinned footer โ
- 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_EMAILon the merchant portal. NOTE: this spends a signin slot from the shared bucket, so run it last. - Steps:
- Sign in as the test merchant.
- 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: none8. 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:
- Measure every
.role-card__metricrow height and confirm the "Unique visitors" row matches its siblings (M1). - Confirm every Recent Offers title renders on one line (M2).
- 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).
- Confirm the delivery label reads
Clicksrather thanClicks ยท โ CTRwhen there are no impressions (M4).
- Measure every
- 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: none9. 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:
- Visit
/merchant/<id>/{overview,offers,venues,notes,photos,account,settings}in one session. - Screenshot each at 1440x950 into the project harness folder.
- Record every console error and page error per route.
- Record any element whose rendered text contains a raw identifier, an unresolved placeholder, or a broken label.
- Visit
- 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:
- Open
/merchant/<id>/offersand read the placement segment of a row's subtitle. - Click that row and read the Placement field on the detail.
- Open Create Offer and read the placement label on the Placements step.
- Open
- Expected: all three read
Hero Rail. None reads the raw enumhero, and none readsDashboard 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: none11. 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:
- Open
/merchant/<id>/account. - Read the Member since field.
- Open
- Expected: a month and year, for example
Aug 2026, not an empty value. - Verify (data/console): the network response for the merchant detail carries
createdAtas 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: none12. 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
createdAtnulled 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 ofDetailFieldwas unreachable. - Steps:
- Intercept the merchant detail response in the browser and set
merchant.createdAtto null. Do not write to shared dev data. - Reload Account and inspect the Member since cell.
- Intercept the merchant detail response in the browser and set
- Expected: a
.detail-field__emptycontaining ansr-onlyspan reading "Not set". - What a surprise means: if the cell still shows the literal words with no
sr-onlyspan, 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: none13. 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:
- Open
/merchant/<id>/offers/create. - Count
selectelements in the document.
- Open
- 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: none14. 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.StrictModeinapps/admin/src/main.jsx, so the live dev path IS the failing path. - Steps:
- Open
/merchant/<id>/overviewand read the switcher card in the sidebar footer. - Open
/merchant/<id>/offersand read it again. - Hard reload and read it a third time, since the defect is about what survives a remount.
- In the unit suite, run the new StrictMode case against the fix, then against the original code.
- Open
- 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: none15. 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:
canPublishfalse 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: none23. The two failing colour tokens are split โ
- Goal:
--muted-dark-2renders text at 2.46 to 2.91 against a required 4.5, and--secondary-accent-blue-500at 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. Thedesignskill'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.