bug(frontend): /tiers fee cards wrap Hold and fee columns on phone-width #651

Closed
opened 2026-08-26 01:06:39 +00:00 by PlasticDigits · 6 comments
PlasticDigits commented 2026-08-26 01:06:39 +00:00 (Migrated from gitlab.com)

Summary

On phone-width /tiers (reported on iPhone + Brave on https://dex.cl8y.com/tiers, disconnected), each self-register tier card wraps so badly that Tier N, Hold {n} CL8Y, and fee discount read as stacked single words. A large empty gap sits where the Register column is reserved even when no wallet is connected.

Community report (2026-08, anonymized): iPhone, Brave, mainnet /tiers. Reporter first guessed the gap was for wallet-connect chrome, then correctly identified it as the connected Register slot. They still want the left/middle identity (Tier 1 / Hold 1 CL8Y) to stay readable — either as two short lines (Tier 1 then Hold 1 CL8Y) or without wrapping at all so more of the ladder fits on one screen.

This is a layout bug, not a fee-math or register-tx bug. Numbers on the card match the canonical ladder (#198). Desktop /tiers is usable; QA row 11.1.4 (“Tiers page mobile — table readable, buttons accessible”) is currently failing on production phones.

Related (do not regress): #476 (Hold + Register eligibility / 18-dec CL8Y labels), #384 (register/deregister gas), #514 (limit-place discount vs swap), #537 (pair-scoped fee chrome), #489 (retail copy). Do not fold this into picker viewport work (#632).


Current codebase

Page and row

TiersPage.tsx is the only /tiers surface (App.tsx lazy route). TierRow is a single horizontal flex (flex items-center gap-4 p-4) with five children that never wrap and never collapse:

Slot Markup Width behavior
1. Badge w-12 h-12 rounded box with tier_id Fixed 48px
2. Identity flex-1 min-w-0 — uppercase tracking-wide Tier {id} + text-sm Hold {formatCl8y} CL8Y Shrinks first (min-w-0)
3. Discount text-right — {discountLabel} + uppercase fee discount Intrinsic; no shrink-0 / whitespace-nowrap
4. Eff. fee text-right min-w-[4.5rem] — mint {effectiveFeeLabel} + eff. fee* Min 4.5rem; always shown for self-register
5. Action always w-28 (7rem / 112px) Reserved even when empty

When disconnected, canSelfRegister is false (!!address && !isOnGovernanceTier), so slot 5 renders an empty w-28 div. That is the “big blank” on the screenshot. Combined with gap-4 × 4 and card p-4, a ~390px iPhone content width leaves ~60–80px for identity — so uppercase TIER 1 wraps to TIER / 1 and Hold 1 CL8Y wraps to Hold / 1 / CL8Y. FEE DISCOUNT wraps the same way. The mint EFF. FEE* column looks fine because it has a min-width and short labels.

Intended copy is already the reporter’s preferred form: a single span Hold {formatCl8y(tier.min_cl8y_balance)} CL8Y (not Hold: + amount on the next line). The wrap is CSS, not a missing colon.

Connected vs disconnected chrome

  • Disconnected: banner Connect your wallet to register for a fee discount tier. (shell-panel-strong). No Your Status panel. Tier cards still reserve the Register column.
  • Connected: Your Status panel (tier name / “Not registered” + Deregister). Register buttons appear in slot 5 when canSelfRegister && !isCurrentTier. That panel is not the empty gap inside each card.

There is no TiersPage Vitest file (gap noted in gaps/GAP_1780200149.md). Playwright e2e/fee-tiers.spec.ts uses the default 1280×720 viewport (playwright.config.ts) and asserts getByText(/Hold.*CL8Y/) — it cannot fail on wrap. QA 11.1.4 is manual only.

How it works table (same page, same class of bug)

Below the cards, a fixed grid-cols-5 dumps Tier / CL8Y Hold / Discount / Eff. Fee* / Limit place* for the full 1–9 ladder. On ~390px that grid also wraps every header and cell. Bundle a mobile treatment here so the page is readable end-to-end.

Fee labels (must stay)

effectiveFeeLabel(discountBps, baseFee = 180)
// (baseFee * (10000 - discountBps)) / 10000

baseFee comes from factory default_fee_bps (fallback 180). Display helpers: discountLabel, limitPlaceLabel / resolveLimitDiscountBps / makerPlacementFeeBps (#514 I13). Hold amounts use formatTokenAmountAbbrev + CL8Y 18 decimals (#476 / I12). Self-register filter: !tier.governance_only (tiers 1–9 only; 0 / 255 stay off this list — I3).


Why this is needed

/tiers is the only place a retail wallet registers for a fee discount (I12: holding alone does not apply). On the devices people actually use (iPhone Safari/WebKit — Brave on iOS is WebKit, not Chromium), the ladder is unreadable: “Hold 1 CL8Y” looks like three labels, and disconnected users see a dead 112px column on every card.

That wastes vertical space (nine cards + How it works) and makes the Register CTA harder to find once connected. This is not “users should rotate to landscape.” Phone-width must show Tier N, Hold {n} CL8Y, {pct} fee discount, and {pct} eff. fee* as intact phrases.


Constraints / guardrails

  1. Display-only. Do not change Register / Deregister execute msgs, gas limits (#384), or on-chain GetTiers / GetRegistration queries.
  2. Do not invent discounts. Card % and eff. fee stay discount_bps + factory default_fee_bps (I4). Do not apply a wallet get_discount to the published ladder. Pair-scoped chrome stays on Swap/Pool/Trade (#537 / I14) — /tiers is the registry ladder, not a pair quote.
  3. I13. How it works Limit place* stays limit_discount_bps (tier 9 place = 0). Do not raise swap discount_bps on tier 9. Crossing the book still uses the taker swap discount.
  4. I12 / #476. Keep Hold {human} CL8Y (18-dec formatTokenAmountAbbrev). Do not relabel as uluna / wei / min_cl8y_balance. Do not imply holding alone registers the wallet.
  5. I3. Only governance_only: false rows in the self-register list. No Register on tier 0 / 255.
  6. Copy (#489). Labels ≤ ~5 words. Prefer keeping Hold {n} CL8Y as one phrase (reporter option 2). A two-line identity (Tier 1 then Hold 1 CL8Y) is OK. Do not add always-on essays (“tap Register after connect”).
  7. Design system (#488). btn-primary / btn-muted, shell-panel-strong, var(--ink) / --mint (alias blue). No *-neo. One chrome layer per card. Light + dark.
  8. Register hit target. When the button is shown, min height ≥ 44px on coarse/narrow. Do not cover it with the identity block. Empty Register slot must not reserve w-28 when the button is absent.
  9. Do not hide Register behind connect-only layout that then overflows. Connected + 9 Register buttons must still fit without clipping the hold amount or making the wrong row’s button the tap target.
  10. iOS WebKit. Fix against ≤390px CSS width (iPhone + Brave/Safari). Do not ship a Chromium-only flex quirk. Viewport meta stays default (resizes-visual); this is not a visualViewport picker issue.
  11. No new indexer / contract surface. No public per-trader probe. No dangerouslySetInnerHTML on LCD tier fields.
  12. Playwright workers stay 5 for e2e-smoke. Do not add e2e-tx for this layout fix. Register/deregister txs stay in existing e2e/fee-tier-tx.spec.ts / make verify-issue-384 if touched at all (they should not be).
  13. Canonical table. Do not duplicate the numeric ladder in new docs; link docs/reference/fee-discount-tiers.md.

Relevant files

Primary

  • frontend-dapp/src/pages/TiersPage.tsx — TierRow flex + How it works grid-cols-5
  • frontend-dapp/src/App.tsx — /tiers route
  • frontend-dapp/src/index.css / theme-dark.css / theme-light.css — only if a shared card utility is added

Copy / math (read, do not fork)

  • frontend-dapp/src/utils/formatAmount.ts — formatTokenAmountAbbrev
  • frontend-dapp/src/utils/limitOrderFeeSummary.ts — effectiveSwapFeeBps, makerPlacementFeeBps, resolveLimitDiscountBps
  • frontend-dapp/src/utils/tokenRegistry.ts — CL8Y 18 decimals
  • frontend-dapp/src/services/terraclassic/feeDiscount.ts — getTiers / getRegistration / register / deregister
  • docs/reference/fee-discount-tiers.md — canonical 1–9 minima / bps
  • skills/AGENTS_FEE_DISCOUNT_TIERS.md
  • docs/frontend.md § Tiers Page (thin today — extend with phone-width invariants)
  • skills/AGENTS_FRONTEND_DESIGN_SYSTEM.md / skills/AGENTS_FRONTEND_COPY_COGNITIVE_LOAD.md
  • QA_TEMPLATE.md — 4.1.* and 11.1.4

Tests to add / extend

  • frontend-dapp/src/pages/TiersPage.test.tsx — new (page currently has no unit coverage)
  • frontend-dapp/e2e/fee-tiers.spec.ts — add a 390×844 (or 375×667) project/test; assert no wrap of Hold / fee phrases
  • frontend-dapp/e2e/fee-tier-tx.spec.ts — must stay green; do not require it for the layout MR

Ship one responsive TierRow + a mobile How it works treatment. Do not keep five always-on columns on ≤767px.

A — Stop reserving the empty Register column

Render the w-28 (or equivalent) only when a Register button or “Governance only” label is actually shown. Disconnected and current-tier rows should not leave a 112px hole. That alone returns enough width for Hold 1 CL8Y on many phones.

B — Phone-width identity + fee cluster (preferred)

At max-width: 767px (same breakpoint as .app-mobile-nav-shell):

  1. Identity (badge + Tier N + Hold {n} CL8Y) takes the remaining row (or a full first row). Keep Hold {n} CL8Y as one unbreakable phrase (whitespace-nowrap on that span, or a single line under Tier N).
  2. Fee cluster is shrink-0 text-right: {pct} + fee discount on the left of the cluster, mint {pct} + eff. fee* on the right — or stacked as two compact right-aligned stats. Do not let FEE / DISCOUNT wrap to one word per line.
  3. Register (when present) sits on a second row full-width, or at the end of the fee cluster if it still fits without wrapping identity. Prefer a second row on ≤390px so the CTA stays ≥44px.

Desktop (≥768) may keep a single row, but still must not reserve an empty w-28.

C — How it works on phone

Replace grid-cols-5 on ≤767 with either:

  • a horizontal scroll table (overflow-x-auto, min-w on the grid, no clipped headers), or
  • per-tier stacked rows (Tier N / Hold / Discount / Eff. fee / Limit place as labeled pairs).

Do not ship five squeezed columns that wrap every cell.

D — Out of scope

  • Changing tier minima, bps, or factory default fee.
  • Showing governance tiers 0/255 as self-register.
  • Pair-specific eff. fee on this page (footnote already says some pairs differ).
  • Keplr in-app chrome / visualViewport (#632).

Acceptance criteria

  • Disconnected, 390×844 (and 375×667): each self-register card shows Tier N on one line (or Tier + N without stacking TIER above 1 as the only content in a 2-em column). Hold {n} CL8Y is one phrase on one line (e.g. Hold 1 CL8Y, Hold 7,500 CL8Y).
  • Disconnected: no empty ~112px Register column; no large dead gap between identity and the fee cluster.
  • Fee cluster: {discount}% + fee discount and {eff}% + eff. fee* remain intact phrases (no FEE / DISCOUNT / EFF. / FEE* stacked as single words).
  • Connected, unregistered: Register is visible for tiers 1–9, ≥44px tall on coarse/narrow, one button per row, does not overlay the wrong card or clip the hold amount.
  • Connected, registered: Active badge + Deregister on Your Status; current row has no Register; other rows still Register.
  • How it works on phone: all five columns (or stacked labels) readable without one-word-per-line wrap; Limit place* still shown for I13.
  • Desktop ≥768: cards still one row (or an intentional denser layout) with the same numbers; e2e/fee-tiers.spec.ts heading / 9 tiers / connect prompt stay green.
  • Light + dark; iOS WebKit (Safari or Brave) and Android Chrome. No *-neo. No new fee-trivia banner.
  • Docs: docs/frontend.md § Tiers Page notes phone-width card + How it works rules. QA 11.1.4 tightened to the phrases above.

Test plan (all paths)

Unit (new TiersPage.test.tsx)

Mock getTiers / getRegistration / getFactoryConfig / wallet store (same pattern as other pages).

  1. Disconnected: connect banner visible; Your Status absent; no Register buttons; no empty w-28 (assert Register column absent or width 0 / not in layout). Hold 1 CL8Y (tier 1) and Hold 5 CL8Y (tier 2) present as full strings.
  2. Connected, unregistered: Your Status “Not registered”; Register count ≥ 1 (tiers 1–9); Deregister absent.
  3. Connected, registered tier 2: Active on tier 2; Register absent on that row; Deregister present; other self-register rows still Register.
  4. Governance-only entries in the API fixture do not render Register and do not appear in the self-register list.
  5. Fee labels: with default_fee_bps 180, tier 1 shows 2.5% and 1.75%; tier 9 shows 95% and the matching I4 eff. fee. How it works Limit place* for tier 9 is 0 / 0.00% per I13 helpers — do not hardcode a second ladder.
  6. Missing VITE_FEE_DISCOUNT_ADDRESS: “Fee discount contract not configured.”
  7. Tiers query error: RetryError + retry calls refetch.
  8. Narrow layout (jsdom): set container / matchMedia('(max-width: 767px)') if the implementation keys off it; assert identity + hold text nodes are single-line phrases (no split across multiple block children). If CSS wrap cannot be asserted in jsdom, use a data-testid on the hold span and assert whitespace-nowrap / class contract.

Vitest (must stay green)

cd frontend-dapp && npm test -- --run \
  src/pages/TiersPage.test.tsx \
  src/utils/__tests__/formatAmount.test.ts \
  src/utils/__tests__/limitOrderFeeSummary.test.ts \
  src/utils/__tests__/feeDiscountUiCopy.test.ts

(limitOrderFeeSummary test path may differ — run the existing fee-summary suite the page imports.)

Playwright (e2e-smoke, 5 workers)

  1. Keep existing e2e/fee-tiers.spec.ts at 1280×720 (heading, 9× Tier N, discounts, connect prompt, How it works).
  2. New phone test in that file (or sibling): page.setViewportSize({ width: 390, height: 844 }) (and 375×667).
    • For each of tiers 1, 2, 9: getByText exact / regex Hold 1 CL8Y, Hold 5 CL8Y, Hold 7,500 CL8Y (or the live formatTokenAmountAbbrev string) is visible as one element — not three separate nodes that only match /Hold/ + /1/ + /CL8Y/.
    • Bounding boxes: hold text height is a single line (≤ ~28px at text-sm); discount label fee discount height is a single line.
    • Disconnected: no Register button; card getBoundingClientRect() — no empty 100px+ trailing hole (e.g. last text cluster’s right is within ~24px of the card’s content box).
    • How it works: CL8Y Hold / Eff. Fee* / Limit place* visible without requiring horizontal overflow that clips the first column; if scroll is used, scrollWidth works and headers stay one line.
  3. Connected (existing connectWallet): viewport 390×844 — Register buttons ≥ 1; each button height ≥ 44; tap target does not overlap the previous card’s hold text (getBoundingClientRect gap ≥ 0).
  4. Do not add e2e-tx here.

Manual (required — iOS Brave/Safari not in Playwright)

  1. iPhone + Brave (WebKit), disconnected, https://dex.cl8y.com/tiers (or LocalTerra /tiers): screenshot cards 1–4 and 9. Confirm Tier N + Hold {n} CL8Y + fee cluster. No empty Register column.
  2. Same device, Safari (confirm not Brave-only).
  3. Connect (Keplr / Simulated Wallet on LocalTerra): Register appears; tap target is the intended row; Your Status does not add a second empty gap inside cards.
  4. Register tier 1 (LocalTerra, #384 gas): Active badge; Deregister; layout still intact.
  5. Android Chrome 360×800-class: same disconnected + connected checks.
  6. Desktop 1280 and tablet 768: no regression; How it works five columns readable.
  7. Light + dark.
  8. Factory default_fee_bps ≠ 180 (if a LocalTerra pair config differs): card footnote still explains pair override; published eff. fee still uses factory default (today’s behavior).

Test plan (attack, hack, and abuse)

Layout sits on the register path. Treat Register as a trust-boundary control even though msgs are unchanged.

  1. Wrong-row Register. Narrow reflow must not stack buttons so a tap on “Tier 3 Register” submits tier_id 2 or 4. RTL/unit: each button’s onRegister(tier_id) matches that row’s entry.tier_id. Playwright: click Register on a mid list row (tier 5) and assert the mutation / pending state is scoped (or data-testid={register-tier-${id}}).
  2. Hidden governance Register. A malicious / extra LCD governance_only: false spoof is out of band; still: fixtures with tier 0 / 255 must not show Register. Do not trust tier_id text alone — filter on governance_only.
  3. XSS via LCD fields. tier strings / future names render as text. No dangerouslySetInnerHTML. CL8Y_TOKEN_ADDRESS in How it works stays font-mono text.
  4. Hold-amount spoof / confusion. Do not parse display text back into a register payload. Register sends only tier_id. Truncation must not turn Hold 7,500 CL8Y into Hold 7 or Hold 7500 without thousands separator consistency (formatTokenAmountAbbrev). Overflow ellipsis on the hold phrase is forbidden if it hides the magnitude (prefer wrap the whole card, not 7,500 → 7…).
  5. Fake discount chrome. CSS reorder must not place tier 9’s 95% next to tier 1’s hold line. Keep discount + hold in the same card DOM subtree (no grid that interleaves rows). Snapshot / testids per row.
  6. Clickjacking / overlay. New full-width Register row must sit below the card chrome, z-index below Connect / Legal / WalletConnect (z-[9999]). Must not be a transparent hit layer over the header CONNECT button.
  7. Disabled / pending double-submit. Keep disabled={isRegistering} on all Register buttons while a tx is in flight (today’s behavior). Reflow must not remount a fresh enabled button mid-tx.
  8. Disconnected “ghost” CTA. Must not show a disabled Register that looks tappable and then no-ops; connect prompt stays the banner, not a fake button.
  9. Fee footnote abuse. * / “some pairs may differ” must remain; do not present the mint eff. fee as a guaranteed pair quote (I14 / #537).
  10. Viewport / font spoof. Extreme devicePixelRatio / 200% text: card must grow vertically, not overlap the next Register. No overflow: hidden that clips the hold amount or the CTA.

Verification criteria

Done when:

  1. Manual iPhone Brave and Safari, disconnected /tiers: all nine self-register cards show intact Hold {n} CL8Y and fee phrases; no empty Register column. Connected: Register ≥44px, correct tier_id.
  2. TiersPage.test.tsx covers disconnected / unregistered / registered / governance filter / fee labels.
  3. Playwright phone viewport (390 and 375) asserts Hold phrases + no empty hole + Register geometry when connected.
  4. e2e/fee-tiers.spec.ts desktop + make test-frontend (or scoped Vitest above) green. No e2e-tx required.
  5. docs/frontend.md § Tiers Page + QA 11.1.4 updated. No duplicated numeric ladder in new docs.
  6. make lint-frontend clean. No *-neo. Light + dark checked.
# after implementation
bash scripts/with-node.sh --cwd frontend-dapp -- npm test -- --run src/pages/TiersPage.test.tsx
# phone + desktop smoke (5 workers):
#   frontend-dapp/e2e/fee-tiers.spec.ts
make lint-frontend
## Summary On **phone-width** `/tiers` (reported on iPhone + Brave on `https://dex.cl8y.com/tiers`, disconnected), each self-register tier card wraps so badly that **Tier N**, **Hold {n} CL8Y**, and **fee discount** read as stacked single words. A large empty gap sits where the **Register** column is reserved even when no wallet is connected. Community report (2026-08, anonymized): iPhone, Brave, mainnet `/tiers`. Reporter first guessed the gap was for wallet-connect chrome, then correctly identified it as the connected **Register** slot. They still want the left/middle identity (`Tier 1` / `Hold 1 CL8Y`) to stay readable — either as two short lines (`Tier 1` then `Hold 1 CL8Y`) or without wrapping at all so more of the ladder fits on one screen. This is a **layout** bug, not a fee-math or register-tx bug. Numbers on the card match the canonical ladder ([#198](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/198)). Desktop `/tiers` is usable; QA row **11.1.4** (“Tiers page mobile — table readable, buttons accessible”) is currently failing on production phones. Related (do not regress): [#476](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/476) (Hold + Register eligibility / 18-dec CL8Y labels), [#384](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/384) (register/deregister gas), [#514](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/514) (limit-place discount vs swap), [#537](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/537) (pair-scoped fee chrome), [#489](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/489) (retail copy). Do **not** fold this into picker viewport work ([#632](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/632)). --- ## Current codebase ### Page and row [`TiersPage.tsx`](frontend-dapp/src/pages/TiersPage.tsx) is the only `/tiers` surface (`App.tsx` lazy route). `TierRow` is a **single horizontal flex** (`flex items-center gap-4 p-4`) with **five** children that never wrap and never collapse: | Slot | Markup | Width behavior | |------|--------|----------------| | 1. Badge | `w-12 h-12` rounded box with `tier_id` | Fixed 48px | | 2. Identity | `flex-1 min-w-0` — `uppercase tracking-wide` **Tier {id}** + `text-sm` **Hold {formatCl8y} CL8Y** | **Shrinks first** (`min-w-0`) | | 3. Discount | `text-right` — `{discountLabel}` + `uppercase` **fee discount** | Intrinsic; no `shrink-0` / `whitespace-nowrap` | | 4. Eff. fee | `text-right min-w-[4.5rem]` — mint `{effectiveFeeLabel}` + **eff. fee*** | Min 4.5rem; always shown for self-register | | 5. Action | **always** `w-28` (7rem / 112px) | Reserved even when empty | When disconnected, `canSelfRegister` is false (`!!address && !isOnGovernanceTier`), so slot 5 renders an **empty** `w-28` div. That is the “big blank” on the screenshot. Combined with `gap-4` × 4 and card `p-4`, a ~390px iPhone content width leaves ~60–80px for identity — so `uppercase` **TIER 1** wraps to `TIER` / `1` and **Hold 1 CL8Y** wraps to `Hold` / `1` / `CL8Y`. **FEE DISCOUNT** wraps the same way. The mint **EFF. FEE*** column looks fine because it has a min-width and short labels. Intended copy is already the reporter’s preferred form: a single span `Hold {formatCl8y(tier.min_cl8y_balance)} CL8Y` (not `Hold:` + amount on the next line). The wrap is CSS, not a missing colon. ### Connected vs disconnected chrome - **Disconnected:** banner `Connect your wallet to register for a fee discount tier.` (`shell-panel-strong`). No **Your Status** panel. Tier cards still reserve the Register column. - **Connected:** **Your Status** panel (tier name / “Not registered” + Deregister). Register buttons appear in slot 5 when `canSelfRegister && !isCurrentTier`. That panel is **not** the empty gap inside each card. There is **no** `TiersPage` Vitest file (gap noted in `gaps/GAP_1780200149.md`). Playwright [`e2e/fee-tiers.spec.ts`](frontend-dapp/e2e/fee-tiers.spec.ts) uses the default **1280×720** viewport (`playwright.config.ts`) and asserts `getByText(/Hold.*CL8Y/)` — it cannot fail on wrap. QA **11.1.4** is manual only. ### How it works table (same page, same class of bug) Below the cards, a **fixed `grid-cols-5`** dumps Tier / CL8Y Hold / Discount / Eff. Fee* / Limit place* for the full 1–9 ladder. On ~390px that grid also wraps every header and cell. Bundle a mobile treatment here so the page is readable end-to-end. ### Fee labels (must stay) ```ts effectiveFeeLabel(discountBps, baseFee = 180) // (baseFee * (10000 - discountBps)) / 10000 ``` `baseFee` comes from factory `default_fee_bps` (fallback 180). Display helpers: `discountLabel`, `limitPlaceLabel` / `resolveLimitDiscountBps` / `makerPlacementFeeBps` ([#514](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/514) I13). Hold amounts use `formatTokenAmountAbbrev` + CL8Y **18** decimals ([#476](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/work_items/476) / I12). Self-register filter: `!tier.governance_only` (tiers **1–9** only; 0 / 255 stay off this list — I3). --- ## Why this is needed `/tiers` is the only place a retail wallet **registers** for a fee discount (I12: holding alone does not apply). On the devices people actually use (iPhone Safari/WebKit — Brave on iOS is WebKit, not Chromium), the ladder is unreadable: “Hold 1 CL8Y” looks like three labels, and disconnected users see a dead 112px column on every card. That wastes vertical space (nine cards + How it works) and makes the Register CTA harder to find once connected. This is not “users should rotate to landscape.” Phone-width must show **Tier N**, **Hold {n} CL8Y**, **{pct} fee discount**, and **{pct} eff. fee*** as intact phrases. --- ## Constraints / guardrails 1. **Display-only.** Do not change `Register` / `Deregister` execute msgs, gas limits (#384), or on-chain `GetTiers` / `GetRegistration` queries. 2. **Do not invent discounts.** Card % and eff. fee stay `discount_bps` + factory `default_fee_bps` (I4). Do not apply a wallet `get_discount` to the published ladder. Pair-scoped chrome stays on Swap/Pool/Trade (#537 / I14) — `/tiers` is the registry ladder, not a pair quote. 3. **I13.** How it works **Limit place*** stays `limit_discount_bps` (tier 9 place = 0). Do not raise swap `discount_bps` on tier 9. Crossing the book still uses the taker swap discount. 4. **I12 / #476.** Keep **Hold {human} CL8Y** (18-dec `formatTokenAmountAbbrev`). Do not relabel as `uluna` / wei / `min_cl8y_balance`. Do not imply holding alone registers the wallet. 5. **I3.** Only `governance_only: false` rows in the self-register list. No Register on tier 0 / 255. 6. **Copy (#489).** Labels ≤ ~5 words. Prefer keeping **Hold {n} CL8Y** as **one phrase** (reporter option 2). A two-line identity (`Tier 1` then `Hold 1 CL8Y`) is OK. Do not add always-on essays (“tap Register after connect”). 7. **Design system (#488).** `btn-primary` / `btn-muted`, `shell-panel-strong`, `var(--ink)` / `--mint` (alias blue). No `*-neo`. One chrome layer per card. Light + dark. 8. **Register hit target.** When the button is shown, min height ≥ 44px on coarse/narrow. Do not cover it with the identity block. Empty **Register** slot must **not** reserve `w-28` when the button is absent. 9. **Do not hide Register behind connect-only layout that then overflows.** Connected + 9 Register buttons must still fit without clipping the hold amount or making the wrong row’s button the tap target. 10. **iOS WebKit.** Fix against **≤390px** CSS width (iPhone + Brave/Safari). Do not ship a Chromium-only flex quirk. Viewport meta stays default (`resizes-visual`); this is not a `visualViewport` picker issue. 11. **No new indexer / contract surface.** No public per-trader probe. No `dangerouslySetInnerHTML` on LCD tier fields. 12. **Playwright workers stay 5** for `e2e-smoke`. Do not add `e2e-tx` for this layout fix. Register/deregister txs stay in existing `e2e/fee-tier-tx.spec.ts` / `make verify-issue-384` if touched at all (they should not be). 13. **Canonical table.** Do not duplicate the numeric ladder in new docs; link [`docs/reference/fee-discount-tiers.md`](docs/reference/fee-discount-tiers.md). --- ## Relevant files **Primary** - `frontend-dapp/src/pages/TiersPage.tsx` — `TierRow` flex + How it works `grid-cols-5` - `frontend-dapp/src/App.tsx` — `/tiers` route - `frontend-dapp/src/index.css` / `theme-dark.css` / `theme-light.css` — only if a shared card utility is added **Copy / math (read, do not fork)** - `frontend-dapp/src/utils/formatAmount.ts` — `formatTokenAmountAbbrev` - `frontend-dapp/src/utils/limitOrderFeeSummary.ts` — `effectiveSwapFeeBps`, `makerPlacementFeeBps`, `resolveLimitDiscountBps` - `frontend-dapp/src/utils/tokenRegistry.ts` — CL8Y 18 decimals - `frontend-dapp/src/services/terraclassic/feeDiscount.ts` — `getTiers` / `getRegistration` / `register` / `deregister` - `docs/reference/fee-discount-tiers.md` — canonical 1–9 minima / bps - `skills/AGENTS_FEE_DISCOUNT_TIERS.md` - `docs/frontend.md` § Tiers Page (thin today — extend with phone-width invariants) - `skills/AGENTS_FRONTEND_DESIGN_SYSTEM.md` / `skills/AGENTS_FRONTEND_COPY_COGNITIVE_LOAD.md` - `QA_TEMPLATE.md` — **4.1.*** and **11.1.4** **Tests to add / extend** - `frontend-dapp/src/pages/TiersPage.test.tsx` — **new** (page currently has no unit coverage) - `frontend-dapp/e2e/fee-tiers.spec.ts` — add a **390×844** (or 375×667) project/test; assert no wrap of Hold / fee phrases - `frontend-dapp/e2e/fee-tier-tx.spec.ts` — must stay green; do not require it for the layout MR --- ## Recommended direction Ship **one** responsive `TierRow` + a mobile How it works treatment. Do not keep five always-on columns on ≤767px. ### A — Stop reserving the empty Register column Render the `w-28` (or equivalent) **only** when a Register button or “Governance only” label is actually shown. Disconnected and current-tier rows should not leave a 112px hole. That alone returns enough width for **Hold 1 CL8Y** on many phones. ### B — Phone-width identity + fee cluster (preferred) At `max-width: 767px` (same breakpoint as `.app-mobile-nav-shell`): 1. **Identity** (badge + `Tier N` + `Hold {n} CL8Y`) takes the remaining row (or a full first row). Keep **Hold {n} CL8Y** as one unbreakable phrase (`whitespace-nowrap` on that span, or a single line under `Tier N`). 2. **Fee cluster** is `shrink-0 text-right`: `{pct}` + `fee discount` on the left of the cluster, mint `{pct}` + `eff. fee*` on the right — or stacked as two compact right-aligned stats. Do not let **FEE** / **DISCOUNT** wrap to one word per line. 3. **Register** (when present) sits on a **second row** full-width, or at the end of the fee cluster if it still fits without wrapping identity. Prefer a second row on ≤390px so the CTA stays ≥44px. Desktop (≥768) may keep a single row, but still must not reserve an empty `w-28`. ### C — How it works on phone Replace `grid-cols-5` on ≤767 with either: - a **horizontal scroll** table (`overflow-x-auto`, `min-w` on the grid, no clipped headers), or - **per-tier stacked rows** (Tier N / Hold / Discount / Eff. fee / Limit place as labeled pairs). Do not ship five squeezed columns that wrap every cell. ### D — Out of scope - Changing tier minima, bps, or factory default fee. - Showing governance tiers 0/255 as self-register. - Pair-specific eff. fee on this page (footnote already says some pairs differ). - Keplr in-app chrome / `visualViewport` (#632). --- ## Acceptance criteria - [ ] **Disconnected, 390×844 (and 375×667):** each self-register card shows **Tier N** on one line (or `Tier` + `N` without stacking `TIER` above `1` as the only content in a 2-em column). **Hold {n} CL8Y** is one phrase on one line (e.g. `Hold 1 CL8Y`, `Hold 7,500 CL8Y`). - [ ] **Disconnected:** no empty ~112px Register column; no large dead gap between identity and the fee cluster. - [ ] **Fee cluster:** `{discount}%` + `fee discount` and `{eff}%` + `eff. fee*` remain intact phrases (no `FEE` / `DISCOUNT` / `EFF.` / `FEE*` stacked as single words). - [ ] **Connected, unregistered:** Register is visible for tiers 1–9, ≥44px tall on coarse/narrow, one button per row, does not overlay the wrong card or clip the hold amount. - [ ] **Connected, registered:** Active badge + Deregister on **Your Status**; current row has no Register; other rows still Register. - [ ] **How it works** on phone: all five columns (or stacked labels) readable without one-word-per-line wrap; Limit place* still shown for I13. - [ ] Desktop ≥768: cards still one row (or an intentional denser layout) with the same numbers; `e2e/fee-tiers.spec.ts` heading / 9 tiers / connect prompt stay green. - [ ] Light + dark; iOS WebKit (Safari or Brave) and Android Chrome. No `*-neo`. No new fee-trivia banner. - [ ] Docs: `docs/frontend.md` § Tiers Page notes phone-width card + How it works rules. QA **11.1.4** tightened to the phrases above. --- ## Test plan (all paths) ### Unit (new `TiersPage.test.tsx`) Mock `getTiers` / `getRegistration` / `getFactoryConfig` / wallet store (same pattern as other pages). 1. **Disconnected:** connect banner visible; **Your Status** absent; no Register buttons; no empty `w-28` (assert Register column absent or `width` 0 / not in layout). `Hold 1 CL8Y` (tier 1) and `Hold 5 CL8Y` (tier 2) present as full strings. 2. **Connected, unregistered:** **Your Status** “Not registered”; Register count ≥ 1 (tiers 1–9); Deregister absent. 3. **Connected, registered tier 2:** Active on tier 2; Register absent on that row; Deregister present; other self-register rows still Register. 4. **Governance-only entries** in the API fixture do not render Register and do not appear in the self-register list. 5. **Fee labels:** with `default_fee_bps` 180, tier 1 shows `2.5%` and `1.75%`; tier 9 shows `95%` and the matching I4 eff. fee. How it works Limit place* for tier 9 is `0` / `0.00%` per I13 helpers — do not hardcode a second ladder. 6. **Missing `VITE_FEE_DISCOUNT_ADDRESS`:** “Fee discount contract not configured.” 7. **Tiers query error:** `RetryError` + retry calls refetch. 8. **Narrow layout (jsdom):** set container / `matchMedia('(max-width: 767px)')` if the implementation keys off it; assert identity + hold text nodes are single-line phrases (no split across multiple block children). If CSS wrap cannot be asserted in jsdom, use a `data-testid` on the hold span and assert `whitespace-nowrap` / class contract. ### Vitest (must stay green) ```text cd frontend-dapp && npm test -- --run \ src/pages/TiersPage.test.tsx \ src/utils/__tests__/formatAmount.test.ts \ src/utils/__tests__/limitOrderFeeSummary.test.ts \ src/utils/__tests__/feeDiscountUiCopy.test.ts ``` (`limitOrderFeeSummary` test path may differ — run the existing fee-summary suite the page imports.) ### Playwright (`e2e-smoke`, 5 workers) 1. Keep existing `e2e/fee-tiers.spec.ts` at 1280×720 (heading, 9× `Tier N`, discounts, connect prompt, How it works). 2. **New phone test** in that file (or sibling): `page.setViewportSize({ width: 390, height: 844 })` (and 375×667). - For each of tiers 1, 2, 9: `getByText` exact / regex **`Hold 1 CL8Y`**, **`Hold 5 CL8Y`**, **`Hold 7,500 CL8Y`** (or the live `formatTokenAmountAbbrev` string) is visible as one element — not three separate nodes that only match `/Hold/` + `/1/` + `/CL8Y/`. - Bounding boxes: hold text `height` is a single line (≤ ~28px at `text-sm`); discount label **fee discount** height is a single line. - Disconnected: no Register button; card `getBoundingClientRect()` — no empty 100px+ trailing hole (e.g. last text cluster’s `right` is within ~24px of the card’s content box). - How it works: `CL8Y Hold` / `Eff. Fee*` / `Limit place*` visible without requiring horizontal overflow that clips the first column; if scroll is used, `scrollWidth` works and headers stay one line. 3. **Connected** (existing `connectWallet`): viewport 390×844 — Register buttons ≥ 1; each button `height` ≥ 44; tap target does not overlap the previous card’s hold text (`getBoundingClientRect` gap ≥ 0). 4. Do **not** add `e2e-tx` here. ### Manual (required — iOS Brave/Safari not in Playwright) 1. **iPhone + Brave** (WebKit), disconnected, `https://dex.cl8y.com/tiers` (or LocalTerra `/tiers`): screenshot cards 1–4 and 9. Confirm `Tier N` + `Hold {n} CL8Y` + fee cluster. No empty Register column. 2. Same device, **Safari** (confirm not Brave-only). 3. **Connect** (Keplr / Simulated Wallet on LocalTerra): Register appears; tap target is the intended row; **Your Status** does not add a second empty gap inside cards. 4. **Register** tier 1 (LocalTerra, #384 gas): Active badge; Deregister; layout still intact. 5. **Android Chrome** 360×800-class: same disconnected + connected checks. 6. Desktop 1280 and tablet 768: no regression; How it works five columns readable. 7. Light + dark. 8. Factory `default_fee_bps` ≠ 180 (if a LocalTerra pair config differs): card footnote still explains pair override; published eff. fee still uses factory default (today’s behavior). --- ## Test plan (attack, hack, and abuse) Layout sits on the **register** path. Treat Register as a trust-boundary control even though msgs are unchanged. 1. **Wrong-row Register.** Narrow reflow must not stack buttons so a tap on “Tier 3 Register” submits `tier_id` 2 or 4. RTL/unit: each button’s `onRegister(tier_id)` matches that row’s `entry.tier_id`. Playwright: click Register on a mid list row (tier 5) and assert the mutation / pending state is scoped (or `data-testid={`register-tier-${id}`}`). 2. **Hidden governance Register.** A malicious / extra LCD `governance_only: false` spoof is out of band; still: fixtures with tier 0 / 255 must not show Register. Do not trust `tier_id` text alone — filter on `governance_only`. 3. **XSS via LCD fields.** `tier` strings / future names render as **text**. No `dangerouslySetInnerHTML`. `CL8Y_TOKEN_ADDRESS` in How it works stays `font-mono` text. 4. **Hold-amount spoof / confusion.** Do not parse display text back into a register payload. Register sends only `tier_id`. Truncation must not turn `Hold 7,500 CL8Y` into `Hold 7` or `Hold 7500` without thousands separator consistency (`formatTokenAmountAbbrev`). Overflow ellipsis on the hold phrase is **forbidden** if it hides the magnitude (prefer wrap the whole card, not `7,500` → `7…`). 5. **Fake discount chrome.** CSS reorder must not place tier 9’s `95%` next to tier 1’s hold line. Keep discount + hold in the same card DOM subtree (no grid that interleaves rows). Snapshot / testids per row. 6. **Clickjacking / overlay.** New full-width Register row must sit below the card chrome, `z-index` below Connect / Legal / WalletConnect (`z-[9999]`). Must not be a transparent hit layer over the header CONNECT button. 7. **Disabled / pending double-submit.** Keep `disabled={isRegistering}` on all Register buttons while a tx is in flight (today’s behavior). Reflow must not remount a fresh enabled button mid-tx. 8. **Disconnected “ghost” CTA.** Must not show a disabled Register that looks tappable and then no-ops; connect prompt stays the banner, not a fake button. 9. **Fee footnote abuse.** `*` / “some pairs may differ” must remain; do not present the mint eff. fee as a guaranteed pair quote (I14 / #537). 10. **Viewport / font spoof.** Extreme `devicePixelRatio` / 200% text: card must grow vertically, not overlap the next Register. No `overflow: hidden` that clips the hold amount or the CTA. --- ## Verification criteria Done when: 1. Manual iPhone Brave **and** Safari, disconnected `/tiers`: all nine self-register cards show intact **Hold {n} CL8Y** and fee phrases; no empty Register column. Connected: Register ≥44px, correct `tier_id`. 2. `TiersPage.test.tsx` covers disconnected / unregistered / registered / governance filter / fee labels. 3. Playwright phone viewport (390 and 375) asserts Hold phrases + no empty hole + Register geometry when connected. 4. `e2e/fee-tiers.spec.ts` desktop + `make test-frontend` (or scoped Vitest above) green. No `e2e-tx` required. 5. `docs/frontend.md` § Tiers Page + QA **11.1.4** updated. No duplicated numeric ladder in new docs. 6. `make lint-frontend` clean. No `*-neo`. Light + dark checked. ```bash # after implementation bash scripts/with-node.sh --cwd frontend-dapp -- npm test -- --run src/pages/TiersPage.test.tsx # phone + desktop smoke (5 workers): # frontend-dapp/e2e/fee-tiers.spec.ts make lint-frontend ```
PlasticDigits commented 2026-08-26 01:49:29 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1153

mentioned in merge request !1153
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-26 01:54:06 +00:00
PlasticDigits commented 2026-08-26 01:54:08 +00:00 (Migrated from gitlab.com)

mentioned in commit 4295334f2f

mentioned in commit 4295334f2f7b3f8d80aa2f8acbb06ab560960ac2
PlasticDigits commented 2026-08-26 01:58:31 +00:00 (Migrated from gitlab.com)

mentioned in issue #654

mentioned in issue #654
PlasticDigits commented 2026-08-26 01:58:32 +00:00 (Migrated from gitlab.com)

marked as related to #654

marked as related to #654
PlasticDigits commented 2026-08-26 01:58:41 +00:00 (Migrated from gitlab.com)

Merge !1153 is on main (4295334f). Local make verify-issue-651 was 9/9 including Playwright e2e/fee-tiers.spec.ts 14/14 (5 workers): desktop + 390×844 + 375×667 + connected Register geometry.

Problems / leftovers (tracked on #654 — do not reopen this issue unless a merged invariant is wrong):

  • GitLab CI on !1153 failed ci_quota_exceeded (not a code defect). Did not wait for CI.
  • Required manual iPhone Brave and Safari on production /tiers was not run.
  • Manual Android Chrome ~360×800 and real-phone light + dark were not run.
  • LocalTerra Register tier 1 after connect was not re-run this pass (layout-only; msgs unchanged).
Merge !1153 is on `main` (`4295334f`). Local `make verify-issue-651` was 9/9 including Playwright `e2e/fee-tiers.spec.ts` 14/14 (5 workers): desktop + 390×844 + 375×667 + connected Register geometry. Problems / leftovers (tracked on #654 — do not reopen this issue unless a merged invariant is wrong): - GitLab CI on !1153 failed `ci_quota_exceeded` (not a code defect). Did not wait for CI. - Required manual iPhone Brave **and** Safari on production `/tiers` was not run. - Manual Android Chrome ~360×800 and real-phone light + dark were not run. - LocalTerra Register tier 1 after connect was not re-run this pass (layout-only; msgs unchanged).
PlasticDigits commented 2026-08-26 04:19:24 +00:00 (Migrated from gitlab.com)

mentioned in issue #669

mentioned in issue #669
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#651
No description provided.