Mobile UX Guidelines
Sport Tech Club - Experiência Mobile-First
Visão Geral
Este documento define as diretrizes de UX para a experiência mobile do Sport Tech Club, focando em usabilidade, performance e engajamento.
1. Princípios Mobile-First
1.1 Core Principles
yaml
principios:
thumb_zone:
descricao: Áreas de fácil alcance com o polegar
aplicacao: Ações principais na zona inferior
content_first:
descricao: Conteúdo priorizado sobre navegação
aplicacao: Mínimo de chrome, máximo de conteúdo
progressive_disclosure:
descricao: Informações reveladas progressivamente
aplicacao: Detalhes sob demanda, resumos first
offline_first:
descricao: Funciona sem conexão quando possível
aplicacao: Cache, filas de sync, estados offline
performance:
descricao: Rápido e responsivo
aplicacao: Skeleton loading, optimistic UI, lazy load1.2 Device Targets
yaml
dispositivos:
mobile_portrait:
width: 320px - 428px
prioridade: 1 (primário)
touch: true
mobile_landscape:
width: 568px - 926px
prioridade: 2
touch: true
tablet:
width: 768px - 1024px
prioridade: 3
touch: true
desktop:
width: 1024px+
prioridade: 4
touch: false2. Touch Targets e Gestos
2.1 Touch Target Guidelines
scss
// Tamanhos mínimos de touch target
$touch-target-min: 44px; // Mínimo absoluto (Apple HIG)
$touch-target-comfortable: 48px; // Recomendado
$touch-target-large: 56px; // Para ações principais
// Espaçamento entre touch targets
$touch-spacing-min: 8px;
$touch-spacing-comfortable: 12px;
// Mixin para touch targets
@mixin touch-target($size: 'comfortable') {
@if $size == 'min' {
min-height: $touch-target-min;
min-width: $touch-target-min;
} @else if $size == 'comfortable' {
min-height: $touch-target-comfortable;
min-width: $touch-target-comfortable;
} @else if $size == 'large' {
min-height: $touch-target-large;
min-width: $touch-target-large;
}
// Área de toque expandida
position: relative;
&::before {
content: '';
position: absolute;
top: -8px;
right: -8px;
bottom: -8px;
left: -8px;
}
}2.2 Gestos Suportados
yaml
gestos:
tap:
uso: Seleção, ativação
feedback: Ripple effect, highlight
long_press:
uso: Ações contextuais, seleção múltipla
duracao: 500ms
feedback: Vibração háptica + menu
swipe_horizontal:
uso: Navegação entre tabs, ações em lista
threshold: 50px
velocidade: 0.3
swipe_vertical:
uso: Scroll, pull-to-refresh
threshold: 40px para refresh
pinch:
uso: Zoom em imagens/mapas
min_scale: 1
max_scale: 3
drag:
uso: Reordenação, arrastar para soltar
feedback: Elevação, sombra2.3 Haptic Feedback
typescript
// Tipos de feedback háptico
enum HapticType {
LIGHT = 'light', // Seleção, toggle
MEDIUM = 'medium', // Ação confirmada
HEAVY = 'heavy', // Erro, alerta
SUCCESS = 'success', // Reserva confirmada
WARNING = 'warning', // Atenção necessária
ERROR = 'error', // Falha
}
// Hook para haptic feedback
function useHaptic() {
const trigger = (type: HapticType) => {
if ('vibrate' in navigator) {
const patterns: Record<HapticType, number[]> = {
[HapticType.LIGHT]: [10],
[HapticType.MEDIUM]: [20],
[HapticType.HEAVY]: [30],
[HapticType.SUCCESS]: [10, 50, 10],
[HapticType.WARNING]: [20, 30, 20],
[HapticType.ERROR]: [30, 50, 30, 50, 30],
};
navigator.vibrate(patterns[type]);
}
};
return { trigger };
}3. Thumb Zone Design
3.1 Zonas de Alcance
┌─────────────────────────────────┐
│ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░ │ Difícil alcance
│ ░░░░░░░░░░░░░░░░░░░░░░░░ │ (Ações secundárias)
│ │
├─────────────────────────────────┤
│ │
│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ Alcance médio
│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ (Navegação)
│ │
├─────────────────────────────────┤
│ │
│ ████████████████████████ │ Fácil alcance
│ ████████████████████████ │ (Ações principais)
│ ████████████████████████ │
│ │
└─────────────────────────────────┘
Bottom Navigation3.2 Layout Bottom-First
vue
<template>
<div class="mobile-layout">
<!-- Conteúdo scrollável -->
<main class="content">
<slot></slot>
</main>
<!-- Ação flutuante (FAB) -->
<button
v-if="showFab"
class="fab"
@click="$emit('fab-click')"
>
<PlusIcon />
<span class="fab-label">{{ fabLabel }}</span>
</button>
<!-- Navegação inferior -->
<nav class="bottom-nav" role="navigation">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-item"
:aria-current="isActive(item.path) ? 'page' : undefined"
>
<component :is="item.icon" class="nav-icon" />
<span class="nav-label">{{ item.label }}</span>
</router-link>
</nav>
<!-- Safe area para notch/home indicator -->
<div class="safe-area-bottom"></div>
</div>
</template>
<style lang="scss" scoped>
.mobile-layout {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh; // Dynamic viewport height
}
.content {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: calc(60px + env(safe-area-inset-bottom));
}
.fab {
position: fixed;
right: 16px;
bottom: calc(76px + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 8px;
padding: 16px 24px;
background-color: $color-primary-500;
color: white;
border: none;
border-radius: 28px;
box-shadow: $shadow-lg;
cursor: pointer;
z-index: 100;
&:active {
transform: scale(0.95);
}
}
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-around;
height: 60px;
background-color: var(--color-surface-0);
border-top: 1px solid var(--color-border);
padding-bottom: env(safe-area-inset-bottom);
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 8px;
color: var(--color-text-muted);
text-decoration: none;
transition: color 0.2s;
&.router-link-active {
color: $color-primary-500;
}
}
.nav-icon {
width: 24px;
height: 24px;
}
.nav-label {
font-size: 10px;
margin-top: 4px;
}
.safe-area-bottom {
height: env(safe-area-inset-bottom);
background-color: var(--color-surface-0);
}
</style>4. Padrões de Navegação
4.1 Bottom Navigation
yaml
bottom_navigation:
itens_maximos: 5
itens_recomendados: 4
itens:
- label: Início
icon: home
path: /
badge: null
- label: Reservas
icon: calendar
path: /bookings
badge: pending_count
- label: Buscar
icon: search
path: /search
badge: null
- label: Perfil
icon: user
path: /profile
badge: notifications_count4.2 Pull-to-Refresh
vue
<template>
<div
class="pull-to-refresh"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<div
class="refresh-indicator"
:class="{ 'is-refreshing': isRefreshing }"
:style="{ transform: `translateY(${pullDistance}px)` }"
>
<LoaderIcon v-if="isRefreshing" class="spin" />
<ArrowDownIcon v-else :style="{ transform: `rotate(${rotation}deg)` }" />
<span>{{ statusText }}</span>
</div>
<div
class="content"
:style="{ transform: `translateY(${pullDistance}px)` }"
>
<slot></slot>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const emit = defineEmits(['refresh']);
const THRESHOLD = 80;
const MAX_PULL = 120;
const startY = ref(0);
const pullDistance = ref(0);
const isRefreshing = ref(false);
const rotation = computed(() => {
return Math.min(pullDistance.value / THRESHOLD * 180, 180);
});
const statusText = computed(() => {
if (isRefreshing.value) return 'Atualizando...';
if (pullDistance.value >= THRESHOLD) return 'Solte para atualizar';
return 'Puxe para atualizar';
});
function onTouchStart(e) {
if (window.scrollY === 0) {
startY.value = e.touches[0].clientY;
}
}
function onTouchMove(e) {
if (startY.value === 0 || isRefreshing.value) return;
const currentY = e.touches[0].clientY;
const diff = currentY - startY.value;
if (diff > 0) {
pullDistance.value = Math.min(diff * 0.5, MAX_PULL);
e.preventDefault();
}
}
async function onTouchEnd() {
if (pullDistance.value >= THRESHOLD) {
isRefreshing.value = true;
pullDistance.value = 60;
await emit('refresh');
isRefreshing.value = false;
}
pullDistance.value = 0;
startY.value = 0;
}
</script>4.3 Swipe Actions
vue
<template>
<div
class="swipe-item"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- Ações da esquerda (reveal on swipe right) -->
<div class="swipe-actions left">
<button class="action action-success" @click="$emit('archive')">
<ArchiveIcon />
</button>
</div>
<!-- Conteúdo principal -->
<div
class="swipe-content"
:style="{ transform: `translateX(${offset}px)` }"
>
<slot></slot>
</div>
<!-- Ações da direita (reveal on swipe left) -->
<div class="swipe-actions right">
<button class="action action-warning" @click="$emit('edit')">
<EditIcon />
</button>
<button class="action action-danger" @click="$emit('delete')">
<TrashIcon />
</button>
</div>
</div>
</template>
<style lang="scss" scoped>
.swipe-item {
position: relative;
overflow: hidden;
}
.swipe-content {
position: relative;
background-color: var(--color-surface-0);
transition: transform 0.2s ease;
z-index: 1;
}
.swipe-actions {
position: absolute;
top: 0;
bottom: 0;
display: flex;
&.left {
left: 0;
}
&.right {
right: 0;
}
}
.action {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
border: none;
cursor: pointer;
&-success { background-color: $color-success-500; }
&-warning { background-color: $color-warning-500; }
&-danger { background-color: $color-error-500; }
svg {
width: 24px;
height: 24px;
color: white;
}
}
</style>5. Loading States
5.1 Skeleton Loading
vue
<template>
<div class="booking-card-skeleton">
<div class="skeleton-header">
<div class="skeleton skeleton-badge"></div>
<div class="skeleton skeleton-status"></div>
</div>
<div class="skeleton-body">
<div class="skeleton skeleton-title"></div>
<div class="skeleton skeleton-subtitle"></div>
<div class="skeleton skeleton-info"></div>
</div>
<div class="skeleton-footer">
<div class="skeleton skeleton-price"></div>
<div class="skeleton skeleton-button"></div>
</div>
</div>
</template>
<style lang="scss" scoped>
.skeleton {
background: linear-gradient(
90deg,
var(--color-surface-1) 25%,
var(--color-surface-2) 50%,
var(--color-surface-1) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-badge {
width: 80px;
height: 24px;
border-radius: 12px;
}
.skeleton-status {
width: 60px;
height: 20px;
}
.skeleton-title {
width: 70%;
height: 20px;
margin-bottom: 8px;
}
.skeleton-subtitle {
width: 50%;
height: 16px;
margin-bottom: 8px;
}
.skeleton-info {
width: 40%;
height: 14px;
}
.skeleton-price {
width: 80px;
height: 28px;
}
.skeleton-button {
width: 120px;
height: 44px;
border-radius: 8px;
}
</style>5.2 Optimistic UI
typescript
// Hook para optimistic updates
function useOptimisticUpdate<T>() {
const pending = ref(false);
const error = ref<Error | null>(null);
async function execute(
optimisticUpdate: () => void,
serverAction: () => Promise<T>,
rollback: () => void,
): Promise<T | null> {
pending.value = true;
error.value = null;
// Aplica mudança otimista imediatamente
optimisticUpdate();
try {
// Tenta persistir no servidor
const result = await serverAction();
return result;
} catch (e) {
// Rollback em caso de erro
error.value = e as Error;
rollback();
return null;
} finally {
pending.value = false;
}
}
return { pending, error, execute };
}
// Exemplo de uso
const { execute } = useOptimisticUpdate();
async function toggleFavorite(arenaId: string) {
const previousState = isFavorite.value;
await execute(
// Optimistic update
() => { isFavorite.value = !isFavorite.value; },
// Server action
() => api.toggleFavorite(arenaId),
// Rollback
() => { isFavorite.value = previousState; },
);
}6. Formulários Mobile
6.1 Input Types Otimizados
vue
<template>
<form class="mobile-form" @submit.prevent="onSubmit">
<!-- Email com teclado otimizado -->
<div class="form-group">
<label for="email">E-mail</label>
<input
id="email"
v-model="form.email"
type="email"
inputmode="email"
autocomplete="email"
autocapitalize="off"
autocorrect="off"
placeholder="seu@email.com"
/>
</div>
<!-- Telefone -->
<div class="form-group">
<label for="phone">Telefone</label>
<input
id="phone"
v-model="form.phone"
type="tel"
inputmode="tel"
autocomplete="tel"
placeholder="(11) 99999-9999"
/>
</div>
<!-- CPF (numérico) -->
<div class="form-group">
<label for="cpf">CPF</label>
<input
id="cpf"
v-model="form.cpf"
type="text"
inputmode="numeric"
pattern="[0-9]*"
placeholder="000.000.000-00"
/>
</div>
<!-- Data de nascimento -->
<div class="form-group">
<label for="birthdate">Data de nascimento</label>
<input
id="birthdate"
v-model="form.birthdate"
type="date"
max="2010-01-01"
/>
</div>
<!-- Busca -->
<div class="form-group">
<label for="search">Buscar arena</label>
<input
id="search"
v-model="form.search"
type="search"
inputmode="search"
enterkeyhint="search"
placeholder="Nome ou cidade..."
/>
</div>
<button type="submit" class="btn btn-primary btn-block">
Continuar
</button>
</form>
</template>
<style lang="scss" scoped>
.mobile-form {
.form-group {
margin-bottom: $spacing-4;
}
label {
display: block;
margin-bottom: $spacing-1;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
}
input {
width: 100%;
padding: $spacing-3 $spacing-4;
font-size: 16px; // Evita zoom no iOS
border: 1px solid var(--color-border);
border-radius: $radius-md;
background-color: var(--color-bg-input);
&:focus {
outline: none;
border-color: $color-primary-500;
box-shadow: 0 0 0 3px rgba($color-primary-500, 0.1);
}
}
}
</style>6.2 Stepper Form
vue
<template>
<div class="booking-stepper">
<!-- Progress indicator -->
<div class="stepper-progress">
<div
v-for="(step, index) in steps"
:key="step.id"
class="step-indicator"
:class="{
'is-active': currentStep === index,
'is-completed': currentStep > index,
}"
>
<div class="step-circle">
<CheckIcon v-if="currentStep > index" />
<span v-else>{{ index + 1 }}</span>
</div>
<span class="step-label">{{ step.label }}</span>
</div>
</div>
<!-- Step content -->
<transition :name="transitionName" mode="out-in">
<component
:is="steps[currentStep].component"
:key="currentStep"
v-model="formData"
@next="nextStep"
@prev="prevStep"
/>
</transition>
<!-- Navigation -->
<div class="stepper-nav">
<button
v-if="currentStep > 0"
class="btn btn-ghost"
@click="prevStep"
>
<ChevronLeftIcon />
Voltar
</button>
<button
v-if="currentStep < steps.length - 1"
class="btn btn-primary"
:disabled="!canProceed"
@click="nextStep"
>
Próximo
<ChevronRightIcon />
</button>
<button
v-else
class="btn btn-primary"
:disabled="!canProceed"
@click="submit"
>
Confirmar reserva
</button>
</div>
</div>
</template>7. Offline Support
7.1 Service Worker Strategy
typescript
// sw.js - Service Worker
const CACHE_NAME = 'stc-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/css/app.css',
'/js/app.js',
'/icons/icon-192.png',
'/icons/icon-512.png',
];
// Install - cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
});
// Fetch - network first, fallback to cache
self.addEventListener('fetch', (event) => {
// API requests - network only with offline queue
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request).catch(() => {
// Queue for later sync
return new Response(
JSON.stringify({ offline: true }),
{ headers: { 'Content-Type': 'application/json' } }
);
})
);
return;
}
// Static assets - cache first
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
// Cache new responses
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
return response;
});
})
);
});
// Background sync for offline actions
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-bookings') {
event.waitUntil(syncPendingBookings());
}
});7.2 Offline UI
vue
<template>
<div class="offline-indicator" v-if="!isOnline">
<WifiOffIcon />
<span>Você está offline</span>
<span class="subtext">Algumas funcionalidades podem estar limitadas</span>
</div>
<!-- Componente com estado offline -->
<div class="booking-list" :class="{ 'is-offline': !isOnline }">
<div v-for="booking in bookings" :key="booking.id" class="booking-item">
<!-- Conteúdo -->
<div v-if="booking.pendingSync" class="sync-indicator">
<CloudOffIcon />
Aguardando sincronização
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const isOnline = ref(navigator.onLine);
function updateOnlineStatus() {
isOnline.value = navigator.onLine;
}
onMounted(() => {
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
});
onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus);
window.removeEventListener('offline', updateOnlineStatus);
});
</script>
<style lang="scss" scoped>
.offline-indicator {
position: fixed;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
background-color: $color-warning-500;
color: white;
font-size: $font-size-sm;
z-index: 1000;
}
.is-offline {
opacity: 0.7;
pointer-events: none;
}
.sync-indicator {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background-color: $color-neutral-100;
border-radius: 4px;
font-size: $font-size-xs;
color: $color-neutral-600;
}
</style>8. PWA Configuration
8.1 Manifest
json
{
"name": "Sport Tech Club",
"short_name": "STC",
"description": "Reserve quadras de esportes de praia",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#FFFFFF",
"theme_color": "#2196F3",
"scope": "/",
"icons": [
{
"src": "/icons/icon-72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Minhas Reservas",
"url": "/bookings",
"icons": [{ "src": "/icons/shortcut-bookings.png", "sizes": "96x96" }]
},
{
"name": "Nova Reserva",
"url": "/search",
"icons": [{ "src": "/icons/shortcut-new.png", "sizes": "96x96" }]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url"
}
}
}8.2 Install Prompt
vue
<template>
<transition name="slide-up">
<div v-if="showInstallPrompt" class="install-prompt">
<div class="install-content">
<img src="/icons/icon-72.png" alt="Sport Tech Club" />
<div>
<h3>Instalar Sport Tech Club</h3>
<p>Acesse mais rápido direto da sua tela inicial</p>
</div>
</div>
<div class="install-actions">
<button class="btn btn-ghost" @click="dismiss">Agora não</button>
<button class="btn btn-primary" @click="install">Instalar</button>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const showInstallPrompt = ref(false);
let deferredPrompt = null;
onMounted(() => {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
// Mostra após 30 segundos de uso
setTimeout(() => {
if (!localStorage.getItem('pwa-dismissed')) {
showInstallPrompt.value = true;
}
}, 30000);
});
});
async function install() {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
// Track install
analytics.track('pwa_installed');
}
deferredPrompt = null;
showInstallPrompt.value = false;
}
}
function dismiss() {
showInstallPrompt.value = false;
localStorage.setItem('pwa-dismissed', 'true');
}
</script>9. Performance Móvel
9.1 Métricas Alvo
yaml
metricas:
core_web_vitals:
LCP: < 2.5s # Largest Contentful Paint
FID: < 100ms # First Input Delay
CLS: < 0.1 # Cumulative Layout Shift
INP: < 200ms # Interaction to Next Paint
custom:
TTI: < 3.5s # Time to Interactive
TBT: < 300ms # Total Blocking Time
speed_index: < 3s
bundle:
initial: < 150KB # gzipped
route_chunk: < 50KB
total: < 400KB9.2 Otimizações
typescript
// Lazy loading de rotas
const routes = [
{
path: '/',
component: () => import('./views/Home.vue'),
},
{
path: '/bookings',
component: () => import('./views/Bookings.vue'),
},
{
path: '/arena/:id',
component: () => import('./views/ArenaDetail.vue'),
},
];
// Preload de rotas críticas
router.beforeEach((to, from, next) => {
// Preload rotas relacionadas
if (to.path === '/search') {
import('./views/ArenaDetail.vue');
}
next();
});
// Image lazy loading
const LazyImage = {
mounted(el: HTMLImageElement) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
el.src = el.dataset.src!;
observer.unobserve(el);
}
});
});
observer.observe(el);
},
};10. Checklist Mobile UX
10.1 Touch & Gestures
- [ ] Touch targets mínimo 44x44px
- [ ] Espaçamento entre elementos tocáveis
- [ ] Feedback visual ao toque
- [ ] Gestos de swipe implementados
- [ ] Pull-to-refresh funcional
10.2 Performance
- [ ] Bundle < 200KB inicial
- [ ] LCP < 2.5s
- [ ] Skeleton loading implementado
- [ ] Lazy loading de imagens
- [ ] Lazy loading de rotas
10.3 PWA
- [ ] Manifest configurado
- [ ] Service Worker funcional
- [ ] Offline mode básico
- [ ] Install prompt
- [ ] Icons em todos tamanhos
10.4 Acessibilidade
- [ ] Teclado virtual não cobre inputs
- [ ] Font-size mínimo 16px em inputs
- [ ] Contraste adequado
- [ ] Screen reader compatível
Este documento serve como guia para garantir uma experiência mobile consistente e de alta qualidade no Sport Tech Club.