Add responsive mobile layouts and navigation

This commit is contained in:
2026-09-18 01:09:05 -04:00
parent 453821805a
commit 166f039ae6
21 changed files with 147 additions and 26 deletions
+66 -13
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onNavigate, goto } from '$app/navigation';
import { afterNavigate, onNavigate, goto } from '$app/navigation';
import { startPipelineStream } from '$ui/stores/pipeline.js';
import { startServerStream } from '$ui/stores/servers.js';
import { page } from '$app/stores';
@@ -13,6 +13,17 @@
interface Props { children: import('svelte').Snippet }
let { children }: Props = $props();
let menuOpen = $state(false);
let menuButton: HTMLButtonElement;
afterNavigate(() => { menuOpen = false; });
function closeMenu(event: KeyboardEvent) {
if (event.key === 'Escape' && menuOpen) {
menuOpen = false;
menuButton?.focus();
}
}
onMount(() => {
startPipelineStream();
@@ -64,26 +75,32 @@
{ href: '/scripts', label: 'Scripts', match: (p: string) => p.startsWith('/scripts') },
{ href: '/configs', label: 'Configs', match: (p: string) => p.startsWith('/configs') },
{ href: '/plugins', label: 'Plugins', match: (p: string) => p.startsWith('/plugins') },
{ href: '/deployments', label: 'Deployments', match: (p: string) => p === '/deployments' },
{ href: '/deployments', label: 'Deployments', match: (p: string) => p.startsWith('/deployments') },
{ href: '/campaigns', label: 'Campaigns', match: (p: string) => p.startsWith('/campaigns') },
{ href: '/server-keys', label: 'Keys', match: (p: string) => p.startsWith('/server-keys') },
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') }
];
</script>
<svelte:window onkeydown={closeMenu} />
<div class="shell">
<nav aria-label="Primary">
<a class="brand-link" href="/" aria-label="Electric Cherry home">
<BrandMark size="sm" />
</a>
<div class="tabs" role="tablist">
<button class="menu-toggle" bind:this={menuButton} type="button"
aria-expanded={menuOpen} aria-controls="primary-links"
onclick={() => { menuOpen = !menuOpen; }}>
{menuOpen ? 'Close' : 'Menu'}
<span aria-hidden="true">{menuOpen ? '×' : '☰'}</span>
</button>
<div class="tabs" id="primary-links" class:expanded={menuOpen}>
{#each tabs as t (t.href)}
{@const on = t.match($page.url.pathname)}
<a
href={t.href}
class:on
role="tab"
aria-selected={on}
aria-current={on ? 'page' : undefined}
>
<span class="lbl">{t.label}</span>
@@ -159,6 +176,7 @@
}
.brand-link {
flex-shrink: 0;
display: flex;
align-items: center;
text-decoration: none;
@@ -173,6 +191,7 @@
margin-left: var(--space-4);
}
.tabs a {
white-space: nowrap;
position: relative;
color: var(--text-tertiary);
text-decoration: none;
@@ -196,25 +215,59 @@
}
.strut { margin-left: auto; }
.menu-toggle { display: none; }
main {
min-width: 0;
overflow-wrap: anywhere;
padding: var(--space-10) var(--space-10) var(--space-24);
max-width: 1440px;
margin: 0 auto;
view-transition-name: main;
}
/* Mobile — collapse nav to a bottom-anchored, thumb-reachable bar.
(Fully realized in Phase 8; this is the graceful base.) */
@media (max-width: 720px) {
/* The full navigation needs more room than a phone or portrait tablet. */
@media (max-width: 1000px) {
nav {
gap: var(--space-3);
padding: 0 var(--space-4);
overflow-x: auto;
flex-wrap: wrap;
justify-content: space-between;
gap: 0;
height: auto;
min-height: 60px;
padding: calc(8px + env(safe-area-inset-top)) max(var(--space-4), env(safe-area-inset-right)) 8px max(var(--space-4), env(safe-area-inset-left));
max-height: 100dvh;
overflow-y: auto;
}
.tabs { margin-left: var(--space-2); }
main { padding: var(--space-6) var(--space-4) var(--space-16); }
.menu-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-height: 44px;
padding: 8px 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface-raised);
color: var(--text-primary);
font: inherit;
font-size: var(--text-sm);
cursor: pointer;
}
.menu-toggle:focus-visible { box-shadow: var(--focus-ring); }
.tabs { display: none; width: 100%; margin: 0; }
.tabs.expanded { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); padding: var(--space-3) 0 4px; }
.tabs a { display: flex; align-items: center; min-height: 44px; border-radius: var(--radius-md); }
.strut { display: none; }
}
@media (max-width: 720px) {
main { padding: var(--space-6) max(var(--space-4), env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4), env(safe-area-inset-left)); }
/* Keep native controls readable and prevent focus zoom on phones. */
:global(input:not([type='checkbox']):not([type='radio'])),
:global(select), :global(textarea) { font-size: 16px !important; min-height: 44px; }
:global(button), :global(.btn) { min-height: 44px; }
}
:global(input), :global(select), :global(textarea) { min-width: 0; max-width: 100%; }
:global(dialog) { max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; }
/* View-transition tuning — cross-fade routes, ease the shared nav pill. */
:global(::view-transition-old(root)),