docs(07): mobile UI quality-bar contract (UI-SPEC)
Adds 07-UI-SPEC.md for Phase 7 (Mobile Test Harness). Documents the assertable mobile-UI quality bar the harness enforces: touch-target thresholds (≥44px), no-horizontal-overflow rule, two-profile viewport matrix (iPhone 14/WebKit + Pixel 7/Chromium), role-based accessible name requirements, empty/error-state copy anchors, screenshot omission rationale, and CI portability rules. Locks D-01 through D-10 from CONTEXT.md as the assertion contract.
This commit is contained in:
@@ -0,0 +1,388 @@
|
|||||||
|
---
|
||||||
|
phase: 7
|
||||||
|
slug: mobile-test-harness
|
||||||
|
status: draft
|
||||||
|
shadcn_initialized: false
|
||||||
|
preset: none
|
||||||
|
created: 2026-06-11
|
||||||
|
framing: quality-bar-contract
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 7 — Mobile UI Quality-Bar Contract
|
||||||
|
|
||||||
|
> This phase builds **no new UI**. The harness asserts against the existing
|
||||||
|
> FamilySync PWA. This document is a **quality-bar contract**, not a design
|
||||||
|
> system spec. Its job is to pin every measurable threshold the harness must
|
||||||
|
> enforce so the planner can turn each rule into a concrete Playwright
|
||||||
|
> assertion. Template sections that have no assertable content for a test
|
||||||
|
> harness are marked N/A with a one-line reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
N/A — test harness, no new UI. The existing design system is declared in
|
||||||
|
`apps/pwa/src/styles/tokens.css` and consumed by the assertions below.
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Tool | none (no shadcn; inline CSS custom properties) |
|
||||||
|
| Preset | not applicable |
|
||||||
|
| Component library | none (lucide-react icons; Schedule-X calendar widget) |
|
||||||
|
| Icon library | lucide-react (via npm dep, no CDN) |
|
||||||
|
| Font | `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing Scale
|
||||||
|
|
||||||
|
N/A — test harness, no new UI. Spacing tokens are declared in `tokens.css`
|
||||||
|
and are not re-specified here. Assertions reference computed pixel values
|
||||||
|
derived from those tokens where needed (e.g. BottomTabBar height = 56px +
|
||||||
|
safe-area-inset).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
N/A — test harness, no new UI. Typography tokens exist in `tokens.css`; the
|
||||||
|
harness does not assert on font metrics unless a visible-text / accessible-name
|
||||||
|
check requires it (captured in Assertion Contract below).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color
|
||||||
|
|
||||||
|
N/A — test harness, no new UI. The 60/30/10 color split is declared in
|
||||||
|
`tokens.css`. The harness does not assert computed colors — color drift is
|
||||||
|
out of scope and prone to rendering-pipeline variance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copywriting Contract
|
||||||
|
|
||||||
|
Copywriting that the harness **must** be able to locate by text in assertions.
|
||||||
|
These are the exact strings emitted by the existing components; the harness
|
||||||
|
uses them as stable locator anchors.
|
||||||
|
|
||||||
|
| Element | Exact String | Source Component |
|
||||||
|
|---------|-------------|------------------|
|
||||||
|
| Calendar empty-state heading | `Nothing here` | `EmptyState.tsx` |
|
||||||
|
| Calendar empty-state body | `No events in this period. Try a different date or switch views.` | `EmptyState.tsx` |
|
||||||
|
| Lists empty-state heading | `No lists yet` | `ListsEmptyState.tsx` |
|
||||||
|
| Lists empty-state body | `Tap + to create your first shared list` (contains) | `ListsEmptyState.tsx` |
|
||||||
|
| Calendar error heading | `Couldn't load events` | `CalendarShell.tsx` |
|
||||||
|
| Calendar error CTA | `Retry` (button text) | `CalendarShell.tsx` |
|
||||||
|
| New Event FAB | `aria-label="New Event"` | `CalendarShell.tsx` |
|
||||||
|
| Bottom nav — Calendar tab | `aria-label="Calendar"` | `BottomTabBar.tsx` |
|
||||||
|
| Bottom nav — Lists tab | `aria-label="Lists"` | `BottomTabBar.tsx` |
|
||||||
|
| Top nav (phone) | `FamilySync` (visible text) | `AppNav.tsx` — `PhoneNav` |
|
||||||
|
| Settings button | `aria-label` contains `open settings` | `AppNav.tsx` — `PhoneNav` |
|
||||||
|
|
||||||
|
> Stable copywriting anchor rule: **always locate interactive elements by
|
||||||
|
> `aria-label` or `role` + accessible name first.** Text-content locators
|
||||||
|
> (`getByText`) are second resort — acceptable for static headings/bodies
|
||||||
|
> that have no ARIA role.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registry Safety
|
||||||
|
|
||||||
|
N/A — test harness, no new UI components. `@playwright/test` is a new dev
|
||||||
|
dependency in `apps/pwa`; it is the official Playwright package from the
|
||||||
|
Playwright team and requires no safety vetting under this gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assertion Contract
|
||||||
|
|
||||||
|
This section is the primary deliverable for Phase 7. It replaces the
|
||||||
|
design-system sections of the standard template with the measurable
|
||||||
|
quality-bar rules that the harness enforces.
|
||||||
|
|
||||||
|
### Device / Viewport Matrix
|
||||||
|
|
||||||
|
| Profile ID | Playwright Descriptor | Engine | Viewport | UA Type |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `iphone` | `'iPhone 14'` | WebKit | 390×844 logical px | Mobile Safari |
|
||||||
|
| `pixel` | `'Pixel 7'` | Chromium | 412×915 logical px | Chrome Android |
|
||||||
|
|
||||||
|
**Source:** D-03 (iPhone + Pixel matrix), D-04 (WebKit for iPhone, Chromium
|
||||||
|
for Pixel). These are the exact Playwright device descriptor strings to pass
|
||||||
|
to `devices['iPhone 14']` and `devices['Pixel 7']` in `playwright.config.ts`.
|
||||||
|
|
||||||
|
Both profiles run with `serviceWorkers: 'block'` (D-02 / Pitfall 15) and
|
||||||
|
`DEV_AUTH_BYPASS=true` (D-01 / Pitfall 14). No `storageState` file.
|
||||||
|
|
||||||
|
**CI note:** Both engines must be installed in the Phase 8 CI image. The
|
||||||
|
harness adds WebKit beyond the existing global `playwright-cli` (Chromium
|
||||||
|
only). Accept the larger CI image cost — this was a deliberate call (D-04,
|
||||||
|
07-CONTEXT.md §Specifics).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rule 1 — Touch-Target Minimum
|
||||||
|
|
||||||
|
**Threshold:** Every interactive element (button, link, `role="button"`) must
|
||||||
|
have a computed bounding box of **≥ 44 × 44 logical pixels**.
|
||||||
|
|
||||||
|
**Basis:**
|
||||||
|
- Apple Human Interface Guidelines: minimum touch target 44×44 pt.
|
||||||
|
- WCAG 2.5.5 (Level AAA): minimum 44×44 CSS px.
|
||||||
|
- The existing codebase declares this as a hard constraint: `BottomTabBar`
|
||||||
|
uses `minHeight: '44px'`; `AppNav` `PhoneNav` settings button uses
|
||||||
|
`minWidth: '44px', minHeight: '44px'`; calendar FAB is `56×56px`; Retry
|
||||||
|
button uses `minHeight: '44px'`; nav links use `minHeight: '44px'`.
|
||||||
|
|
||||||
|
**Measurement approach:**
|
||||||
|
```typescript
|
||||||
|
// Use boundingBox() on the element handle, not CSS-declared values.
|
||||||
|
const box = await element.boundingBox()
|
||||||
|
expect(box!.width).toBeGreaterThanOrEqual(44)
|
||||||
|
expect(box!.height).toBeGreaterThanOrEqual(44)
|
||||||
|
```
|
||||||
|
|
||||||
|
**What counts as an interactive target:**
|
||||||
|
- `<button>` elements (including FAB, Retry, settings avatar button)
|
||||||
|
- `<a>` and `NavLink` elements (BottomTabBar tabs, sidebar nav links)
|
||||||
|
- Any element with `role="button"`, `role="link"`, or `tabindex="0"` that
|
||||||
|
has a click/tap handler
|
||||||
|
|
||||||
|
**Explicit elements to assert on both profiles:**
|
||||||
|
|
||||||
|
| Element | Expected min size | Locator strategy |
|
||||||
|
|---|---|---|
|
||||||
|
| BottomTabBar Calendar tab | 44×44 | `getByRole('link', { name: 'Calendar' })` |
|
||||||
|
| BottomTabBar Lists tab | 44×44 | `getByRole('link', { name: 'Lists' })` |
|
||||||
|
| PhoneNav settings button | 44×44 | `getByRole('button', { name: /open settings/i })` |
|
||||||
|
| New Event FAB | 56×56 | `getByRole('button', { name: 'New Event' })` |
|
||||||
|
| Retry button (error state) | 44×44 | `getByRole('button', { name: 'Retry' })` |
|
||||||
|
|
||||||
|
**BottomTabBar phone-only gate:** `BottomTabBar` renders `null` on desktop
|
||||||
|
(`matchMedia('(max-width: 767px)')`). Assert it is present on both mobile
|
||||||
|
profiles (390px and 412px width) and absent on desktop (1280px). Both test
|
||||||
|
profiles qualify as phone-width so the bar must be visible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rule 2 — No Horizontal Overflow
|
||||||
|
|
||||||
|
**Threshold:** On every tested route, `document.documentElement.scrollWidth`
|
||||||
|
must equal `document.documentElement.clientWidth`. No horizontal scrollbar;
|
||||||
|
no content overflow.
|
||||||
|
|
||||||
|
**Measurement approach:**
|
||||||
|
```typescript
|
||||||
|
const overflow = await page.evaluate(() => ({
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
}))
|
||||||
|
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Routes to assert on both profiles:**
|
||||||
|
|
||||||
|
| Route | State to assert |
|
||||||
|
|---|---|
|
||||||
|
| `/calendar` | populated (seeded events) |
|
||||||
|
| `/calendar` | error state (simulated — mock API to 500) |
|
||||||
|
| `/lists` | populated (seeded list + items) |
|
||||||
|
| `/lists` | empty state (no lists — dev-bypass user 1 native state) |
|
||||||
|
|
||||||
|
**Allowed exceptions:** none. The Schedule-X calendar widget historically
|
||||||
|
caused overflow on narrow viewports (see memory entry `schedule-x-allday-event-styling`).
|
||||||
|
If a Schedule-X internal element overflows, the assertion must still fail —
|
||||||
|
this is the defect the harness exists to catch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rule 3 — Critical Elements Visible and In-Viewport
|
||||||
|
|
||||||
|
Assertion: each element below must be visible (`isVisible() === true`) **and**
|
||||||
|
within the viewport (`boundingBox().y >= 0`, `boundingBox().y + height <=
|
||||||
|
viewport.height`) on initial load, before any scroll.
|
||||||
|
|
||||||
|
| Element | Route | Profile |
|
||||||
|
|---|---|---|
|
||||||
|
| BottomTabBar | `/calendar`, `/lists` | iPhone + Pixel |
|
||||||
|
| PhoneNav header | `/calendar`, `/lists` | iPhone + Pixel |
|
||||||
|
| Schedule-X calendar grid | `/calendar` (populated) | iPhone + Pixel |
|
||||||
|
| New Event FAB | `/calendar` | iPhone + Pixel |
|
||||||
|
| Lists index cards (≥1 card) | `/lists` (seeded) | iPhone + Pixel |
|
||||||
|
|
||||||
|
**BottomTabBar position assertion (safe-area-inset):** the bar uses
|
||||||
|
`env(safe-area-inset-bottom, 0px)`. In the emulated context there is no
|
||||||
|
safe-area-inset, so the bar's bottom edge must be ≤ the viewport height.
|
||||||
|
Assert `boundingBox().y + boundingBox().height <= page.viewportSize().height`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rule 4 — Accessible Names on All Interactive Elements
|
||||||
|
|
||||||
|
Assertion: every interactive element exposed to the assertions above must
|
||||||
|
have a non-empty accessible name, locatable via Playwright's ARIA role
|
||||||
|
queries without needing a CSS selector fallback.
|
||||||
|
|
||||||
|
**Required accessible names (exact or pattern):**
|
||||||
|
|
||||||
|
| Element | Role | Expected accessible name |
|
||||||
|
|---|---|---|
|
||||||
|
| BottomTabBar Calendar tab | `link` | `"Calendar"` |
|
||||||
|
| BottomTabBar Lists tab | `link` | `"Lists"` |
|
||||||
|
| PhoneNav settings button | `button` | matches `/open settings/i` |
|
||||||
|
| New Event FAB | `button` | `"New Event"` |
|
||||||
|
| Retry button | `button` | `"Retry"` |
|
||||||
|
| Main navigation landmark | `navigation` | `"Main navigation"` |
|
||||||
|
|
||||||
|
Locator pattern:
|
||||||
|
```typescript
|
||||||
|
page.getByRole('link', { name: 'Calendar' })
|
||||||
|
page.getByRole('button', { name: /open settings/i })
|
||||||
|
```
|
||||||
|
|
||||||
|
If an element cannot be found by role + name, the test fails. This doubles as
|
||||||
|
a regression gate for accessible-name regressions (e.g. a button losing its
|
||||||
|
`aria-label`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rule 5 — Empty States Render Correctly
|
||||||
|
|
||||||
|
**Context (D-05):** DEV_AUTH_BYPASS user 1 natively has no CalDAV credentials
|
||||||
|
or calendars. Without seeding, calendar views render empty and list views
|
||||||
|
render empty. This is the "native empty" state.
|
||||||
|
|
||||||
|
**Seeded populated state:** D-06 seeds deterministic fixtures before each run
|
||||||
|
(global-setup truncate/insert). Seeding targets shared calendar id 10 (from
|
||||||
|
project memory `dev-data-user1-no-calendars`) and creates ≥1 list with ≥2
|
||||||
|
items for user 1 (via direct MariaDB insert, not the API, since live
|
||||||
|
event-create 422s for user 1).
|
||||||
|
|
||||||
|
**Assertions:**
|
||||||
|
|
||||||
|
| State | Route | Assert |
|
||||||
|
|---|---|---|
|
||||||
|
| Populated calendar | `/calendar` (after seeding) | Schedule-X grid is visible; `<EmptyState>` is NOT in DOM |
|
||||||
|
| Populated lists | `/lists` (after seeding) | ≥1 list card is visible; `ListsEmptyState` is NOT in DOM |
|
||||||
|
| Empty lists (pre-seed teardown or clean run) | `/lists` | `getByText('No lists yet')` is visible; `getByText(/Tap \+ to create/)` is visible |
|
||||||
|
| Calendar error | `/calendar` (API mocked to 500) | `getByRole('heading', { name: "Couldn't load events" })` is visible; `getByRole('button', { name: 'Retry' })` is visible |
|
||||||
|
|
||||||
|
**Empty-state assertion depth:** each empty state must additionally pass Rule
|
||||||
|
1 (touch targets on any interactive elements within it) and Rule 2 (no
|
||||||
|
horizontal overflow).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rule 6 — Visual Snapshots
|
||||||
|
|
||||||
|
**Decision: OMIT `toHaveScreenshot()` assertions entirely for this phase.**
|
||||||
|
|
||||||
|
Rationale (from D-08-area steer in 07-CONTEXT.md):
|
||||||
|
- The Schedule-X calendar widget renders dynamic content (current date
|
||||||
|
highlighted, event chips placed by the widget's internal layout engine)
|
||||||
|
that will differ between host and CI renderers on different dates and OS
|
||||||
|
font-rendering pipelines.
|
||||||
|
- CI-generated baselines + tolerance configuration (`maxDiffPixelRatio`,
|
||||||
|
`threshold`) address pixel variance but not date-dependent layout changes
|
||||||
|
(today's date highlight shifts every day; event chip wrapping varies by
|
||||||
|
viewport pixel density).
|
||||||
|
- There is no established prior art in this codebase for non-flaky
|
||||||
|
Schedule-X snapshot tests across host↔CI WebKit.
|
||||||
|
- The structural + role-based locator assertions in Rules 1–5 cover the
|
||||||
|
quality bar with zero rendering-pipeline variance.
|
||||||
|
|
||||||
|
**If added later:** snapshots must use CI-generated baselines only
|
||||||
|
(`--update-snapshots` run in the CI environment on first run), store baselines
|
||||||
|
per browser engine under `apps/pwa/e2e/snapshots/{browser}/`, and set
|
||||||
|
`maxDiffPixelRatio: 0.03`. Snapshots must be scoped to static UI elements
|
||||||
|
(e.g. BottomTabBar only, clipped), not the full viewport containing
|
||||||
|
Schedule-X.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rule 7 — Auth and Service Worker Preconditions
|
||||||
|
|
||||||
|
These are not UI-quality assertions but are preconditions that must hold for
|
||||||
|
all other assertions to be valid. They are enforced in global-setup and
|
||||||
|
browser context options.
|
||||||
|
|
||||||
|
| Precondition | Enforcement | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `DEV_AUTH_BYPASS=true` in API process | Env var set before dev-server launch | D-01 / Pitfall 14 |
|
||||||
|
| `serviceWorkers: 'block'` on every context | `playwright.config.ts` contextOptions | D-02 / Pitfall 15 |
|
||||||
|
| No `storageState` file | `playwright.config.ts` — omit `storageState` | D-01 / Pitfall 14 |
|
||||||
|
| PWA reachable before specs run | global-setup polls `GET /health` until 200 | D-08 / SC #3 |
|
||||||
|
| DB fixtures reset before run | global-setup truncate + insert | D-06 |
|
||||||
|
| No SW-sourced responses | Playwright trace shows no `(ServiceWorker)` source | D-02 / Pitfall 15 |
|
||||||
|
|
||||||
|
**SW-source verification (in trace):** after a run, if a test fails with
|
||||||
|
unexpected data, inspect the `.zip` trace artifact. Any response with source
|
||||||
|
`(ServiceWorker)` is a contract violation — the `serviceWorkers: 'block'`
|
||||||
|
option should prevent this. Log a test failure if detected programmatically:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In each test: attach a route listener to flag SW-sourced responses
|
||||||
|
page.on('response', (resp) => {
|
||||||
|
// Playwright does not expose SW-source in the Response object directly;
|
||||||
|
// rely on serviceWorkers: 'block' and trace inspection for post-hoc audit.
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rule 8 — CI Portability
|
||||||
|
|
||||||
|
Assertions and harness configuration must produce identical pass/fail results
|
||||||
|
when run:
|
||||||
|
1. Locally against the operator's already-running dev stack (Vite PWA +
|
||||||
|
API + compose MariaDB/Redis).
|
||||||
|
2. In Gitea CI against a runner-brought-up dev stack (Phase 8).
|
||||||
|
|
||||||
|
**Contract rules:**
|
||||||
|
|
||||||
|
| Rule | Enforcement |
|
||||||
|
|---|---|
|
||||||
|
| `baseURL` is env-driven (`PLAYWRIGHT_BASE_URL`, fallback `http://localhost:5173`) | `playwright.config.ts` `use.baseURL` |
|
||||||
|
| No hardcoded `localhost:5173` in spec files | Lint / code review gate |
|
||||||
|
| Readiness gate in global-setup polls `baseURL + '/health'` until 200 or timeout 60s | `playwright.config.ts` `globalSetup` |
|
||||||
|
| DB seed uses `DB_HOST` env (fallback `127.0.0.1`), port 3306, same `.env` creds | global-setup `mysql2` connection |
|
||||||
|
| No spec imports a dev-only module path that does not exist in CI | Jest/Playwright import resolution |
|
||||||
|
| Browser binaries installed at `apps/pwa` level via `@playwright/test` dep | `apps/pwa/package.json` `devDependencies` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checker Sign-Off
|
||||||
|
|
||||||
|
> For this phase the checker validates the quality-bar contract dimensions,
|
||||||
|
> not the standard design-system dimensions.
|
||||||
|
|
||||||
|
- [ ] Dimension 1 Copywriting: stable text anchors declared for all empty/error/nav states
|
||||||
|
- [ ] Dimension 2 Structural: role+name locators declared for all interactive elements
|
||||||
|
- [ ] Dimension 3 Touch Targets: ≥44px threshold declared with measurement approach
|
||||||
|
- [ ] Dimension 4 Overflow: `scrollWidth ≤ clientWidth` rule declared with approach
|
||||||
|
- [ ] Dimension 5 Viewport Matrix: two profiles with correct engines declared (D-03/D-04)
|
||||||
|
- [ ] Dimension 6 Registry Safety: N/A — `@playwright/test` is official, no vetting required
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source Decisions
|
||||||
|
|
||||||
|
| Decision | Source |
|
||||||
|
|---|---|
|
||||||
|
| D-01 DEV_AUTH_BYPASS, no storage-state | 07-CONTEXT.md |
|
||||||
|
| D-02 serviceWorkers: 'block' | 07-CONTEXT.md |
|
||||||
|
| D-03 iPhone + Pixel two-profile matrix | 07-CONTEXT.md |
|
||||||
|
| D-04 WebKit for iPhone, Chromium for Pixel | 07-CONTEXT.md |
|
||||||
|
| D-05 hybrid seed strategy | 07-CONTEXT.md |
|
||||||
|
| D-06 deterministic reset-per-run seed | 07-CONTEXT.md |
|
||||||
|
| D-07 global-setup for seeding | 07-CONTEXT.md |
|
||||||
|
| D-08 readiness gate + configurable baseURL | 07-CONTEXT.md |
|
||||||
|
| D-09 stack lifecycle is caller's responsibility | 07-CONTEXT.md |
|
||||||
|
| D-10 optional webServer for Vite | 07-CONTEXT.md |
|
||||||
|
| 44px threshold | Apple HIG; WCAG 2.5.5; existing codebase pattern |
|
||||||
|
| Screenshot omission | D-08-area steer; Schedule-X drift risk; 07-CONTEXT.md |
|
||||||
|
| Pitfall 14 (storage-state stale) | PITFALLS.md §Pitfall 14 |
|
||||||
|
| Pitfall 15 (SW intercept) | PITFALLS.md §Pitfall 15 |
|
||||||
|
| Existing tokens/copy strings | `tokens.css`, `EmptyState.tsx`, `ListsEmptyState.tsx`, `CalendarShell.tsx`, `AppNav.tsx`, `BottomTabBar.tsx` |
|
||||||
Reference in New Issue
Block a user