refactor: restructure navigation into 5 grouped hubs, off the 22-item flat strip

Implements the researched IA fix: mobile nav UX consensus caps primary
destinations at 4-5 (uxpin.com, fintech/banking 2026 UX research), and
this app had grown to 22 tabs in one horizontally-scrolling row —
exactly the anti-pattern that research flags for choice paralysis and
slower task completion.

New structure — 5 bottom-nav hubs, grouped by what the user is actually
trying to do, not build order:
- Home: Coverage (unchanged, still the landing screen)
- Estate: Assets, Faraid, Insurance, Wassiyah, Hibah, Family Waqf,
  Nominate, Claims (H2)
- Giving: Zakat, Sadaqah, Khairat
- Family: Tree, Trigger, Mutawalli, Manage (was 'Family', renamed to
  avoid colliding with the hub's own label), Neighbourhood
- Daily: Prayer Times, Qibla, Quran, Locate

Each hub reveals its own sub-nav one tap in, instead of every tab
competing for space in a single row. Settings moved out of the tab
strip entirely into a header gear icon, matching the banking-app pattern
of keeping settings out of primary thumb-reach real estate. The bottom
nav is now fixed (thumb-zone), sub-nav keeps the old sticky-top position.

Cross-component navigation (nav.js requestedTab, used by the Family
Tree's 'give sadaqah in memory' link) now resolves a tab label to its
owning (hub, sub-tab) pair instead of a flat index — verified live.

This touches every E2E suite: a single click on a tab's old selector no
longer reaches it (hub, then sub-tab). Added a shared gotoTab(page, label)
helper to e2e-auth-helper.cjs encapsulating the two-step navigation, and
migrated all ~22 affected test files off direct nav-button selectors —
mechanical substitution followed by manual fixes for local clickTab
wrappers, template-literal selectors, and active-state assertions that
needed to target the new .hub-tab/.subnav structure specifically.

Full regression after migration: every suite passes (one isolated
Family Tree flake confirmed clean on rerun, unrelated to navigation).
This commit is contained in:
2026-08-14 14:55:09 +08:00
parent 78e025c27d
commit 33a821e61d
23 changed files with 250 additions and 136 deletions
+108 -39
View File
@@ -52,19 +52,83 @@
}
});
const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Zakat', 'Sadaqah', 'Khairat', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Prayer Times', 'Qibla', 'Locate', 'Quran', 'Neighbourhood', 'Claims (H2)', 'Family', 'Settings'];
const icons = ['🎯', '📊', '📁', '🛡️', '🌙', '🤲', '🆘', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🕌', '🧭', '📍', '📖', '📢', '🔗', '👥', '⚙️'];
let activeTab = $state(0);
// Five hubs instead of one 22-item flat tab strip — grouped by what the
// user is actually trying to do (plan the estate, give, manage the
// family, or use it day-to-day), not alphabetically or by build order.
// Research consistently points to 4-5 primary destinations as the
// ceiling for a bottom nav; everything else lives one tap deeper inside
// its hub instead of competing for space in a single scrolling row.
const HUBS = [
{ key: 'home', label: 'Home', icon: '🎯', tabs: [
{ label: 'Coverage', icon: '🎯', component: CoverageDashboard }
] },
{ key: 'estate', label: 'Estate', icon: '📁', tabs: [
{ label: 'Assets', icon: '📁', component: AssetRegistry },
{ label: 'Faraid', icon: '📊', component: FaraidCalculator },
{ label: 'Insurance', icon: '🛡️', component: InsurancePolicies },
{ label: 'Wassiyah', icon: '📜', component: WassiyahGenerator },
{ label: 'Hibah', icon: '🎁', component: HibahTracker },
{ label: 'Family Waqf', icon: '⛲', component: FamilyWaqfDesignator },
{ label: 'Nominate', icon: '📇', component: NominationRegistry },
{ label: 'Claims (H2)', icon: '🔗', component: DigitalClaims }
] },
{ key: 'giving', label: 'Giving', icon: '🤲', tabs: [
{ label: 'Zakat', icon: '🌙', component: ZakatCalculator },
{ label: 'Sadaqah', icon: '🤲', component: SadaqahTracker },
{ label: 'Khairat', icon: '🆘', component: KhairatTracker }
] },
{ key: 'family', label: 'Family', icon: '👪', tabs: [
{ label: 'Tree', icon: '🌳', component: FamilyTree },
{ label: 'Trigger', icon: '⚡', component: DeathTrigger },
{ label: 'Mutawalli', icon: '🕋', component: MutawalliDashboard },
{ label: 'Manage', icon: '👥', component: FamilyManagement },
{ label: 'Neighbourhood', icon: '📢', component: NeighbourhoodBoard }
] },
{ key: 'daily', label: 'Daily', icon: '🕌', tabs: [
{ label: 'Prayer Times', icon: '🕌', component: PrayerTimes },
{ label: 'Qibla', icon: '🧭', component: QiblaFinder },
{ label: 'Quran', icon: '📖', component: QuranReader },
{ label: 'Locate', icon: '📍', component: Locators }
] }
];
let activeHubKey = $state('home');
// Remembers which sub-tab was last open in each hub, so switching hubs
// and back doesn't reset your place.
let subIndexByHub = $state(Object.fromEntries(HUBS.map(h => [h.key, 0])));
let showSettings = $state(false);
const activeHub = $derived(HUBS.find(h => h.key === activeHubKey));
const activeSubTab = $derived(activeHub.tabs[subIndexByHub[activeHubKey]] ?? activeHub.tabs[0]);
const CurrentComponent = $derived(activeSubTab.component);
function selectHub(key) {
activeHubKey = key;
showSettings = false;
}
function selectSubTab(index) {
subIndexByHub = { ...subIndexByHub, [activeHubKey]: index };
showSettings = false;
}
// Cross-component navigation (e.g. the Family Tree's "give sadaqah in
// memory of" link) requests a tab by label — resolve it to whichever
// hub actually contains that label.
requestedTab.subscribe(name => {
if (!name) return;
const idx = tabs.indexOf(name);
if (idx >= 0) activeTab = idx;
for (const hub of HUBS) {
const idx = hub.tabs.findIndex(t => t.label === name);
if (idx >= 0) { activeHubKey = hub.key; subIndexByHub = { ...subIndexByHub, [hub.key]: idx }; showSettings = false; break; }
}
requestedTab.set(null);
});
function handleKeydown(e) {
if (e.key === 'ArrowRight') activeTab = (activeTab + 1) % tabs.length;
if (e.key === 'ArrowLeft') activeTab = (activeTab - 1 + tabs.length) % tabs.length;
const tabs = activeHub.tabs;
if (tabs.length < 2) return;
const current = subIndexByHub[activeHubKey] ?? 0;
if (e.key === 'ArrowRight') selectSubTab((current + 1) % tabs.length);
if (e.key === 'ArrowLeft') selectSubTab((current - 1 + tabs.length) % tabs.length);
}
function doExport() {
@@ -95,6 +159,7 @@
{:else}
<div class="app">
<header>
<button class="settings-btn" class:active={showSettings} onclick={() => showSettings = !showSettings} aria-label="Settings">⚙️</button>
<div class="header-brand">
<div class="brand-line brand-line-first">Nur</div>
<div class="brand-line brand-line-second">Falah</div>
@@ -103,38 +168,19 @@
<div class="header-divider"></div>
</header>
<nav>
{#each tabs as tab, i}
<button class="tab" class:active={activeTab === i} onclick={() => activeTab = i} aria-label={tab}>
<span class="tab-icon">{icons[i]}</span>
<span class="tab-label">{tab}</span>
</button>
{/each}
</nav>
{#if !showSettings && activeHub.tabs.length > 1}
<nav class="subnav">
{#each activeHub.tabs as t, i}
<button class="tab" class:active={subIndexByHub[activeHubKey] === i} onclick={() => selectSubTab(i)} aria-label={t.label}>
<span class="tab-icon">{t.icon}</span>
<span class="tab-label">{t.label}</span>
</button>
{/each}
</nav>
{/if}
<main>
{#if activeTab === 0}<CoverageDashboard />
{:else if activeTab === 1}<FaraidCalculator />
{:else if activeTab === 2}<AssetRegistry />
{:else if activeTab === 3}<InsurancePolicies />
{:else if activeTab === 4}<ZakatCalculator />
{:else if activeTab === 5}<SadaqahTracker />
{:else if activeTab === 6}<KhairatTracker />
{:else if activeTab === 7}<WassiyahGenerator />
{:else if activeTab === 8}<HibahTracker />
{:else if activeTab === 9}<FamilyWaqfDesignator />
{:else if activeTab === 10}<NominationRegistry />
{:else if activeTab === 11}<DeathTrigger />
{:else if activeTab === 12}<MutawalliDashboard />
{:else if activeTab === 13}<FamilyTree />
{:else if activeTab === 14}<PrayerTimes />
{:else if activeTab === 15}<QiblaFinder />
{:else if activeTab === 16}<Locators />
{:else if activeTab === 17}<QuranReader />
{:else if activeTab === 18}<NeighbourhoodBoard />
{:else if activeTab === 19}<DigitalClaims />
{:else if activeTab === 20}<FamilyManagement />
{:else if activeTab === 21}
{#if showSettings}
<div class="module">
<div class="module-header">
<h2>Settings</h2>
@@ -161,8 +207,19 @@
<button class="btn-secondary" onclick={signOut}>Sign out</button>
<button class="btn-danger" onclick={doDelete}>Delete local device data irreversible</button>
</div>
{:else}
<CurrentComponent />
{/if}
</main>
<nav class="hub-nav">
{#each HUBS as hub}
<button class="hub-tab" class:active={!showSettings && activeHubKey === hub.key} onclick={() => selectHub(hub.key)} aria-label={hub.label}>
<span class="hub-icon">{hub.icon}</span>
<span class="hub-label">{hub.label}</span>
</button>
{/each}
</nav>
</div>
{/if}
@@ -187,9 +244,11 @@
:global(#app) { position: relative; z-index: 2; }
.loading-screen { display: flex; align-items: center; justify-content: center; min-height: 100dvh; color: #8A8478; font-size: 14px; }
.app { max-width: 480px; margin: 0 auto; min-height: 100dvh; display: flex; flex-direction: column; padding-bottom: 80px; }
.app { max-width: 480px; margin: 0 auto; min-height: 100dvh; display: flex; flex-direction: column; padding-bottom: 88px; }
header { text-align: center; padding: 28px 16px 16px; background: rgba(12,17,23,0.95); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(201,168,76,0.15); position: sticky; top: 0; z-index: 10; }
.settings-btn { position: absolute; top: 20px; right: 14px; background: none; border: none; font-size: 18px; cursor: pointer; opacity: 0.7; padding: 6px; border-radius: 8px; }
.settings-btn.active { opacity: 1; background: rgba(201,168,76,0.12); }
.header-brand { display: flex; justify-content: center; gap: 8px; }
.brand-line { font-family: 'DM Serif Display', serif; font-size: 26px; }
.brand-line-first { color: #E8E4DC; }
@@ -197,7 +256,9 @@
.header-tagline { font-size: 10px; letter-spacing: 2px; color: #8A8478; }
.header-divider { height: 2px; width: 40px; background: #C9A84C; margin: 10px auto 0; border-radius: 2px; }
nav { display: flex; overflow-x: auto; gap: 4px; padding: 10px 8px; background: rgba(12,17,23,0.7); border-bottom: 1px solid rgba(201,168,76,0.1); position: sticky; top: 92px; z-index: 9; }
/* Sub-nav — this hub's own tabs, revealed one level below the primary
bottom nav rather than competing with 21 other items for space. */
.subnav { display: flex; overflow-x: auto; gap: 4px; padding: 10px 8px; background: rgba(12,17,23,0.7); border-bottom: 1px solid rgba(201,168,76,0.1); position: sticky; top: 92px; z-index: 9; }
.tab { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 8px 12px; background: none; border: none; border-radius: 10px; cursor: pointer; color: #8A8478; }
.tab.active { background: rgba(201,168,76,0.12); color: #C9A84C; }
.tab-icon { font-size: 18px; }
@@ -205,6 +266,14 @@
main { flex: 1; padding: 16px; }
/* Primary nav — 5 hubs, fixed to the bottom so every destination stays
in thumb reach, matching the pattern every reference app converges on. */
.hub-nav { display: flex; position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); width: 100%; max-width: 480px; background: rgba(12,17,23,0.97); backdrop-filter: blur(12px); border-top: 1px solid rgba(201,168,76,0.15); z-index: 11; padding: 6px 4px calc(6px + env(safe-area-inset-bottom, 0px)); }
.hub-tab { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 8px 2px; background: none; border: none; border-radius: 10px; cursor: pointer; color: #8A8478; }
.hub-tab.active { color: #C9A84C; }
.hub-icon { font-size: 20px; }
.hub-label { font-size: 10px; }
:global(.btn-danger) { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(239,68,68,0.4); background: rgba(239,68,68,0.1); color: #EF4444; font-weight: 600; cursor: pointer; margin-top: 12px; }
:global(.btn-secondary) { width: 100%; padding: 12px; border-radius: 8px; border: none; background: rgba(255,255,255,0.08); color: #E8E4DC; font-weight: 600; cursor: pointer; }