Files
minstrel/web/src/lib/components/Shell.svelte
T
bvandeusenandClaude Opus 5 e1e591b520
test-web / test (push) Successful in 45s
android / Build + lint + test (push) Successful in 4m14s
feat(brand): Minstrel mark — favicon, header lockup, Android adaptive icon
A Didone M whose right leg is an eighth note: stem, flag and notehead in the
accent, the letter in parchment. Traced from the operator's reference at
99.74% IoU (potrace, 26 + 22 segments), so the geometry is theirs, not an
approximation of it.

Subject-neutral on purpose. "Minstrel" pulls toward a lute or a bard, which
would tell a new user this is a renaissance-faire player rather than one for
all music. A geometric letter plus universal notation says "music" without
saying which music. The family look arrives through palette and drawing
style instead of through the subject — see the design-system discussion.

Starting state: web/static/favicon.png was a 1x1 PIXEL placeholder, so there
was effectively no favicon at all; Android had legacy bitmaps only, so modern
launchers letterboxed the square instead of masking it.

## The colour problem, and why each surface differs

Parchment on white is invisible — the operator caught this. The M therefore
has to flip with its background, while the accent note holds in both:

  - mark.svg / MinstrelMark.svelte use currentColor, so the letter takes the
    surrounding text colour and one asset covers both palettes.
  - favicon.svg bakes colours with a prefers-color-scheme swap, because a
    favicon sits on browser chrome and has no cascade to inherit from.
  - PNG fallback, apple-touch-icon and Android are PLATED. A PNG can't
    respond to scheme and iOS composites onto white regardless.

MinstrelMark is inlined rather than <img src>, because an <img> cannot
inherit currentColor and inheriting it is the entire point.

## Plate colour chosen by measurement

Obsidian (#14171A), not the raised-surface iron. The accent note only clears
the 3:1 non-text contrast threshold against the darker value: 3.04:1 vs iron's
2.70:1. My own earlier suggestion — lighten the plate — is WRONG and the
numbers say so: slate scores 2.21:1, worse, because the note is a dark colour
and lifting the plate closes the gap. Recorded in colors.xml so the reasoning
sits with the value.

## Construction

Traced as a full ink silhouette with the note painted OVER it, rather than as
two separate shapes. Separate shapes needed either a 2px seam where letter and
note touch, or an anti-aliasing fringe (2,430 misclassified pixels) around the
note. Painting over avoids both and yields a monochrome version for free — the
base layer alone is the whole mark in one colour, which is what
mipmap-anydpi-v26's <monochrome> uses for themed icons.

Android foreground sits at 61% of the 108dp canvas so it stays inside the
66dp safe zone and no launcher mask can clip it.

Paths are duplicated between the component and the two static SVGs, since one
needs currentColor and the others need literals. A comment in each names the
others.

Verified by render at 16/20/32/64/180 on obsidian, white, parchment and
plated; one optical size holds across the whole range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:37:25 -04:00

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={20} 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>