Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements. Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
import BrandMark from '$ui/components/BrandMark.svelte';
|
||||
import Toaster from '$ui/components/Toaster.svelte';
|
||||
import Confirmation from '$ui/components/Confirmation.svelte';
|
||||
import NotificationHistory from '$ui/components/NotificationHistory.svelte';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
import { notificationHistory, startNotificationHistory } from '$ui/toast.js';
|
||||
import { resolveConfirmation } from '$ui/confirm.js';
|
||||
import '$ui/styles/components.css';
|
||||
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
|
||||
@@ -18,8 +21,17 @@
|
||||
interface Props { children: import('svelte').Snippet }
|
||||
let { children }: Props = $props();
|
||||
let menuOpen = $state(false);
|
||||
let notificationsOpen = $state(false);
|
||||
let helpOpen = $state(false);
|
||||
const unread = $derived($notificationHistory.filter(item => !item.read).length);
|
||||
let menuButton: HTMLButtonElement;
|
||||
|
||||
function openUtility(panel: 'notifications' | 'help') {
|
||||
if (menuOpen) { menuButton?.focus(); menuOpen = false; }
|
||||
if (panel === 'notifications') notificationsOpen = true;
|
||||
else helpOpen = true;
|
||||
}
|
||||
|
||||
afterNavigate(() => { menuOpen = false; resolveConfirmation(false); });
|
||||
|
||||
function closeMenu(event: KeyboardEvent) {
|
||||
@@ -30,6 +42,7 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const stopNotifications = startNotificationHistory();
|
||||
const stopPipeline = startPipelineStream();
|
||||
const stopServers = startServerStream();
|
||||
const stopFleetMonitor = startFleetMonitor();
|
||||
@@ -38,7 +51,7 @@
|
||||
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
|
||||
const shortcuts: Record<string, string> = {
|
||||
f: '/', d: '/deployments', a: '/campaigns', b: '/betas',
|
||||
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard'
|
||||
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings'
|
||||
};
|
||||
let armed = false;
|
||||
let armTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -47,6 +60,7 @@
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.key === '?') { e.preventDefault(); openUtility('help'); return; }
|
||||
|
||||
if (!armed && e.key === 'g') {
|
||||
armed = true;
|
||||
@@ -66,6 +80,7 @@
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
stopNotifications();
|
||||
stopFleetMonitor();
|
||||
stopPipeline?.();
|
||||
stopServers?.();
|
||||
@@ -83,14 +98,14 @@
|
||||
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
|
||||
{ href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) },
|
||||
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
|
||||
{ href: '/server-keys', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') },
|
||||
{ href: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') || p.startsWith('/settings') },
|
||||
{ href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') }
|
||||
];
|
||||
const secondary = $derived(tabs[1].match($page.url.pathname)
|
||||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
|
||||
: tabs[2].match($page.url.pathname)
|
||||
? [{ href: '/deployments', label: 'Server deployments' }, { href: '/campaigns', label: 'Rollouts' }]
|
||||
: tabs[4].match($page.url.pathname) ? [{ href: '/server-keys', label: 'Server keys' }] : []);
|
||||
: tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'System status' }, { href: '/server-keys', label: 'Server keys' }] : []);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={closeMenu} />
|
||||
@@ -119,7 +134,7 @@
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="strut" aria-hidden="true"></div>
|
||||
<div class="nav-tools" class:expanded={menuOpen}><button class="utility" aria-label={`Recent notifications${unread ? `, ${unread} unread` : ''}`} onclick={() => openUtility('notifications')}>Activity{unread ? ` · ${unread}` : ''}</button><button class="utility" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => openUtility('help')}>?</button></div>
|
||||
</nav>
|
||||
{#if secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={$page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
|
||||
<main id="main-content" tabindex="-1">
|
||||
@@ -127,6 +142,10 @@
|
||||
</main>
|
||||
<Toaster />
|
||||
<Confirmation />
|
||||
<NotificationHistory bind:open={notificationsOpen} />
|
||||
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog.">
|
||||
<dl class="shortcuts">{#each [['g f','Fleet'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Server deployments'],['g a','Rollouts'],['g b','Betas'],['g h','System status'],['g k','Server keys'],['g l','Leaderboard'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -229,7 +248,13 @@
|
||||
view-transition-name: nav-active;
|
||||
}
|
||||
|
||||
.strut { margin-left: auto; }
|
||||
.nav-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||||
.utility { background: var(--surface-raised); color: var(--text-secondary); border: 1px solid var(--border-strong); padding: 8px 10px; border-radius: var(--radius-md); cursor: pointer; font: inherit; font-size: var(--text-xs); white-space: nowrap; }
|
||||
.shortcuts { display: grid; gap: 12px; margin: 0; }
|
||||
.shortcuts > div { display: flex; gap: 20px; align-items: center; }
|
||||
.shortcuts dt { min-width: 55px; }
|
||||
.shortcuts dd { margin: 0; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
kbd { font-family: var(--font-mono); padding: 3px 8px; border: 1px solid var(--border-strong); border-radius: 5px; }
|
||||
.menu-toggle { display: none; }
|
||||
|
||||
main {
|
||||
@@ -242,7 +267,7 @@
|
||||
}
|
||||
|
||||
/* The full navigation needs more room than a phone or portrait tablet. */
|
||||
@media (max-width: 800px) {
|
||||
@media (max-width: 1080px) {
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
@@ -271,7 +296,8 @@
|
||||
.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; }
|
||||
.nav-tools { display: none; }
|
||||
.nav-tools.expanded { display: flex; width: 100%; padding-top: 8px; }
|
||||
}
|
||||
@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)); }
|
||||
@@ -282,6 +308,7 @@
|
||||
}
|
||||
|
||||
:global(input), :global(select), :global(textarea) { min-width: 0; max-width: 100%; }
|
||||
:global(input), :global(select), :global(textarea), :global(button), :global(a) { scroll-margin-block: 90px 100px; }
|
||||
:global(dialog) { max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; }
|
||||
.skip-link { position: fixed; left: 16px; top: -80px; z-index: 300; padding: 12px 16px; background: var(--surface-raised); border-radius: var(--radius-md); }
|
||||
.skip-link:focus { top: 12px; }
|
||||
|
||||
Reference in New Issue
Block a user