Files
website/src/lib/components/SiteChrome.svelte

763 lines
28 KiB
Svelte

<script lang="ts">
import { onMount, type Component, type Snippet } from 'svelte';
import { resolve } from '$app/paths';
import logo from '#lib/assets/brand/favicon-32x32.png?url';
import { hidesFooter, isTyping } from '#lib/chrome.js';
import { page } from '$app/state';
import CopyButton from '#lib/components/CopyButton.svelte';
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import { projects, visibleTrackers } from '#lib/projects.js';
import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
import * as Sheet from '#lib/components/ui/sheet/index.js';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import ChevronDown from 'lucide-svelte/icons/chevron-down';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import ExternalLink from 'lucide-svelte/icons/external-link';
import Menu from 'lucide-svelte/icons/menu';
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
import Plus from 'lucide-svelte/icons/plus';
import SiOsu from '~icons/simple-icons/osu';
import SiSteam from '~icons/simple-icons/steam';
import SiTwitch from '~icons/simple-icons/twitch';
import SiSpotify from '~icons/simple-icons/spotify';
import SiGithub from '~icons/simple-icons/github';
import SiYoutube from '~icons/simple-icons/youtube';
import SiLinkedin from '~icons/simple-icons/linkedin';
import SiInstagram from '~icons/simple-icons/instagram';
import SiX from '~icons/simple-icons/x';
import SiMail from '~icons/simple-icons/gmail';
import SiDiscord from '~icons/simple-icons/discord';
import SiSignal from '~icons/simple-icons/signal';
import SiWhatsapp from '~icons/simple-icons/whatsapp';
// Site header and footer, wrapped around every page except the full-screen ones
// (see #lib/chrome.ts and src/routes/+layout.ts).
type ChromeData = {
user: { name: string } | null;
isOwner: boolean;
version: string;
};
let { data, children }: { data: ChromeData; children: Snippet } = $props();
const showFooter = $derived(!hidesFooter(page.url.pathname));
let menuOpen = $state(false);
// Which header dropdown is open (desktop): '/projects', '/trackers' or none.
let openMenu = $state<string | null>(null);
type ThemePreference = 'dark' | 'system' | 'light';
let theme = $state<ThemePreference>('dark');
const navigation = [
{ label: 'home', href: '/' },
{ label: 'projects', href: '/projects' },
{ label: 'trackers', href: '/trackers' },
{ label: 'blog', href: '/blog' },
{ label: 'lists', href: '/lists' }
] as const;
type MenuItem = {
name: string;
subtitle: string;
href: string;
external: boolean;
children?: readonly MenuItem[];
};
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
'/projects': { label: 'projects', items: projects },
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) }
});
const socialLinks = [
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
{ icon: SiGithub, label: 'github', href: 'https://github.com/stanrunge' },
{ icon: SiLinkedin, label: 'linkedin', href: 'https://linkedin.com/in/stanrunge' },
{ icon: SiX, label: 'x / twitter', href: 'https://x.com/stanrunge' },
{ icon: SiYoutube, label: 'youtube', href: 'https://youtube.com/@stanrunge' },
{ icon: SiInstagram, label: 'instagram', href: 'https://instagram.com/stan_runge' },
{ icon: SiTwitch, label: 'twitch', href: 'https://twitch.tv/stanrunge_' },
{
icon: SiSpotify,
label: 'spotify',
href: 'https://open.spotify.com/user/e3o3qr9z7fpsu4gur9zz4y1ld'
},
{ icon: SiSteam, label: 'steam', href: 'https://steamcommunity.com/id/stanrunge' },
{ icon: SiOsu, label: 'osu!', href: 'https://osu.ppy.sh/users/11212255' }
];
const copyLinks = [
{ icon: SiWhatsapp, label: 'WhatsApp', value: 'stanrunge' },
{ icon: SiDiscord, label: 'Discord', value: 'stanrunge' },
{ icon: SiSignal, label: 'Signal', value: 'stan.443' }
];
onMount(() => {
const preference = document.documentElement.dataset.themePreference;
theme = preference === 'light' || preference === 'system' ? preference : 'dark';
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
const onSystemThemeChange = () => {
if (theme === 'system') applyTheme('system');
};
systemTheme.addEventListener('change', onSystemThemeChange);
return () => systemTheme.removeEventListener('change', onSystemThemeChange);
});
function applyTheme(preference: ThemePreference) {
const resolvedTheme =
preference === 'system'
? window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark'
: preference;
document.documentElement.dataset.themePreference = preference;
document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.classList.toggle('dark', resolvedTheme === 'dark');
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', resolvedTheme === 'light' ? '#ffffff' : '#000000');
}
function toggleTheme() {
theme = theme === 'dark' ? 'system' : theme === 'system' ? 'light' : 'dark';
applyTheme(theme);
try {
localStorage.setItem('theme', theme);
} catch {
// The selected theme still applies for this visit.
}
}
function current(href: string) {
return page.url.pathname === href || (href !== '/' && page.url.pathname.startsWith(href + '/'));
}
// Owner shortcuts (outside text fields): g then d opens the /start dashboard, g then t
// opens quick add task.
let gPressedAt = 0;
function handleShortcut(event: KeyboardEvent) {
if (!data.isOwner || event.defaultPrevented || event.repeat) return;
if (event.ctrlKey || event.metaKey || event.altKey || isTyping(event.target)) return;
const key = event.key.toLowerCase();
if (key === 'g') {
gPressedAt = event.timeStamp;
} else if (key === 't' && gPressedAt && event.timeStamp - gPressedAt < 1500) {
gPressedAt = 0;
event.preventDefault();
openQuickAdd();
} else if (key === 'd' && gPressedAt && event.timeStamp - gPressedAt < 1500) {
gPressedAt = 0;
event.preventDefault();
// Imported on use: $app/navigation is a ~47 KB chunk the public pages don't need.
import('$app/navigation').then(({ goto }) => goto(resolve('/start')));
} else {
gPressedAt = 0;
}
}
// Quick add task: the modal's code is fetched on first open only.
let QuickAdd = $state<Component<{ onclose: () => void; ondone: (m: string) => void }> | null>(
null
);
let quickAddOpen = $state(false);
let toast = $state('');
let toastTimer: ReturnType<typeof setTimeout> | undefined;
async function openQuickAdd() {
if (!data.isOwner) return;
menuOpen = false;
QuickAdd ??= (await import('#lib/components/QuickAddTask.svelte')).default;
quickAddOpen = true;
}
function showToast(message: string) {
toast = message;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast = ''), 4000);
}
function handleMenuPointerEnter(event: PointerEvent, href: string) {
if (event.pointerType === 'touch') return;
openMenu = href;
}
function handleMenuPointerLeave(event: PointerEvent) {
if (event.pointerType === 'touch') return;
openMenu = null;
flyout = null;
}
// Group trackers (spotting) open a flyout with their sub-trackers next to their tile in the
// Trackers dropdown. It opens on hover or keyboard focus, on the outer side of the dropdown
// when there is room, and closes after a short delay so a diagonal mouse move from the tile
// into the flyout (crossing a neighbouring tile) does not close it.
let flyout = $state<string | null>(null);
let flyoutSide = $state<'left' | 'right'>('right');
let flyoutUp = $state(false);
let flyoutTimer: ReturnType<typeof setTimeout> | undefined;
// Big groups (games) get a two-column flyout; it opens upwards when it would not fit below.
const WIDE_FLYOUT = 6;
function openFlyout(name: string, el: HTMLElement, count: number) {
clearTimeout(flyoutTimer);
if (flyout === name) return;
const wide = count > WIDE_FLYOUT;
const width = wide ? 432 : 240;
const height = (wide ? Math.ceil(count / 2) : count) * 46 + 20;
const rect = el.getBoundingClientRect();
const panel = el.closest('[data-menu-panel]')?.getBoundingClientRect() ?? rect;
const leftColumn = rect.left + rect.width / 2 < panel.left + panel.width / 2;
const roomLeft = rect.left - width >= 8;
const roomRight = rect.right + width <= window.innerWidth - 8;
flyoutUp = rect.top + height > window.innerHeight - 8 && rect.bottom - height >= 8;
flyoutSide = leftColumn ? (roomLeft ? 'left' : 'right') : roomRight ? 'right' : 'left';
flyout = name;
}
function closeFlyoutOnEscape(event: KeyboardEvent) {
if (event.key !== 'Escape' || !flyout) return;
event.stopPropagation();
flyout = null;
}
function closeFlyoutSoon() {
clearTimeout(flyoutTimer);
flyoutTimer = setTimeout(() => (flyout = null), 300);
}
</script>
<svelte:window onkeydown={handleShortcut} />
{#snippet themeIcon()}
<svg
viewBox="0 0 24 24"
width="22"
height="22"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
aria-hidden="true"
>
<path
d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"
/>
<circle cx="12" cy="12" r="6" fill={theme === 'light' ? 'currentColor' : 'none'} />
{#if theme === 'system'}
<path d="M12 6a6 6 0 0 1 0 12Z" fill="currentColor" stroke="none" />
{/if}
</svg>
{/snippet}
<div class="flex min-h-screen flex-col bg-[var(--site-bg)] text-[var(--site-text)]">
<a
href="#main"
class="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:bg-[var(--site-bg)] focus:px-4 focus:py-2"
>skip to content</a
>
<!--
Stacking: backdrop-blur makes the header its own stacking context, so the dropdown's z-50 only
counts inside it. Without a z-index of its own the header then sits at z-auto/0 and anything
positioned later in <main> (maps, chart tooltips, sticky headers) paints over the dropdown.
Fix: lift the header (relative z-50) and isolate <main> so page z-indexes stay inside it.
Dialogs/sheets/popovers are portalled to <body> and still render above both.
-->
<header class="relative z-50 border-b border-border bg-background/95 backdrop-blur">
<div class="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6">
<a
href={resolve('/')}
class="flex shrink-0 items-center gap-3 font-semibold tracking-tight"
aria-label="Stan Runge home"
>
<span class="h-7 w-7 overflow-hidden rounded-full bg-black"
><img src={logo} alt="" width="32" height="32" class="h-full w-full scale-[1.7]" /></span
>
<span>stan runge</span>
</a>
<nav class="ml-auto hidden items-center gap-1 md:flex" aria-label="Main navigation">
{#each navigation as item (item.href)}
{#if menus[item.href]}
{@const menu = menus[item.href]}
<div
class="relative"
role="group"
aria-label={`${menu.label} menu`}
onpointerenter={(event) => handleMenuPointerEnter(event, item.href)}
onpointerleave={handleMenuPointerLeave}
>
<Button
href={resolve(item.href)}
variant={current(item.href) ? 'secondary' : 'ghost'}
size="sm"
class="cursor-pointer gap-1"
aria-label={`Browse ${menu.label}`}
aria-haspopup="true"
aria-expanded={openMenu === item.href}
onclick={() => (openMenu = null)}
onkeydown={(event) => {
if (event.key === 'Escape') openMenu = null;
}}>{item.label}<ChevronDown size={15} /></Button
>
{#if openMenu === item.href}
<div
class={[
'absolute top-full z-50 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
// No overflow clipping on the trackers panel: its spotting flyout sticks out.
item.href === '/trackers'
? 'left-1/2 grid w-[min(36rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-2 gap-1.5 p-2'
: 'left-0 max-h-[75vh] w-72 overflow-y-auto'
]}
aria-label={menu.label}
data-menu-panel
>
<a
href={resolve(item.href)}
class="flex items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
class:col-span-2={item.href === '/trackers'}
>all {menu.label} <ArrowRight size={14} class="ml-auto" /></a
>
<div class="col-span-2 my-1 h-px bg-border"></div>
{#each menu.items as project (project.name)}
{#if project.children}
<div
class="relative"
class:z-10={flyout === project.name}
role="group"
aria-label={`${project.name} trackers`}
onpointerenter={(event) => {
if (event.pointerType !== 'touch')
openFlyout(
project.name,
event.currentTarget,
project.children?.length ?? 0
);
}}
onpointerleave={(event) => {
if (event.pointerType !== 'touch') closeFlyoutSoon();
}}
onfocusin={(event) =>
openFlyout(
project.name,
event.currentTarget,
project.children?.length ?? 0
)}
onfocusout={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
flyout = null;
}}
>
<a
href={resolve(project.href as '/')}
class="tile flex h-full items-center gap-2.5 p-2 text-sm"
aria-haspopup="true"
onkeydown={closeFlyoutOnEscape}
aria-expanded={flyout === project.name}
><TrackerIcon name={project.name} class="tile-icon size-9" /><span
class="min-w-0"
><span class="block font-medium"
>{project.name}{#if 'locked' in project && project.locked}<PrivateLock
/>{/if}</span
><span class="block truncate text-xs text-muted-foreground"
>{project.subtitle}</span
></span
><ChevronRight
size={14}
class="ml-auto shrink-0 text-muted-foreground transition-transform {flyout ===
project.name
? flyoutSide === 'left'
? 'rotate-180'
: 'translate-x-0.5'
: ''}"
/></a
>
{#if flyout === project.name}
<!-- The padding facing the tile is a hover bridge into the panel. -->
<div
class={[
'absolute',
flyoutUp ? '-bottom-2' : '-top-2',
project.children.length > WIDE_FLYOUT ? 'w-[27rem]' : 'w-60',
flyoutSide === 'right' ? 'left-full pl-3' : 'right-full pr-3'
]}
>
<div
class={[
'flyout grid gap-1 rounded-md border border-border bg-popover p-1.5 shadow-lg',
project.children.length > WIDE_FLYOUT && 'grid-cols-2'
]}
class:from-left={flyoutSide === 'left'}
>
{#each project.children as child (child.name)}
<a
href={resolve(child.href as '/')}
class="tile flex min-w-0 items-center gap-2.5 p-1.5 text-sm"
title={child.subtitle}
aria-current={current(child.href) ? 'page' : undefined}
onkeydown={closeFlyoutOnEscape}
><TrackerIcon
name={child.icon ?? child.name}
class="tile-icon size-8"
/><span class="truncate font-medium">{child.name}</span></a
>
{/each}
</div>
</div>
{/if}
</div>
{:else}
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={project.external ? project.href : resolve(project.href)}
rel={project.external ? 'external' : undefined}
class={item.href === '/trackers'
? 'tile flex items-center gap-2.5 p-2 text-sm'
: 'flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent'}
>
{#if item.href === '/trackers'}
<TrackerIcon name={project.name} class="tile-icon size-9" />
{:else}
<span
class="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
>
<ProjectIcon name={project.name} class="size-4" />
</span>
{/if}
<span class="min-w-0"
><span class="block font-medium"
>{project.name}{#if 'locked' in project && project.locked}<PrivateLock
/>{/if}</span
><span class="block truncate text-xs text-muted-foreground"
>{project.subtitle}</span
></span
>
{#if project.external}<ExternalLink
size={14}
class="ml-auto shrink-0"
/>{/if}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/if}
{/each}
</div>
{/if}
</div>
{:else}
<Button
href={resolve(item.href)}
variant={current(item.href) ? 'secondary' : 'ghost'}
size="sm">{item.label}</Button
>
{/if}
{/each}
</nav>
<div class="hidden items-center gap-1 md:flex">
{#if data.isOwner}
<a
href={resolve('/start')}
class="tile flex h-8 items-center gap-1.5 px-2.5 text-sm"
aria-current={current('/start') ? 'page' : undefined}
title="Dashboard (g then d)"
aria-keyshortcuts="g d"
><span class="tile-icon accent-dashboard size-5"
><LayoutDashboard size={13} aria-hidden="true" /></span
>dashboard</a
>
<button
type="button"
class="tile flex size-8 items-center justify-center"
onclick={openQuickAdd}
aria-label="Quick add task"
title="Quick add task (g then t)"
aria-keyshortcuts="g t"
><span class="tile-icon accent-add size-5"><Plus size={14} aria-hidden="true" /></span
></button
>
{/if}
<Button
variant="ghost"
size="icon"
onclick={toggleTheme}
aria-label={`Theme: ${theme}. Switch mode`}
title={`Theme: ${theme}`}>{@render themeIcon()}</Button
>
{#if data.user}
<Button href={resolve('account')} variant="outline" size="sm" class="max-w-36 truncate"
>{data.user.name}</Button
>
{:else}
<Button
href={resolve(
`login?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`
)}
variant="outline"
size="sm">sign in</Button
>
{/if}
</div>
<Sheet.Root bind:open={menuOpen}>
<Sheet.Trigger
class={buttonVariants({ variant: 'ghost', size: 'icon' }) + ' ml-auto md:hidden'}
aria-label="Open navigation"><Menu size={20} /></Sheet.Trigger
>
<Sheet.Content side="right" class="overflow-y-auto p-6">
<Sheet.Header
><Sheet.Title>stan runge</Sheet.Title><Sheet.Description>Navigation</Sheet.Description
></Sheet.Header
>
<nav class="flex flex-col gap-1" aria-label="Mobile navigation">
{#if data.isOwner}
<div class="mb-2 grid grid-cols-2 gap-1.5">
<a
href={resolve('/start')}
class="tile flex items-center gap-2 p-2 text-sm"
aria-current={current('/start') ? 'page' : undefined}
onclick={() => (menuOpen = false)}
><span class="tile-icon accent-dashboard size-7"
><LayoutDashboard size={15} aria-hidden="true" /></span
>dashboard</a
>
<button
type="button"
class="tile flex items-center gap-2 p-2 text-left text-sm"
onclick={openQuickAdd}
><span class="tile-icon accent-add size-7"
><Plus size={16} aria-hidden="true" /></span
>add task</button
>
</div>
{/if}
{#each navigation as item (item.href)}
<Button
href={resolve(item.href)}
variant={current(item.href) ? 'secondary' : 'ghost'}
class="justify-start"
onclick={() => (menuOpen = false)}>{item.label}</Button
>
{#if menus[item.href]}
{#if item.href === '/trackers'}
<div class="ml-3 grid grid-cols-2 gap-1.5 border-l border-border py-1 pl-3">
{#each menus[item.href].items as project (project.name)}
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={project.external ? project.href : resolve(project.href)}
class="tile flex min-w-0 items-center gap-2 p-1.5 text-sm"
onclick={() => (menuOpen = false)}
><TrackerIcon name={project.name} class="tile-icon size-7" /><span
class="truncate">{project.name}</span
>{#if 'locked' in project && project.locked}<PrivateLock tight />{/if}</a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/each}
{#each menus[item.href].items.filter((p) => p.children) as group (group.name)}
<div class="col-span-2 mt-1">
<span class="mb-1 block text-xs text-muted-foreground">{group.name}</span>
<div class="ml-1 grid grid-cols-2 gap-1.5 border-l border-border pl-2">
{#each group.children ?? [] as child (child.name)}
<a
href={resolve(child.href as '/')}
class="tile flex min-w-0 items-center gap-2 p-1.5 text-sm"
onclick={() => (menuOpen = false)}
><TrackerIcon
name={child.icon ?? child.name}
class="tile-icon size-6"
/><span class="truncate">{child.name}</span></a
>
{/each}
</div>
</div>
{/each}
</div>
{:else}
<div class="ml-3 flex flex-col border-l border-border pl-3">
{#each menus[item.href].items as project (project.name)}
<Button
href={project.external ? project.href : resolve(project.href)}
variant="ghost"
size="sm"
class="justify-start"
onclick={() => (menuOpen = false)}>{project.name}</Button
>
{/each}
</div>
{/if}
{/if}
{/each}
</nav>
<Sheet.Footer
class="mt-auto flex-row items-center justify-between border-t border-border pt-4"
>
<Button
variant="ghost"
size="icon"
onclick={toggleTheme}
aria-label={`Theme: ${theme}. Switch mode`}>{@render themeIcon()}</Button
>
{#if data.user}
<Button href={resolve('account')} variant="outline" onclick={() => (menuOpen = false)}
>account</Button
>
<form method="POST" action="/logout">
<Button type="submit" variant="outline">sign out</Button>
</form>
{:else}
<Button href={resolve('login')} variant="outline" onclick={() => (menuOpen = false)}
>sign in</Button
>
<Button
href={resolve('register')}
variant="outline"
onclick={() => (menuOpen = false)}>register</Button
>
{/if}
</Sheet.Footer>
</Sheet.Content>
</Sheet.Root>
</div>
</header>
<main id="main" class="isolate min-w-0 flex-1">{@render children()}</main>
{#if showFooter}
<footer
class="mt-16 border-t border-[var(--site-border)]"
class:tracker-footer={page.route.id === '/projects/game-tracker'}
class:cod-footer={page.route.id === '/projects/game-tracker/call-of-duty/[slug]'}
>
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
<div class="flex flex-wrap items-center justify-between gap-3 text-base">
<span
>stan runge <span class="ml-2 font-mono text-xs" title="Deployed commit"
>{data.version.slice(0, 7)}</span
></span
>
<a
href="https://git.stanrunge.dev/stan/website"
rel="external"
class="inline-flex items-center gap-1 hover:text-[var(--site-text)]"
>source <ExternalLink size={14} aria-hidden="true" /></a
>
</div>
<nav class="flex flex-wrap items-center justify-center gap-1" aria-label="Social links">
{#each socialLinks as { icon: Icon, label, href } (label)}
<Button
{href}
variant="ghost"
size="icon-lg"
rel="external"
aria-label={label}
title={label}><Icon class="size-6" /></Button
>
{/each}
{#each copyLinks as item (item.label)}<CopyButton {...item} />{/each}
</nav>
</div>
</footer>
{/if}
</div>
{#if QuickAdd && quickAddOpen}
<QuickAdd onclose={() => (quickAddOpen = false)} ondone={showToast} />
{/if}
{#if toast}
<div
role="status"
class="fixed bottom-4 left-1/2 z-[60] -translate-x-1/2 rounded-md border border-border bg-popover px-4 py-2 text-sm text-popover-foreground shadow-lg"
>
{toast}
</div>
{/if}
<style>
/* Header tiles: monochrome surface (border, hover tint, lift), colourful content.
Used by the Trackers dropdown, the dashboard and quick add buttons. */
.tile {
position: relative;
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
border-radius: 0.6rem;
background: color-mix(in oklab, var(--foreground) 2%, transparent);
color: var(--foreground);
cursor: pointer;
outline: none;
transition:
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
}
.tile:hover {
background: color-mix(in oklab, var(--foreground) 6%, transparent);
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
transform: translateY(-1px);
box-shadow: 0 6px 16px -10px rgb(0 0 0 / 0.45);
}
.tile:active {
transform: translateY(0);
box-shadow: none;
}
.tile:focus-visible {
outline: 2px solid var(--foreground);
outline-offset: 2px;
}
.tile[aria-current='page'] {
background: color-mix(in oklab, var(--foreground) 9%, transparent);
border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
}
.flyout {
animation: flyout-in 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
--flyout-from: -4px;
}
.flyout.from-left {
--flyout-from: 4px;
}
@keyframes flyout-in {
/* Slide only: an opacity fade here left the flyout half see-through after hovering. */
from {
transform: translateX(var(--flyout-from));
}
}
.tile :global(.tile-icon) {
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tile:hover :global(.tile-icon) {
transform: scale(1.06);
}
.accent-dashboard,
.accent-add {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 0.35rem;
color: #fff;
}
.accent-dashboard {
background-image: linear-gradient(135deg, #6366f1, #8b5cf6);
}
.accent-add {
background-image: linear-gradient(135deg, #10b981, #0d9488);
}
@media (prefers-reduced-motion: reduce) {
.flyout {
animation: none;
}
.tile,
.tile :global(.tile-icon) {
transition:
background-color 160ms ease,
border-color 160ms ease;
}
.tile:hover,
.tile:hover :global(.tile-icon) {
transform: none;
}
}
@media (min-width: 1100px) and (min-height: 700px) {
.cod-footer {
display: none;
}
}
@media (min-width: 1024px) and (min-height: 650px) {
.tracker-footer {
margin-top: 0;
}
.tracker-footer > div {
padding-top: 10px;
padding-bottom: 10px;
}
.tracker-footer nav {
display: none;
}
.tracker-footer > div > div {
font-size: 12px;
}
}
</style>