feat(ui): add global feedback and agent menu

This commit is contained in:
JB
2026-07-26 01:40:19 -04:00
parent 0e93aa25bd
commit d4ddfe3b80
11 changed files with 299 additions and 15 deletions
+50
View File
@@ -95,6 +95,25 @@ cards collapse from four columns to two and then one; hover color alone never
replaces their textual destination labels, focus-visible treatment, or pressed
feedback.
Authenticated desktop account destinations are consolidated into one
agent-status menu at the right edge of the global header. Its trigger combines
the agent avatar, display name, online state, unread count, and an explicit
expanded marker. The menu contains My Profile, Notifications, and Sign Out;
these actions are not duplicated as standalone desktop header links. The menu
uses the same square, compact control-surface treatment as the rest of the
header, with orange reserved for the open/current state and unread count and
danger color reserved for the Sign Out hover state.
The account trigger exposes its expanded and controlled-menu state. Opening the
menu moves keyboard focus to its first destination; Escape closes it and
returns focus to the trigger, while an outside click dismisses it. Mobile keeps
Profile, Notifications, and Sign Out as explicit navigation actions and shows
the avatar in the signed-in identity block. Avatar imagery supplements rather
than replaces the textual identity, state, and unread indicators. User avatars
are always rendered as circular portraits with the restrained orange SHD ring
used by the Agent Profile; the source image remains square and uses
`object-fit: cover` so authenticated surfaces crop it consistently.
Contact notifications use the mandatory ISAC visual identity because they are
operational inbound messages. The notification carries a concise preview and a
specific action to an authorization-checked full record. Full messages never
@@ -1392,3 +1411,34 @@ Weapon 1 · Top, and Weapon 2 · Bottom. Always include the Top/Bottom context
when presenting numbered Weapon Talent slots to users. The filter remains
additive within the group and wraps to two columns on narrow screens; do not
introduce search or dropdown controls for this four-option set.
Notification delivery preferences are a temporary profile control surface.
After a successful save, close the interface and return focus to the inbox
context. Consume a unique server result token only once so later actions or
reopening the panel do not close it again. Validation and server errors keep
the interface open with the submitted controls available for correction.
### Global toast feedback
Use the root-mounted `ToastViewport` and the shared API in `src/lib/toast.ts`
for transient action feedback. Enhanced server mutations should normally use
`PendingForm`, which derives success and failure feedback from the action
result. Client-only operations call `toast.success`, `toast.error`,
`toast.warning`, or `toast.info` directly. Do not create page-specific toast
implementations.
Toasts occupy the top-right safe area below the global header and show no more
than four messages. Each uses a textual state label, tone-colored top and left
rails, a concise title, optional supporting text, and an explicit dismiss
control. Success is green, errors use danger red, warnings use SHD orange, and
informational feedback uses restrained blue-gray. Color never carries state
alone.
Use success to confirm completed mutations, error for failed requests, warning
when attention is required, and info for neutral client or system state. Keep
inline validation beside affected fields even when an error toast is also
emitted. Avoid toasts for passive navigation, filtering, hover, unpersisted
selection changes, and background refreshes. Toasts automatically dismiss
according to severity, remain keyboard dismissible, use `status` or `alert`
semantics, and disable entrance motion with reduced motion. On mobile they span
the safe viewport width below the header without obscuring navigation.
+9
View File
File diff suppressed because one or more lines are too long
+49 -10
View File
@@ -5,7 +5,7 @@
import type { Snippet } from 'svelte';
let { user, isacAccess, notificationSummary, children }: {
user: { username: string; displayName: string; role: string } | null;
user: { username: string; displayName: string; role: string; avatarUrl: string } | null;
isacAccess: boolean;
notificationSummary: { unread: number };
children: Snippet;
@@ -22,6 +22,9 @@
let showCookieNotice = $state(false);
let mobileOpen = $state(false);
let mobilePanel = $state<HTMLElement | null>(null);
let accountOpen = $state(false);
let accountButton = $state<HTMLButtonElement | null>(null);
let accountMenu = $state<HTMLElement | null>(null);
let unreadNotifications = $state(0);
$effect(() => { unreadNotifications = notificationSummary.unread; });
@@ -70,15 +73,33 @@
event.preventDefault();
closeMobileNavigation();
}
if (event.key === 'Escape' && accountOpen) {
event.preventDefault();
accountOpen = false;
accountButton?.focus();
}
}
async function toggleAccountMenu() {
accountOpen = !accountOpen;
if (accountOpen) {
await tick();
accountMenu?.querySelector<HTMLElement>('a')?.focus();
}
}
function closeAccountFromOutside(event: MouseEvent) {
if (accountOpen && !(event.target as Element).closest('.desktop-agent-menu')) accountOpen = false;
}
$effect(() => {
page.url.pathname;
mobileOpen = false;
accountOpen = false;
});
</script>
<svelte:window onkeydown={handleNavigationKey}/>
<svelte:window onkeydown={handleNavigationKey} onclick={closeAccountFromOutside}/>
<header class="site-header">
<a class="brand" href="/" aria-label="SHD.build home">
@@ -88,18 +109,36 @@
{#each links as link}
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}>{link.label}</a>
{/each}
{#if user}
<a class:active={page.url.pathname === '/account'} href="/account">My Profile</a>
{/if}
{#if isacAccess}
<a class:active={page.url.pathname.startsWith('/isac')} href="/isac">ISAC</a>
{/if}
</nav>
{#if user}
<div class="account">
<a class="notification-link" href="/account/notifications" aria-label={`${unreadNotifications} unread notifications`}><span>◆</span>{#if unreadNotifications}<b>{unreadNotifications > 99 ? '99+' : unreadNotifications}</b>{/if}</a>
<a href="/account"><small><i></i> AGENT ONLINE</small>{user.displayName}</a>
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button aria-busy={pending}>SIGN OUT →</button>{/snippet}</PendingForm>
<div class="desktop-agent-menu">
<button
class="desktop-agent-trigger"
type="button"
aria-haspopup="menu"
aria-expanded={accountOpen}
aria-controls="desktop-agent-menu"
bind:this={accountButton}
onclick={toggleAccountMenu}
>
<img src={user.avatarUrl} alt=""/>
<span><small><i></i> AGENT ONLINE</small><b>{user.displayName}</b></span>
{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{/if}
<strong aria-hidden="true">{accountOpen ? '−' : '+'}</strong>
</button>
{#if accountOpen}
<div id="desktop-agent-menu" class="desktop-agent-panel" role="menu" bind:this={accountMenu}>
<header><img src={user.avatarUrl} alt=""/><div><small>ACTIVE AGENT</small><b>{user.displayName}</b><span>{user.role.toUpperCase()} ACCESS</span></div></header>
<a role="menuitem" class:active={page.url.pathname === '/account'} href="/account"><span>MY PROFILE</span><b>AGENT DOSSIER →</b></a>
<a role="menuitem" class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><span>NOTIFICATIONS</span><b>{unreadNotifications ? `${unreadNotifications > 99 ? '99+' : unreadNotifications} UNREAD` : 'INBOX CLEAR'} →</b></a>
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button role="menuitem" class="desktop-agent-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
</div>
{/if}
</div>
</div>
{:else}
<a class="sign-in" href="/sign-in">SIGN IN →</a>
@@ -117,14 +156,14 @@
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}><small>{String(index + 1).padStart(2, '0')}</small><span>{link.label}</span><b>›</b></a>
{/each}
{#if user}
<a class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Inbox</span>{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{:else}<b>›</b>{/if}</a>
<a class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Notifications</span>{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{:else}<b>›</b>{/if}</a>
<a class:active={page.url.pathname === '/account'} href="/account"><small>{String(links.length + 2).padStart(2, '0')}</small><span>My Profile</span><b>›</b></a>
{:else}
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Sign In</span><b>›</b></a>
{/if}
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/isac')} href="/isac"><small>{String(links.length + 3).padStart(2, '0')}</small><span>ISAC</span><b>›</b></a>{/if}
{#if user}
<div class="mobile-agent-state"><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
<div class="mobile-agent-state"><img src={user.avatarUrl} alt=""/><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button class="mobile-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
{/if}
</nav>
+19 -2
View File
@@ -1,10 +1,27 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { actionFeedback } from '$lib/toast';
import type { Snippet } from 'svelte';
let { action, method = 'POST', enctype, class: className = '', children }: { action: string; method?: 'POST'; enctype?: 'multipart/form-data'; class?: string; children: Snippet<[boolean]> } = $props();
let {
action,
method = 'POST',
enctype,
class: className = '',
toast = true,
successMessage,
children
}: {
action: string;
method?: 'POST';
enctype?: 'multipart/form-data';
class?: string;
toast?: boolean;
successMessage?: string;
children: Snippet<[boolean]>;
} = $props();
let pending = $state(false);
</script>
<form {action} {method} {enctype} class={className} use:enhance={() => { if (pending) return; pending = true; return async ({ update }) => { try { await update(); } finally { pending = false; } }; }}>
<form {action} {method} {enctype} class={className} use:enhance={() => { if (pending) return; pending = true; return async ({ result, update }) => { try { if (toast) actionFeedback(result, action, successMessage); await update(); } finally { pending = false; } }; }}>
<fieldset disabled={pending} aria-busy={pending}>{@render children(pending)}</fieldset>
</form>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import { dismissToast, toasts, type Toast } from '$lib/toast';
const toneLabel = (toast: Toast) => ({
success: 'OPERATION COMPLETE',
error: 'ACTION FAILED',
warning: 'ATTENTION REQUIRED',
info: 'SYSTEM UPDATE'
})[toast.tone];
function startTimer(_node: HTMLElement, toast: Toast) {
const timer = window.setTimeout(() => dismissToast(toast.id), toast.duration);
return {
destroy() {
window.clearTimeout(timer);
}
};
}
</script>
<aside class="toast-viewport" aria-label="Application feedback">
{#each $toasts as item (item.id)}
<article
class={`toast-message ${item.tone}`}
role={item.tone === 'error' || item.tone === 'warning' ? 'alert' : 'status'}
aria-atomic="true"
use:startTimer={item}
>
<i aria-hidden="true"></i>
<div>
<span>{toneLabel(item)}</span>
<strong>{item.title}</strong>
{#if item.message}<p>{item.message}</p>{/if}
</div>
<button type="button" aria-label={`Dismiss ${item.title}`} onclick={() => dismissToast(item.id)}>×</button>
</article>
{/each}
</aside>
+114
View File
@@ -0,0 +1,114 @@
import { writable } from 'svelte/store';
export type ToastTone = 'success' | 'error' | 'warning' | 'info';
export type Toast = {
id: string;
tone: ToastTone;
title: string;
message?: string;
duration: number;
};
const store = writable<Toast[]>([]);
export const toasts = { subscribe: store.subscribe };
const defaultDuration: Record<ToastTone, number> = {
success: 5000,
info: 6000,
warning: 8000,
error: 10000
};
export function dismissToast(id: string) {
store.update((items) => items.filter((item) => item.id !== id));
}
export function pushToast(input: {
tone?: ToastTone;
title: string;
message?: string;
duration?: number;
}) {
const tone = input.tone ?? 'info';
const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
const toast: Toast = {
id,
tone,
title: input.title,
message: input.message,
duration: input.duration ?? defaultDuration[tone]
};
store.update((items) => [...items.slice(-3), toast]);
return id;
}
export const toast = {
success: (title: string, message?: string) => pushToast({ tone: 'success', title, message }),
error: (title: string, message?: string) => pushToast({ tone: 'error', title, message }),
warning: (title: string, message?: string) => pushToast({ tone: 'warning', title, message }),
info: (title: string, message?: string) => pushToast({ tone: 'info', title, message })
};
const readableKey = (key: string) =>
key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replaceAll('_', ' ')
.replace(/\b\w/g, (character) => character.toUpperCase());
const successfulKey = /(saved|created|updated|changed|removed|deleted|unlinked|linked|added|sent|cleared|approved|rejected|reverted|archived|unarchived|queued|enabled|disabled|restored)$/i;
export function actionFeedback(
result: { type: string; data?: unknown },
action: string,
successMessage?: string
) {
const data = result.data && typeof result.data === 'object' ? result.data as Record<string, unknown> : {};
if (result.type === 'failure' || result.type === 'error') {
const message = Object.entries(data).find(([key, value]) =>
/error|message/i.test(key) && typeof value === 'string'
)?.[1];
toast.error('REQUEST NOT COMPLETED', typeof message === 'string' ? message : 'Review the form and try again.');
return;
}
if (result.type !== 'success' && result.type !== 'redirect') return;
const explicit = typeof data.toastMessage === 'string'
? data.toastMessage
: typeof data.message === 'string'
? data.message
: successMessage;
if (explicit) {
toast.success(explicit);
return;
}
const resultKey = Object.entries(data).find(([key, value]) => value === true && successfulKey.test(key))?.[0];
if (resultKey) {
toast.success(readableKey(resultKey));
return;
}
const actionName = action.split('?/')[1]?.split(/[?#]/)[0] ?? '';
const actionMessages: Record<string, string> = {
save: 'Changes saved',
saveTalent: 'Talent saved',
saveTrigger: 'Trigger saved',
savePreferences: 'Preferences saved',
saveCoreRanges: 'Configuration saved',
saveProfile: 'Profile saved',
approve: 'Approved successfully',
reject: 'Rejected successfully',
correct: 'Corrections saved',
revert: 'Revert completed',
remove: 'Removed successfully',
unlinkDiscord: 'Discord connection unlinked',
removePasskey: 'Passkey removed',
removeEmail: 'Email sign-in removed',
removeCustomAvatar: 'Custom avatar removed',
changePassword: 'Password updated',
changeDisplayName: 'Display name updated',
changeLoginUsername: 'Login username updated',
requestEmailVerification: 'Verification email sent',
markAllRead: 'All messages marked as read',
markRead: 'Message marked as read',
send: 'Message sent'
};
if (actionMessages[actionName]) toast.success(actionMessages[actionName]);
}
+2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import '../app.css';
import AppShell from '$lib/components/AppShell.svelte';
import ToastViewport from '$lib/components/ToastViewport.svelte';
let { data, children } = $props();
</script>
@@ -9,3 +10,4 @@
</svelte:head>
<AppShell user={data.user} isacAccess={data.isacAccess} notificationSummary={data.notifications}>{@render children()}</AppShell>
<ToastViewport/>
+3
View File
@@ -2,6 +2,7 @@
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import { toast } from '$lib/toast';
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
let { data, form } = $props();
@@ -64,9 +65,11 @@
const result = await verification.json();
if (!verification.ok || !result.verified) throw new Error(result.message || 'Passkey registration failed.');
passkeyMessage = 'Passkey registered. Reloading account methods…';
toast.success('Passkey registered', 'Your new sign-in method is ready.');
location.reload();
} catch (error) {
passkeyClientError = error instanceof Error ? error.message : 'Passkey registration was cancelled.';
toast.error('Passkey not registered', passkeyClientError);
passkeyPending = false;
}
}
@@ -81,6 +81,9 @@ export const actions: Actions = {
in_app_enabled=excluded.in_app_enabled,browser_enabled=excluded.browser_enabled,email_frequency=excluded.email_frequency,updated_at=excluded.updated_at`))
.run(user.id, category, inApp ? 1 : 0, browser ? 1 : 0, emailFrequency, now);
}
return { notificationPreferencesSaved: true };
return {
notificationPreferencesSaved: true,
notificationPreferenceSaveToken: crypto.randomUUID()
};
}
};
@@ -6,6 +6,7 @@
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
let { data, form } = $props();
let settingsOpen = $state(false);
let consumedPreferenceSaveToken = $state<string | null>(null);
let selected = $state<any>(null);
let browserPermission = $state(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission);
const labels: Record<string, string> = { isac: 'ISAC', contributions: 'CONTRIBUTIONS', comments: 'COMMENTS', builds: 'BUILDS', guides: 'GUIDES', system: 'SYSTEM' };
@@ -45,6 +46,13 @@
function applyFilters() {
document.querySelector<HTMLFormElement>('.notification-filter-console')?.requestSubmit();
}
$effect(() => {
const token = form?.notificationPreferenceSaveToken;
if (token && token !== consumedPreferenceSaveToken) {
consumedPreferenceSaveToken = token;
settingsOpen = false;
}
});
</script>
<svelte:head><title>Notification inbox — SHD.build</title></svelte:head>
+3 -2
View File
@@ -7,6 +7,7 @@
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import { toast } from '$lib/toast';
import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, remapRangeValue, scaled, weaponBaseRange,
type LoadoutDraft, type Roll
@@ -326,8 +327,8 @@
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v1', JSON.stringify(draft));
saved = true; message = 'Draft saved on this device.';
setTimeout(() => (message = ''), 2200);
saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.');
}
</script>