The mark is now a feathered hat with an arc of eighth notes, traced from the operator's reference artwork at 99.74% IoU. The hat takes the text colour and the note arc holds the accent — the same construction the M used, and for the same reason: parchment on a light surface is invisible, so the silhouette has to flip with its background while the accent stays constant. This reverses the subject-neutrality argument recorded in Minstrel's design system, which held that depicting a bard would tell a new user the app is for renaissance-faire music and had twice rejected a hat. The operator commissioned this artwork and chose it with that objection on the table; the record is updated rather than silently contradicted. Both accent-filled alternatives were measured and rejected: #4A6B5C is 3.04:1 on obsidian and 2.80:1 on the raised iron, so an accent hat drops under the 3:1 graphics floor as soon as it sits on a card. tools/gen-brand-assets.py is the single source for the four copies, which cannot share a file because each needs a different colour mechanism — currentColor inlined, a prefers-color-scheme swap in the favicon, literal fills in mark.svg, flat pixels in the rasters. Hand-copying 20KB of path data four ways is how a silhouette change lands in three of them. Two notes on the trace, both non-obvious: it runs on the original antialiased greyscale rather than a binary mask, because tracing a supersampled mask scores ~100% IoU by reproducing the pixel staircase exactly — a perfect number for jagged art at 120KB of path, versus 99.74% at 20KB. And potrace reads PBM where bit 1 is black, so the ink mask is inverted going in; backwards, it traces the background and still emits a plausible-looking SVG. The header lockup moves 20px → 28px: the hat carries far more detail than the M and does not resolve below ~32px. The 16px browser-tab favicon is still a blob at that size and is not addressed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
173 lines
6.7 KiB
Svelte
173 lines
6.7 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/state';
|
|
import { goto } from '$app/navigation';
|
|
import { ChevronDown, Compass, House, LibraryBig } from 'lucide-svelte';
|
|
import { user, logout } from '$lib/auth/store.svelte';
|
|
import { player } from '$lib/player/store.svelte';
|
|
import { appName } from '$lib/branding';
|
|
import MinstrelMark from './MinstrelMark.svelte';
|
|
import PlayerBar from './PlayerBar.svelte';
|
|
import SearchInput from './SearchInput.svelte';
|
|
|
|
let { children } = $props<{ children: import('svelte').Snippet }>();
|
|
|
|
let menuOpen = $state(false);
|
|
let menuRef: HTMLElement | undefined = $state();
|
|
let menuButtonRef: HTMLElement | undefined = $state();
|
|
|
|
// Close the dropdown when the click was outside both the button and the menu.
|
|
// The previous implementation used onclick={(e) => e.stopPropagation()} on the
|
|
// menu container, which kept the window-level close-on-outside-click handler
|
|
// from firing — but it also blocked SvelteKit's document-level link-click
|
|
// interceptor, so clicking Admin/Settings inside the dropdown fell through
|
|
// to the browser's native navigation (a full-page reload). On a hard reload,
|
|
// /admin/+layout.ts's load function ran before bootstrap() settled the user
|
|
// store, so the admin guard saw user.value === null and redirected to /.
|
|
function handleWindowClick(e: MouseEvent) {
|
|
if (!menuOpen) return;
|
|
const target = e.target as Node | null;
|
|
if (!target) return;
|
|
if (menuRef?.contains(target) || menuButtonRef?.contains(target)) return;
|
|
menuOpen = false;
|
|
}
|
|
|
|
async function handleLogout() {
|
|
menuOpen = false;
|
|
await logout();
|
|
goto('/login', { replaceState: true });
|
|
}
|
|
|
|
function isActive(prefix: string): boolean {
|
|
if (prefix === '/') return page.url.pathname === '/';
|
|
return page.url.pathname.startsWith(prefix);
|
|
}
|
|
|
|
// Primary nav. Sits centered in the top bar (replaces the prior left
|
|
// sidebar). Mirrors the Android top-app-bar treatment: three first-class
|
|
// destinations, plus Library acting as a parent for Artists / Albums /
|
|
// Liked / History / Playlists (tabs at /library/+layout.svelte).
|
|
// Search + user menu live on the right side of the header (unchanged).
|
|
// Library targets the Artists tab directly so SPA navigation doesn't
|
|
// bounce through the /library 308-redirect. isActive('/library')
|
|
// still matches all sub-routes by prefix so the active state covers
|
|
// every tab.
|
|
const navItems = [
|
|
{ href: '/', label: 'Home', icon: House, matchPrefix: '/' },
|
|
{ href: '/library/artists', label: 'Library', icon: LibraryBig, matchPrefix: '/library' },
|
|
{ href: '/discover', label: 'Discover', icon: Compass, matchPrefix: '/discover' }
|
|
];
|
|
</script>
|
|
|
|
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
|
|
|
|
<div class="grid h-screen grid-rows-[auto_1fr_auto] bg-background text-text-primary">
|
|
<!-- 3-col grid so the middle column (nav) is centered on the page
|
|
regardless of how wide the side columns are. With the prior
|
|
flex+justify-center layout the nav's "available space" shifted
|
|
whenever search or the user menu grew, so the nav drifted off
|
|
window-center. Grid pins each column to a fixed lane. -->
|
|
<header class="grid grid-cols-3 items-center border-b border-border bg-surface px-3 md:px-4 py-2 gap-3 md:gap-6">
|
|
<a
|
|
href="/"
|
|
class="flex items-center gap-2 font-semibold text-sm md:text-base whitespace-nowrap justify-self-start"
|
|
>
|
|
<MinstrelMark size={28} class="shrink-0" />
|
|
{appName()}
|
|
</a>
|
|
|
|
<nav aria-label="Primary" class="flex items-center justify-center gap-1 md:gap-2 justify-self-center">
|
|
{#each navItems as item}
|
|
{@const active = isActive(item.matchPrefix)}
|
|
<a
|
|
href={item.href}
|
|
class="group flex items-center gap-2 rounded px-2 md:px-3 py-2 min-h-[44px]
|
|
text-text-secondary hover:text-text-primary hover:bg-surface-hover
|
|
focus-visible:ring-2 focus-visible:ring-accent
|
|
{active ? 'text-text-primary bg-surface-hover' : ''}"
|
|
aria-current={active ? 'page' : undefined}
|
|
>
|
|
<item.icon size={18} strokeWidth={1.75} aria-hidden="true" />
|
|
<span class="hidden sm:inline">{item.label}</span>
|
|
</a>
|
|
{/each}
|
|
</nav>
|
|
|
|
<div class="flex items-center gap-3 md:gap-6 justify-self-end min-w-0">
|
|
<div class="hidden md:block min-w-0 max-w-xs flex-shrink">
|
|
<SearchInput />
|
|
</div>
|
|
|
|
<div class="relative flex-shrink-0">
|
|
<button
|
|
bind:this={menuButtonRef}
|
|
type="button"
|
|
class="flex items-center gap-1.5 rounded px-2 md:px-3 py-1 hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent min-h-[44px]"
|
|
aria-haspopup="menu"
|
|
aria-expanded={menuOpen}
|
|
onclick={() => (menuOpen = !menuOpen)}
|
|
>
|
|
<span class="hidden sm:inline max-w-[8ch] truncate">{user.value?.username ?? ''}</span>
|
|
<ChevronDown
|
|
size={16}
|
|
strokeWidth={1}
|
|
class="transition-transform {menuOpen ? 'rotate-180' : ''}"
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
{#if menuOpen}
|
|
<div
|
|
bind:this={menuRef}
|
|
class="absolute right-0 z-50 mt-1 w-40 rounded border border-border bg-surface shadow"
|
|
role="menu"
|
|
tabindex="-1"
|
|
>
|
|
<a
|
|
href="/settings"
|
|
role="menuitem"
|
|
class="block px-3 py-2 text-sm hover:bg-surface-hover"
|
|
onclick={() => (menuOpen = false)}
|
|
>
|
|
Settings
|
|
</a>
|
|
{#if user.value?.is_admin}
|
|
<a
|
|
href="/admin"
|
|
role="menuitem"
|
|
class="block px-3 py-2 text-sm hover:bg-surface-hover"
|
|
onclick={() => (menuOpen = false)}
|
|
>
|
|
Admin
|
|
</a>
|
|
{/if}
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
class="block w-full border-t border-border px-3 py-2 text-left text-sm hover:bg-surface-hover"
|
|
onclick={handleLogout}
|
|
>
|
|
Log out
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Subtle 1200x600 radial highlight at the top of the scroll
|
|
area fades the slate tone (fs-iron with alpha) into the
|
|
background. Adds depth so the page doesn't read as a flat
|
|
slab. color-mix lets the gradient theme-track in case the
|
|
palette token ever shifts. -->
|
|
<main
|
|
class="overflow-y-auto p-4"
|
|
style="background-image: radial-gradient(ellipse 1200px 600px at 50% -100px,
|
|
color-mix(in srgb, var(--fs-iron) 60%, transparent), transparent 70%);"
|
|
>
|
|
{@render children?.()}
|
|
</main>
|
|
|
|
{#if player.current}
|
|
<PlayerBar />
|
|
{/if}
|
|
</div>
|