header: trackers menu as a 2-column grid (desktop and mobile) with a coloured inline-SVG tile per tracker
All checks were successful
Deploy production / deploy (push) Successful in 2m41s
All checks were successful
Deploy production / deploy (push) Successful in 2m41s
TrackerIcon + #lib/tracker-icons: one gradient rounded tile and a few stroke paths per tracker (tasks, money, books, cars, music, health, groceries, houses, energy, games, skins, pets, lists), distinct colours, readable in light and dark. No icon library. Test checks every tracker has a unique tile and tasks/money/books stay first.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
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 { 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';
|
||||
@@ -219,15 +220,21 @@
|
||||
>
|
||||
{#if openMenu === item.href}
|
||||
<div
|
||||
class="absolute top-full left-0 z-50 max-h-[70vh] w-72 overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
class={[
|
||||
'absolute top-full z-50 max-h-[75vh] overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
|
||||
item.href === '/trackers'
|
||||
? 'left-1/2 grid w-[min(36rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-2 gap-x-1'
|
||||
: 'left-0 w-72'
|
||||
]}
|
||||
aria-label={menu.label}
|
||||
>
|
||||
<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="my-1 h-px bg-border"></div>
|
||||
<div class="col-span-2 my-1 h-px bg-border"></div>
|
||||
{#each menu.items as project (project.name)}
|
||||
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
@@ -236,11 +243,15 @@
|
||||
rel={project.external ? 'external' : undefined}
|
||||
class="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"
|
||||
>
|
||||
<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 item.href === '/trackers'}
|
||||
<TrackerIcon name={project.name} class="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}</span><span
|
||||
class="block truncate text-xs text-muted-foreground"
|
||||
@@ -324,17 +335,34 @@
|
||||
onclick={() => (menuOpen = false)}>{item.label}</Button
|
||||
>
|
||||
{#if menus[item.href]}
|
||||
<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 item.href === '/trackers'}
|
||||
<div class="ml-3 grid grid-cols-2 gap-1 border-l border-border 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="flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => (menuOpen = false)}
|
||||
><TrackerIcon name={project.name} class="size-7" /><span class="truncate"
|
||||
>{project.name}</span
|
||||
></a
|
||||
>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
{/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>
|
||||
|
||||
27
src/lib/components/TrackerIcon.svelte
Normal file
27
src/lib/components/TrackerIcon.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { defaultTrackerIcon, trackerIcons } from '#lib/tracker-icons.js';
|
||||
|
||||
// A coloured rounded tile with a white (or dark, on light tiles) inline-SVG icon.
|
||||
// Same in light and dark themes: the tile carries its own background.
|
||||
let { name, class: className = 'size-8' }: { name: string; class?: string } = $props();
|
||||
const icon = $derived(trackerIcons[name] ?? defaultTrackerIcon);
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="flex shrink-0 items-center justify-center rounded-lg shadow-sm ring-1 ring-black/10 ring-inset {className}"
|
||||
style:background-image="linear-gradient(135deg, {icon.from}, {icon.to})"
|
||||
style:color={icon.ink}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="size-[58%]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
{#each icon.paths as d, i (i)}<path {d} />{/each}
|
||||
</svg>
|
||||
</span>
|
||||
20
src/lib/tracker-icons.test.ts
Normal file
20
src/lib/tracker-icons.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { trackerIcons } from './tracker-icons';
|
||||
import { visibleTrackers } from './projects';
|
||||
|
||||
describe('tracker icons', () => {
|
||||
it('gives every tracker its own colour', () => {
|
||||
const names = visibleTrackers(true).map((t) => t.name);
|
||||
for (const name of names) expect(trackerIcons[name], name).toBeDefined();
|
||||
const colours = names.map((n) => trackerIcons[n].to);
|
||||
expect(new Set(colours).size).toBe(colours.length);
|
||||
});
|
||||
|
||||
it('keeps tasks, money and books first', () => {
|
||||
expect(
|
||||
visibleTrackers(true)
|
||||
.slice(0, 3)
|
||||
.map((t) => t.name)
|
||||
).toEqual(['tasks', 'money', 'books']);
|
||||
});
|
||||
});
|
||||
125
src/lib/tracker-icons.ts
Normal file
125
src/lib/tracker-icons.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// Colourful tracker tiles for the header menu: a gradient rounded tile per tracker with a
|
||||
// small inline SVG icon (24×24, stroke paths). No icon library: each icon is a few path
|
||||
// strings, so the whole set is a couple of KB.
|
||||
|
||||
/** A circle as a path, so every icon is just a list of `d` strings. */
|
||||
const c = (cx: number, cy: number, r: number) =>
|
||||
`M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;
|
||||
|
||||
export type TrackerIcon = {
|
||||
/** Gradient start and end (top-left to bottom-right). */
|
||||
from: string;
|
||||
to: string;
|
||||
/** Icon colour; white unless the tile is light (yellow, lime). */
|
||||
ink: string;
|
||||
paths: string[];
|
||||
};
|
||||
|
||||
const WHITE = '#ffffff';
|
||||
|
||||
export const trackerIcons: Record<string, TrackerIcon> = {
|
||||
tasks: {
|
||||
from: '#60a5fa',
|
||||
to: '#2563eb',
|
||||
ink: WHITE,
|
||||
paths: [c(12, 12, 9), 'm8.5 12 2.5 2.5 4.5-5']
|
||||
},
|
||||
money: {
|
||||
from: '#34d399',
|
||||
to: '#059669',
|
||||
ink: WHITE,
|
||||
paths: [c(12, 12, 9), 'M15.5 8.6a4 4 0 1 0 0 6.8', 'M7 10.8h6M7 13.2h6']
|
||||
},
|
||||
books: {
|
||||
from: '#2dd4bf',
|
||||
to: '#0d9488',
|
||||
ink: WHITE,
|
||||
paths: [
|
||||
'M12 7v14',
|
||||
'M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z'
|
||||
]
|
||||
},
|
||||
cars: {
|
||||
from: '#f87171',
|
||||
to: '#dc2626',
|
||||
ink: WHITE,
|
||||
paths: ['M3 16v-3.5L5.5 7h13l2.5 5.5V16', 'M3 12.5h18', c(7.5, 16.5, 2), c(16.5, 16.5, 2)]
|
||||
},
|
||||
music: {
|
||||
from: '#a78bfa',
|
||||
to: '#7c3aed',
|
||||
ink: WHITE,
|
||||
paths: ['M9 18V5l12-2v13', c(6, 18, 3), c(18, 16, 3)]
|
||||
},
|
||||
health: {
|
||||
from: '#f472b6',
|
||||
to: '#db2777',
|
||||
ink: WHITE,
|
||||
paths: [
|
||||
'M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7Z',
|
||||
'M3.2 12h6.3l.5-1 2 4.5 2-7 1.5 3.5h5.3'
|
||||
]
|
||||
},
|
||||
groceries: {
|
||||
from: '#bef264',
|
||||
to: '#65a30d',
|
||||
ink: '#1a2e05',
|
||||
paths: [
|
||||
c(8, 20.5, 1.2),
|
||||
c(18.5, 20.5, 1.2),
|
||||
'M2 2.5h2.2l2.6 12a2 2 0 0 0 2 1.5h9.5a2 2 0 0 0 2-1.5L22 7H5.2'
|
||||
]
|
||||
},
|
||||
houses: {
|
||||
from: '#fb923c',
|
||||
to: '#ea580c',
|
||||
ink: WHITE,
|
||||
paths: ['M3 10.5 12 3l9 7.5', 'M5 9.5V20h14V9.5', 'M10 20v-6h4v6']
|
||||
},
|
||||
energy: {
|
||||
from: '#fde047',
|
||||
to: '#eab308',
|
||||
ink: '#422006',
|
||||
paths: ['M13 2 3 14h9l-1 8 10-12h-9l1-8z']
|
||||
},
|
||||
games: {
|
||||
from: '#818cf8',
|
||||
to: '#4338ca',
|
||||
ink: WHITE,
|
||||
paths: [
|
||||
'M6 11h4M8 9v4M15 12h.01M18 10h.01',
|
||||
'M17.3 5H6.7a4 4 0 0 0-4 3.6C2.6 9.4 2 14.5 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.4-1.4A2 2 0 0 1 9.8 16h4.4a2 2 0 0 1 1.4.6L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.5-.6-6.6-.7-7.3A4 4 0 0 0 17.3 5z'
|
||||
]
|
||||
},
|
||||
skins: {
|
||||
from: '#e879f9',
|
||||
to: '#a21caf',
|
||||
ink: WHITE,
|
||||
paths: [c(12, 12, 8), c(12, 12, 2.5), 'M22 12h-4M6 12H2M12 6V2M12 22v-4']
|
||||
},
|
||||
pets: {
|
||||
from: '#d6a46a',
|
||||
to: '#92602a',
|
||||
ink: WHITE,
|
||||
paths: [
|
||||
c(11, 4, 2),
|
||||
c(18, 8, 2),
|
||||
c(20, 16, 2),
|
||||
'M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.8 1C6.5 17.5 4.5 17 4.5 17A3.5 3.5 0 0 1 5.5 10Z'
|
||||
]
|
||||
},
|
||||
lists: {
|
||||
from: '#94a3b8',
|
||||
to: '#475569',
|
||||
ink: WHITE,
|
||||
paths: ['M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01']
|
||||
}
|
||||
};
|
||||
|
||||
/** Neutral fallback for a tracker without its own tile yet. */
|
||||
export const defaultTrackerIcon: TrackerIcon = {
|
||||
from: '#a1a1aa',
|
||||
to: '#52525b',
|
||||
ink: WHITE,
|
||||
paths: ['M4 20V10M10 20V4M16 20v-7M22 20H2']
|
||||
};
|
||||
Reference in New Issue
Block a user