[#1+#7] Mobile-first redesign + accessibility

- Bottom tab bar on mobile (≤640px), top tabs on desktop
- Name modal → bottom sheet on mobile with drag handle
- Larger tap targets (72px cards, 44px touch areas)
- Arrow hint (›) on clickable option cards
- Touch feedback with :active scale animation
- ARIA roles: tablist/tab/tabpanel, aria-selected, aria-live=polite
- Keyboard: Arrow keys navigate tabs, 1-5 number keys switch tabs
- Skip-to-content link for screen readers
- Visible :focus-visible ring
- Desktop body padding reset for bottom tabs
This commit is contained in:
2026-04-28 21:37:33 -07:00
parent ee1f4d93ce
commit 3a3ff55893

View File

@@ -40,6 +40,26 @@
a { color: var(--accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
/* Skip to content */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--accent);
color: var(--bg);
padding: 8px 16px;
z-index: 9999;
font-weight: 700;
border-radius: 0 0 8px 0;
}
.skip-link:focus { top: 0; }
/* Visible focus ring for accessibility */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ── Header ─────────────────────────────────────────────── */ /* ── Header ─────────────────────────────────────────────── */
header { header {
background: linear-gradient(135deg, #13161f 0%, #1a1e2a 100%); background: linear-gradient(135deg, #13161f 0%, #1a1e2a 100%);
@@ -97,6 +117,10 @@
width: 360px; width: 360px;
text-align: center; text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.5); box-shadow: 0 20px 60px rgba(0,0,0,0.5);
position: relative;
}
.modal .drag-handle {
display: none;
} }
.modal h2 { font-size: 1.3rem; margin-bottom: 6px; color: var(--accent); } .modal h2 { font-size: 1.3rem; margin-bottom: 6px; color: var(--accent); }
.modal p { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 20px; } .modal p { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 20px; }
@@ -405,20 +429,134 @@
} }
.empty-state .empty-emoji { font-size: 2rem; margin-bottom: 10px; } .empty-state .empty-emoji { font-size: 2rem; margin-bottom: 10px; }
/* ── Mobile ──────────────────────────────────────────────── */ /* ── Bottom tab bar (mobile ≤640px) ──────────────────────── */
@media (max-width: 480px) { @media (max-width: 640px) {
header { flex-direction: column; gap: 6px; align-items: flex-start; } body { padding-bottom: 68px; }
.meta { text-align: left; }
main { padding: 12px; } /* Compact sticky header */
.tab { min-width: 70px; font-size: 0.68rem; } header {
padding: 8px 14px;
gap: 4px;
}
header h1 { font-size: 0.95rem; }
header .meta { font-size: 0.72rem; }
/* Bottom tab bar */
.tabs {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: auto;
border-bottom: none;
border-top: 1px solid var(--border);
background: var(--surface);
z-index: 100;
display: flex;
padding-bottom: env(safe-area-inset-bottom);
/* hide scroll — show all 5 tabs in a row */
overflow-x: auto;
scrollbar-width: none;
box-shadow: 0 -4px 20px rgba(0,0,0,0.4);
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
flex: 1;
min-width: 0;
padding: 8px 4px 10px;
border-bottom: none;
border-top: 3px solid transparent;
border-radius: 0;
font-size: 0.6rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.tab.active {
border-top-color: var(--accent);
background: rgba(0,212,255,0.08);
color: var(--accent);
}
.tab .tab-emoji { font-size: 1.2rem; }
.tab .tab-count {
position: absolute;
top: 4px;
right: 6px;
font-size: 0.55rem;
padding: 0 4px;
}
/* Main adjusts for bottom tabs */
main { padding: 12px; max-width: 100%; }
/* Option cards — larger tap targets */
.option-card { min-height: 72px; padding: 14px 16px; }
.option-name { font-size: 0.92rem; }
.option-desc { font-size: 0.76rem; }
/* Add arrow hint to cards */
.option-card::after {
content: '';
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 1.2rem;
opacity: 0.4;
}
.option-card.voted::after { display: none; }
/* Touch feedback */
.option-card:active { transform: scale(0.98); opacity: 0.9; }
} }
/* ── Desktop (≥641px) ──────────────────────────────────── */
@media (min-width: 641px) {
body { padding-bottom: 0; }
}
/* ── Mobile bottom sheet modal ───────────────────────────── */
@media (max-width: 640px) {
.modal-overlay { align-items: flex-end; justify-content: stretch; padding: 0; }
.modal {
width: 100%;
border-radius: 20px 20px 0 0;
padding: 12px 24px 32px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 -8px 40px rgba(0,0,0,0.6);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.modal .drag-handle {
display: block;
width: 40px;
height: 4px;
background: var(--border);
border-radius: 2px;
margin: 0 auto 14px;
}
.modal h2 { font-size: 1.15rem; }
.modal p { font-size: 0.8rem; margin-bottom: 14px; }
.modal input, .modal button { margin-bottom: 10px; }
}
/* Touch highlight */
.option-card { -webkit-tap-highlight-color: rgba(0,212,255,0.12); }
</style> </style>
</head> </head>
<body> <body>
<a class="skip-link" href="#optionsList">Skip to voting options</a>
<!-- Name Modal --> <!-- Name Modal -->
<div class="modal-overlay" id="nameModal"> <div class="modal-overlay" id="nameModal">
<div class="modal"> <div class="modal">
<div class="drag-handle"></div>
<h2>🏄 Who's Voting?</h2> <h2>🏄 Who's Voting?</h2>
<p>Enter your name so groomsmen know who voted for what. Stored locally — only visible to the group.</p> <p>Enter your name so groomsmen know who voted for what. Stored locally — only visible to the group.</p>
<input type="text" id="voterNameInput" placeholder="e.g. Mike, Chris, Dave…" maxlength="30" autocomplete="off" /> <input type="text" id="voterNameInput" placeholder="e.g. Mike, Chris, Dave…" maxlength="30" autocomplete="off" />
@@ -448,7 +586,7 @@
<div><span id="totalVotersCount"></span></div> <div><span id="totalVotersCount"></span></div>
</div> </div>
<div class="options-list" id="optionsList"> <div class="options-list" id="optionsList" role="tabpanel" aria-label="Voting options" aria-live="polite">
<div class="empty-state"><div class="empty-emoji"></div>Loading options…</div> <div class="empty-state"><div class="empty-emoji"></div>Loading options…</div>
</div> </div>
@@ -500,6 +638,15 @@
if (e.key === 'Enter') submitName(); if (e.key === 'Enter') submitName();
}); });
document.getElementById('voterNameInput').focus(); document.getElementById('voterNameInput').focus();
// Number keys 1-5 to switch tabs
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const num = parseInt(e.key);
if (num >= 1 && num <= state.categories.length) {
setTab(state.categories[num - 1].id);
}
});
} }
// ── WebSocket ────────────────────────────────────────────── // ── WebSocket ──────────────────────────────────────────────
@@ -592,18 +739,44 @@
function renderTabs() { function renderTabs() {
const bar = document.getElementById('tabsBar'); const bar = document.getElementById('tabsBar');
bar.innerHTML = state.categories.map(cat => ` bar.innerHTML = state.categories.map(cat => `
<div class="tab${cat.id === activeTab ? ' active' : ''}" onclick="setTab('${cat.id}')"> <div class="tab${cat.id === activeTab ? ' active' : ''}"
role="tab"
id="tab-${cat.id}"
aria-selected="${cat.id === activeTab}"
aria-controls="optionsList"
tabindex="${cat.id === activeTab ? 0 : -1}"
onclick="setTab('${cat.id}')"
onkeydown="handleTabKey(event, '${cat.id}')">
<span class="tab-emoji">${cat.emoji}</span> <span class="tab-emoji">${cat.emoji}</span>
${cat.name} ${cat.name}
<span class="tab-count" id="tab-count-${cat.id}">${state.options.filter(o => o.categoryId === cat.id).length}</span> <span class="tab-count" id="tab-count-${cat.id}">${state.options.filter(o => o.categoryId === cat.id).length}</span>
</div> </div>
`).join(''); `).join('');
bar.setAttribute('role', 'tablist');
bar.setAttribute('aria-label', 'Voting categories');
}
function handleTabKey(event, catId) {
const cats = state.categories.map(c => c.id);
const idx = cats.indexOf(catId);
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
setTab(cats[(idx + 1) % cats.length]);
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault();
setTab(cats[(idx - 1 + cats.length) % cats.length]);
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setTab(catId);
}
document.getElementById(`tab-${cats[(idx + 1) % cats.length]}`)?.focus();
} }
function setTab(id) { function setTab(id) {
activeTab = id; activeTab = id;
renderTabs(); renderTabs();
render(); render();
document.getElementById('optionsList')?.setAttribute('aria-label', state.categories.find(c => c.id === id)?.name + ' options');
} }
// ── Render options ──────────────────────────────────────── // ── Render options ────────────────────────────────────────