feat(ui): improve mobile navigation and registry actions
This commit is contained in:
@@ -61,6 +61,30 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps
|
||||
|
||||
## Layout patterns
|
||||
|
||||
### Global mobile navigation
|
||||
|
||||
At viewports up to 900px, replace the hidden desktop link rail and account
|
||||
controls with a clearly labeled square `MENU` control in the shared site
|
||||
header. Activating it opens a right-side SHD navigation panel over a dismissible
|
||||
backdrop. The panel includes Home, Discover, Builder, Guides, Sign In or My
|
||||
Profile, and ISAC when authorized; authenticated agents also receive identity
|
||||
state and Sign Out inside the panel.
|
||||
|
||||
Use numbered full-width navigation rows, explicit active-route text styling,
|
||||
orange action indicators, and minimum 44px interaction targets. Opening moves
|
||||
keyboard focus to the first route. Escape, backdrop activation, route changes,
|
||||
and the header Close control dismiss the panel. The toggle exposes
|
||||
`aria-expanded` and `aria-controls`, the panel has a distinct navigation label,
|
||||
and reduced-motion preferences remove icon transitions. Do not make mobile
|
||||
users rely on the logo or desktop-only account controls to reach core routes.
|
||||
|
||||
On phone-sized viewports up to 700px, ISAC module toolbars stack their action
|
||||
area beneath the module title. A single Add action spans the available width;
|
||||
multiple creation actions share an equal responsive grid. These controls use a
|
||||
minimum 48px height, centered labels, and the standard pressed, focus-visible,
|
||||
pending, and disabled treatments. Do not leave desktop-sized Add buttons
|
||||
squeezed beside long module headings on mobile.
|
||||
|
||||
### Public pages
|
||||
|
||||
- Maximum content width: approximately 1400–1640px.
|
||||
@@ -594,3 +618,18 @@ Inherent permissions that cannot be removed through role or user assignment use
|
||||
the compact blue information rail above the affected access controls. Always
|
||||
name the baseline and enumerate its capabilities so operators do not mistake an
|
||||
empty role assignment for an account with no effective access.
|
||||
|
||||
### Mobile registry detail targets
|
||||
|
||||
On narrow screens, a non-editing registry row with one canonical detail or
|
||||
review destination becomes a full-row tap target. Use a real stretched link
|
||||
when the destination is another route and a transparent button when it opens an
|
||||
in-page detail surface. Keep the explicit desktop `VIEW`, `REVIEW`, or
|
||||
`EVIDENCE` action, and add a visible mobile cue at the bottom of the row so the
|
||||
interaction is discoverable.
|
||||
|
||||
The target must expose an accurate accessible name, show the standard orange
|
||||
focus-visible outline, provide pressed feedback, and meet a minimum 48-pixel
|
||||
tap height. Do not apply this pattern to edit, delete, approve, or other
|
||||
mutating actions, and do not stretch a target across rows containing independent
|
||||
interactive controls.
|
||||
|
||||
@@ -135,4 +135,7 @@
|
||||
.command-status .data-mode-status{border-left:2px solid #667174}.command-status .data-mode-status strong{color:#b8c0c1;white-space:nowrap}.command-status .data-mode-status.contributor{border-left-color:var(--shd-orange)}.command-status .data-mode-status.contributor strong{color:#f2b64d}.command-status .data-mode-status.approver{border-left-color:#68a9c4}.command-status .data-mode-status.approver strong{color:#8ecce5}.command-status .data-mode-status.data-manager,.command-status .data-mode-status.admin{border-left-color:#75c08c}.command-status .data-mode-status.data-manager strong,.command-status .data-mode-status.admin strong{color:#8bd49f}@media(max-width:900px){.admin-command{align-items:flex-start;gap:12px}.command-status{display:grid;grid-template-columns:repeat(2,minmax(105px,1fr))}}@media(max-width:560px){.admin-command{display:grid}.command-status{width:100%}.command-status>div{min-width:0}}
|
||||
.data-readonly .module-toolbar button,.data-readonly .dashboard-row:not(.table-heading)>button,.data-readonly .dashboard-editor,.data-readonly form[method=post]{display:none!important}
|
||||
.command-status .data-mode-status.oversight{border-left-color:#68a9c4}.command-status .data-mode-status.oversight strong{color:#8ecce5}.oversight-decision-tabs{grid-template-columns:repeat(3,1fr)}.proposal-correction-head{display:flex;align-items:center;justify-content:space-between;margin:10px 0 6px;padding:8px 9px;border:1px solid var(--shd-border);background:#0d1011}.proposal-correction-head>div{display:grid;gap:3px}.proposal-correction-head span{font-size:7px;font-weight:900;letter-spacing:.8px}.proposal-correction-head small{color:var(--shd-text-muted);font-size:6px}.proposal-correction-head>b{color:var(--shd-orange);font-size:7px}.proposal-correction-grid{max-height:340px;display:grid;grid-template-columns:1fr 1fr;gap:6px;overflow:auto;padding:7px;border:1px solid var(--shd-border);background:#0b0e0f}.proposal-correction-grid .field{margin:0}.proposal-correction-grid .field>span{display:flex;justify-content:space-between}.proposal-correction-grid .field>span small{color:#61696b;font-size:5px}.proposal-correction-grid textarea{min-height:74px;resize:vertical}.immutable-proposal-file{display:grid;align-content:center;gap:4px;min-height:54px;padding:8px;border:1px dashed #394041;color:var(--shd-text-muted)}.immutable-proposal-file span,.immutable-proposal-file small{font-size:6px}.immutable-proposal-file b{color:var(--shd-text);font-size:8px}@media(max-width:700px){.oversight-decision-tabs,.proposal-correction-grid{grid-template-columns:1fr}}
|
||||
.mobile-nav-toggle,.mobile-nav-layer{display:none}@media(max-width:900px){.site-header{position:relative;z-index:40;padding:0 16px}.site-header>.account,.site-header>.sign-in{display:none}.mobile-nav-toggle{width:52px;height:44px;display:grid;grid-template-columns:1fr;align-content:center;gap:4px;margin-left:auto;padding:6px 8px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text);cursor:pointer}.mobile-nav-toggle>span{width:20px;height:1px;margin-left:auto;background:var(--shd-orange);transition:transform .14s ease,opacity .14s ease}.mobile-nav-toggle>b{font-size:5px;letter-spacing:1px;text-align:right}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(1){transform:translateY(5px) rotate(45deg)}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(2){opacity:0}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.mobile-nav-toggle:hover,.mobile-nav-toggle:focus-visible{border-color:var(--shd-orange);background:#171b1c}.mobile-nav-toggle:active{transform:translateY(1px);filter:brightness(.86)}.mobile-nav-layer{position:fixed;z-index:35;inset:76px 0 0;display:grid;grid-template-columns:1fr minmax(270px,82vw)}.mobile-nav-backdrop{grid-column:1;grid-row:1;width:100%;height:100%;padding:0;border:0;background:#050607c9;backdrop-filter:blur(2px);cursor:pointer}.mobile-navigation{grid-column:2;grid-row:1;width:100%;height:100%;display:flex;flex-direction:column;margin:0;overflow-y:auto;border-left:1px solid #4a5051;background:#0d1011;box-shadow:-18px 0 40px #000b}.mobile-nav-heading{min-height:61px;display:flex;align-items:center;justify-content:space-between;padding:11px 14px;border-bottom:1px solid var(--shd-border);background:#111415}.mobile-nav-heading>div{display:grid;gap:4px}.mobile-nav-heading span{color:var(--shd-text-muted);font-size:6px;letter-spacing:1.2px}.mobile-nav-heading strong{font-size:13px;letter-spacing:1px}.mobile-nav-heading>b{color:var(--shd-success);font-size:6px;letter-spacing:1px}.mobile-navigation>a{min-height:54px;display:grid;grid-template-columns:42px 1fr 20px;align-items:center;padding:0 13px 0 0;border-bottom:1px solid var(--shd-border-soft);color:#a1a8a9}.mobile-navigation>a small{height:100%;display:grid;place-items:center;background:#111415;color:#555d5f;font-size:6px;letter-spacing:.7px}.mobile-navigation>a span{padding-left:13px;font-size:10px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.mobile-navigation>a>b{color:var(--shd-orange);font-size:18px}.mobile-navigation>a:hover,.mobile-navigation>a:focus-visible,.mobile-navigation>a.active{background:#171b1c;color:#fff}.mobile-navigation>a.active{box-shadow:inset 2px 0 var(--shd-orange)}.mobile-navigation>a.active small{background:#211a0e;color:var(--shd-orange)}.mobile-navigation>a:active{filter:brightness(.82)}.mobile-agent-state{display:grid;gap:4px;margin-top:auto;padding:14px;border-top:1px solid var(--shd-border)}.mobile-agent-state span{color:#747c7e;font-size:6px;letter-spacing:1px}.mobile-agent-state i{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--shd-success);box-shadow:0 0 6px var(--shd-success)}.mobile-agent-state strong{font-size:11px}.mobile-navigation form{margin:0;padding:0 14px 14px}.mobile-sign-out{width:100%;min-height:40px;border:1px solid #503b37;background:#1b1211;color:var(--shd-danger);font-size:7px;font-weight:900;letter-spacing:1px;cursor:pointer}.mobile-sign-out:hover,.mobile-sign-out:focus-visible{border-color:var(--shd-danger);background:#251715}.mobile-sign-out:active{transform:translateY(1px);filter:brightness(.86)}}@media(max-width:430px){.mobile-nav-layer{grid-template-columns:36px 1fr}.site-header .brand-name small{display:none}}@media(prefers-reduced-motion:reduce){.mobile-nav-toggle>span{transition:none}}
|
||||
@media(max-width:700px){.module-toolbar{align-items:stretch;flex-direction:column;gap:11px;padding-bottom:11px}.module-toolbar>div:first-child{width:100%}.module-toolbar>button{width:100%;min-height:48px;justify-content:center}.module-toolbar>div:last-child:has(button){width:100%;display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:7px}.module-toolbar>div:last-child:has(button)>button{width:100%;min-height:48px;padding:0 14px;font-size:8px;letter-spacing:1px}.module-toolbar .primary,.module-toolbar .secondary{height:auto;min-height:48px}}
|
||||
.mobile-row-open{display:none}@media(max-width:700px){.mobile-detail-row{position:relative;min-height:48px;padding-bottom:25px;cursor:pointer}.dashboard-row>.mobile-row-open{position:absolute;z-index:2;inset:0;width:100%;height:100%;display:block;justify-self:stretch;margin:0;padding:0;border:0;background:transparent;color:transparent;font-size:0;cursor:pointer}.mobile-detail-row>.row-action,.mobile-detail-row>button:not(.mobile-row-open){display:none}.mobile-detail-row::after{content:"TAP TO VIEW ›";position:absolute;right:10px;bottom:7px;color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.8px;pointer-events:none}.moderation-row.mobile-detail-row::after{content:"TAP TO REVIEW ›"}.mobile-detail-row:hover{border-color:#665026;background:#171713}.mobile-detail-row:has(.mobile-row-open:focus-visible){outline:2px solid var(--shd-orange);outline-offset:-2px}.mobile-detail-row:has(.mobile-row-open:active){transform:translateY(1px);filter:brightness(.86)}}@media(prefers-reduced-motion:reduce){.mobile-detail-row:has(.mobile-row-open:active){transform:none}}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { user, isacAccess, children }: {
|
||||
@@ -17,6 +17,8 @@
|
||||
];
|
||||
|
||||
let showCookieNotice = $state(false);
|
||||
let mobileOpen = $state(false);
|
||||
let mobilePanel = $state<HTMLElement | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
showCookieNotice = localStorage.getItem('shd-essential-cookie-notice') !== 'dismissed';
|
||||
@@ -26,8 +28,32 @@
|
||||
localStorage.setItem('shd-essential-cookie-notice', 'dismissed');
|
||||
showCookieNotice = false;
|
||||
}
|
||||
|
||||
async function openMobileNavigation() {
|
||||
mobileOpen = true;
|
||||
await tick();
|
||||
mobilePanel?.querySelector<HTMLElement>('a')?.focus();
|
||||
}
|
||||
|
||||
function closeMobileNavigation() {
|
||||
mobileOpen = false;
|
||||
}
|
||||
|
||||
function handleNavigationKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && mobileOpen) {
|
||||
event.preventDefault();
|
||||
closeMobileNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
page.url.pathname;
|
||||
mobileOpen = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleNavigationKey}/>
|
||||
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/" aria-label="SHD.build home">
|
||||
<span class="brand-mark"><i></i><b>SHD</b></span><span class="brand-name">SHD<span>.build</span><small>LOADOUT NETWORK</small></span>
|
||||
@@ -51,8 +77,32 @@
|
||||
{:else}
|
||||
<a class="sign-in" href="/sign-in">SIGN IN →</a>
|
||||
{/if}
|
||||
<button class="mobile-nav-toggle" type="button" aria-label={mobileOpen ? 'Close navigation' : 'Open navigation'} aria-expanded={mobileOpen} aria-controls="mobile-navigation" onclick={mobileOpen ? closeMobileNavigation : openMobileNavigation}><span></span><span></span><span></span><b>{mobileOpen ? 'CLOSE' : 'MENU'}</b></button>
|
||||
</header>
|
||||
|
||||
{#if mobileOpen}
|
||||
<div class="mobile-nav-layer">
|
||||
<button class="mobile-nav-backdrop" type="button" aria-label="Close navigation" onclick={closeMobileNavigation}></button>
|
||||
<nav id="mobile-navigation" class="mobile-navigation" aria-label="Mobile navigation" bind:this={mobilePanel}>
|
||||
<div class="mobile-nav-heading"><div><span>SHD NETWORK</span><strong>NAVIGATION</strong></div><b>ONLINE</b></div>
|
||||
<a class:active={page.url.pathname === '/'} href="/"><small>00</small><span>Home</span><b>›</b></a>
|
||||
{#each links as link, index}
|
||||
<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')} href="/account"><small>04</small><span>My Profile</span><b>›</b></a>
|
||||
{:else}
|
||||
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>04</small><span>Sign In</span><b>›</b></a>
|
||||
{/if}
|
||||
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/admin')} href="/admin"><small>05</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>
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{@render children()}
|
||||
|
||||
<footer class="site-footer">
|
||||
|
||||
@@ -50,12 +50,13 @@
|
||||
<div class="dashboard-table moderation-queue">
|
||||
<div class="dashboard-row moderation-row table-heading"><span>CHANGE</span><span>CONTRIBUTOR</span><span>SUBMITTED</span><span>STATUS</span><span></span></div>
|
||||
{#each data.proposals as proposal}
|
||||
<div class="dashboard-row moderation-row">
|
||||
<div class="dashboard-row moderation-row mobile-detail-row">
|
||||
<div class="change-identity"><span>{proposal.module.slice(0,4).toUpperCase()}</span><div><strong>{proposal.summary}</strong><small>{proposal.module.toUpperCase()} / {operationLabel(proposal.action)}</small></div></div>
|
||||
<div><strong>{proposal.proposer_name}</strong><small>AGENT {String(proposal.proposed_by).slice(0,8).toUpperCase()}</small></div>
|
||||
<span>{date(proposal.created_at)}</span>
|
||||
<span class="proposal-state" class:pending={proposal.status === 'pending'} class:approved={proposal.status === 'approved'} class:declined={proposal.status === 'rejected' || proposal.status === 'failed'}><i></i>{proposal.status.toUpperCase()}</span>
|
||||
{#if proposal.status === 'pending'}<button onclick={() => openReview(proposal)}>REVIEW →</button>{:else}<button onclick={() => openReview(proposal)}>EVIDENCE →</button>{/if}
|
||||
<button class="mobile-row-open" aria-label={`${proposal.status === 'pending' ? 'Review' : 'View evidence for'} ${proposal.summary}`} onclick={() => openReview(proposal)}></button>
|
||||
</div>
|
||||
{:else}<div class="empty-dashboard"><strong>NO {data.status.toUpperCase()} CHANGE REQUESTS</strong><span>Adjust the module or search filters to inspect another part of the queue.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</span><span>ROLES</span><span>LAST LOGIN</span><span>LAST ACTIVE</span><span>CONTENT</span><span></span></div>
|
||||
{#each data.users as user}<div class="dashboard-row user-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><div class="role-summary">{#each String(user.assigned_roles ?? user.role ?? 'User').split(' · ') as role}<b>{role.toUpperCase()}</b>{/each}</div><span><small>{user.last_login_provider?.toUpperCase() ?? 'NONE'}</small>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/admin/users/${user.id}`}>VIEW</a></div>
|
||||
{#each data.users as user}<div class="dashboard-row user-row mobile-detail-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><div class="role-summary">{#each String(user.assigned_roles ?? user.role ?? 'User').split(' · ') as role}<b>{role.toUpperCase()}</b>{/each}</div><span><small>{user.last_login_provider?.toUpperCase() ?? 'NONE'}</small>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/admin/users/${user.id}`}>VIEW</a><a class="mobile-row-open" href={`/admin/users/${user.id}`} aria-label={`View agent ${user.display_name}`}></a></div>
|
||||
{:else}<div class="empty-dashboard"><strong>NO USERS FOUND</strong><span>Adjust the username search and try again.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user