Compare commits

...

10 Commits

Author SHA1 Message Date
Ilia Mashkov
6f65aa207e fix: stories errors
All checks were successful
Workflow / build (pull_request) Successful in 3m22s
Workflow / publish (pull_request) Has been skipped
2026-03-02 22:45:29 +03:00
Ilia Mashkov
87bba388dc chore(app): update config, dependencies, storybook, and app shell 2026-03-02 22:21:19 +03:00
Ilia Mashkov
55e2efc222 refactor(features, widgets): update ThemeManager, FontSampler, FontSearch, and SampleList 2026-03-02 22:20:48 +03:00
Ilia Mashkov
0fa3437661 refactor(SetupFont): reorganize TypographyMenu and add control tests 2026-03-02 22:20:29 +03:00
Ilia Mashkov
efe1b4f9df refactor(GetFonts): restructure filter API and add sort store 2026-03-02 22:19:59 +03:00
Ilia Mashkov
0dd08874bc refactor(ui): update shared components and add ControlGroup, SidebarContainer 2026-03-02 22:19:35 +03:00
Ilia Mashkov
13818d5844 refactor(shared): update utilities, API layer, and types 2026-03-02 22:19:13 +03:00
Ilia Mashkov
ac73fd5044 refactor(helpers): modernize reactive helpers and add tests 2026-03-02 22:18:59 +03:00
Ilia Mashkov
594af924c7 refactor(Breadcrumb): simplify entity structure and add tests 2026-03-02 22:18:41 +03:00
Ilia Mashkov
af4137f47f refactor(Font): consolidate API layer and update type structure 2026-03-02 22:18:21 +03:00
131 changed files with 7815 additions and 2107 deletions

View File

@@ -2,9 +2,15 @@
Component: ThemeDecorator
Storybook decorator that initializes ThemeManager for theme-related stories.
Ensures theme management works correctly in Storybook's iframe environment.
Includes a floating theme toggle for universal theme switching across all stories.
-->
<script lang="ts">
import { themeManager } from '$features/ChangeAppTheme';
import type { ResponsiveManager } from '$shared/lib';
import { IconButton } from '$shared/ui';
import MoonIcon from '@lucide/svelte/icons/moon';
import SunIcon from '@lucide/svelte/icons/sun';
import { getContext } from 'svelte';
import {
onDestroy,
onMount,
@@ -16,15 +22,58 @@ interface Props {
let { children }: Props = $props();
// Get responsive context (set by Decorator)
const responsive = getContext<ResponsiveManager>('responsive');
// Initialize themeManager on mount
onMount(() => {
themeManager.init();
// Add keyboard shortcut for theme toggle (Cmd/Ctrl+Shift+D)
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'D') {
e.preventDefault();
themeManager.toggle();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
});
// Clean up themeManager when story unmounts
onDestroy(() => {
themeManager.destroy();
});
const theme = $derived(themeManager.value);
const themeLabel = $derived(theme === 'light' ? 'Light' : 'Dark');
</script>
<!-- Floating Theme Toggle -->
<div
class="fixed top-4 right-4 z-50 flex items-center gap-2 px-3 py-2 bg-card border border-border shadow-lg rounded-lg"
title="Toggle theme (Cmd/Ctrl+Shift+D)"
>
<span class="text-xs font-medium text-muted-foreground">Theme: {themeLabel}</span>
<IconButton
onclick={() => themeManager.toggle()}
size={responsive?.isMobile ? 'sm' : 'md'}
variant="ghost"
title="Toggle theme"
>
{#snippet icon()}
{#if theme === 'light'}
<MoonIcon class="size-4" />
{:else}
<SunIcon class="size-4" />
{/if}
{/snippet}
</IconButton>
</div>
<!-- Story Content -->
{@render children()}

View File

@@ -1,11 +1,74 @@
import type { Preview } from '@storybook/svelte-vite';
import Decorator from './Decorator.svelte';
import StoryStage from './StoryStage.svelte';
import ThemeDecorator from './ThemeDecorator.svelte';
import '../src/app/styles/app.css';
const preview: Preview = {
globalTypes: {
viewport: {
description: 'Viewport size for responsive design',
defaultValue: 'widgetWide',
toolbar: {
icon: 'view',
items: [
{
value: 'reset',
icon: 'refresh',
title: 'Reset viewport',
},
{
value: 'mobile1',
icon: 'mobile',
title: 'iPhone 5/SE',
},
{
value: 'mobile2',
icon: 'mobile',
title: 'iPhone 14 Pro Max',
},
{
value: 'tablet',
icon: 'tablet',
title: 'iPad (Portrait)',
},
{
value: 'desktop',
icon: 'desktop',
title: 'Desktop (Small)',
},
{
value: 'widgetMedium',
icon: 'view',
title: 'Widget Medium',
},
{
value: 'widgetWide',
icon: 'view',
title: 'Widget Wide',
},
{
value: 'widgetExtraWide',
icon: 'view',
title: 'Widget Extra Wide',
},
{
value: 'fullWidth',
icon: 'view',
title: 'Full Width',
},
{
value: 'fullScreen',
icon: 'expand',
title: 'Full Screen',
},
],
dynamicTitle: true,
},
},
},
parameters: {
layout: 'fullscreen',
layout: 'padded',
controls: {
matchers: {
color: /(background|color)$/i,
@@ -23,7 +86,79 @@ const preview: Preview = {
docs: {
story: {
// This sets the default height for the iframe in Autodocs
iframeHeight: '400px',
iframeHeight: '600px',
},
},
viewport: {
viewports: {
// Mobile devices
mobile1: {
name: 'iPhone 5/SE',
styles: {
width: '320px',
height: '568px',
},
},
mobile2: {
name: 'iPhone 14 Pro Max',
styles: {
width: '414px',
height: '896px',
},
},
// Tablet
tablet: {
name: 'iPad (Portrait)',
styles: {
width: '834px',
height: '1112px',
},
},
desktop: {
name: 'Desktop (Small)',
styles: {
width: '1024px',
height: '1280px',
},
},
// Widget-specific viewports
widgetMedium: {
name: 'Widget Medium',
styles: {
width: '768px',
height: '800px',
},
},
widgetWide: {
name: 'Widget Wide',
styles: {
width: '1024px',
height: '800px',
},
},
widgetExtraWide: {
name: 'Widget Extra Wide',
styles: {
width: '1280px',
height: '800px',
},
},
// Full-width viewports
fullWidth: {
name: 'Full Width',
styles: {
width: '100%',
height: '800px',
},
},
fullScreen: {
name: 'Full Screen',
styles: {
width: '100%',
height: '100%',
},
},
},
},
@@ -45,6 +180,13 @@ const preview: Preview = {
},
decorators: [
// Outermost: initialize ThemeManager for all stories
story => ({
Component: ThemeDecorator,
props: {
children: story(),
},
}),
// Wrap with providers (TooltipProvider, ResponsiveManager)
story => ({
Component: Decorator,

View File

@@ -61,7 +61,6 @@
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^7.2.6",
"vitest": "^4.0.16",
"vitest-browser-svelte": "^2.0.1"

View File

@@ -1,3 +1,7 @@
<!--
Component: App
Application root with query provider and layout
-->
<script lang="ts">
/**
* App Component

View File

@@ -11,6 +11,9 @@ import { QueryClientProvider } from '@tanstack/svelte-query';
import type { Snippet } from 'svelte';
interface Props {
/**
* Content snippet
*/
children?: Snippet;
}

View File

@@ -306,81 +306,72 @@
animation: nudge 10s ease-in-out infinite;
}
* {
scrollbar-width: thin;
scrollbar-color: hsl(0 0% 70% / 0.4) transparent;
/* ============================================
SCROLLBAR STYLES
============================================ */
/* ---- Modern API: color + width (Chrome 121+, FF 64+) ---- */
@supports (scrollbar-width: auto) {
* {
scrollbar-width: thin;
scrollbar-color: hsl(0 0% 70% / 0.4) var(--color-surface);
}
.dark * {
scrollbar-color: hsl(0 0% 40% / 0.5) var(--color-surface);
}
}
.dark * {
scrollbar-color: hsl(0 0% 40% / 0.5) transparent;
/* ---- Webkit layer: runs ON TOP in Chrome, standalone in old Safari ---- */
/* Handles things scrollbar-width can't: hiding buttons, exact sizing */
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-button {
display: none; /* kills arrows */
}
::-webkit-scrollbar-track {
background: var(--color-surface);
}
::-webkit-scrollbar-thumb {
background: hsl(0 0% 70% / 0.4);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(0 0% 50% / 0.6);
}
::-webkit-scrollbar-thumb:active {
background: hsl(0 0% 40% / 0.8);
}
::-webkit-scrollbar-corner {
background: var(--color-surface);
}
.dark ::-webkit-scrollbar-thumb { background: hsl(0 0% 40% / 0.5); }
.dark ::-webkit-scrollbar-thumb:hover { background: hsl(0 0% 55% / 0.6); }
.dark ::-webkit-scrollbar-thumb:active { background: hsl(0 0% 65% / 0.7); }
}
/* ---- Webkit / Blink ---- */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(0 0% 70% / 0);
border-radius: 3px;
transition: background 0.2s ease;
}
/* Show thumb when container is hovered or actively scrolling */
:hover > ::-webkit-scrollbar-thumb,
::-webkit-scrollbar-thumb:hover,
*:hover::-webkit-scrollbar-thumb {
background: hsl(0 0% 70% / 0.4);
}
::-webkit-scrollbar-thumb:hover {
background: hsl(0 0% 50% / 0.6);
}
::-webkit-scrollbar-thumb:active {
background: hsl(0 0% 40% / 0.8);
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* Dark mode */
.dark ::-webkit-scrollbar-thumb {
background: hsl(0 0% 40% / 0);
}
.dark :hover > ::-webkit-scrollbar-thumb,
.dark ::-webkit-scrollbar-thumb:hover,
.dark *:hover::-webkit-scrollbar-thumb {
background: hsl(0 0% 40% / 0.5);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: hsl(0 0% 55% / 0.6);
}
.dark ::-webkit-scrollbar-thumb:active {
background: hsl(0 0% 65% / 0.7);
}
/* ---- Behavior ---- */
* {
html {
scroll-behavior: smooth;
scrollbar-gutter: stable;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
html { scroll-behavior: auto; }
}
body {
overscroll-behavior-y: none;
}
.scroll-stable {
scrollbar-gutter: stable;
}

View File

@@ -1,3 +1,7 @@
<!--
Component: Layout
Application shell with providers and page wrapper
-->
<script lang="ts">
/**
* Layout Component
@@ -11,13 +15,9 @@
* - Footer area (currently empty, reserved for future use)
*/
import { BreadcrumbHeader } from '$entities/Breadcrumb';
import {
ThemeSwitch,
themeManager,
} from '$features/ChangeAppTheme';
import { themeManager } from '$features/ChangeAppTheme';
import GD from '$shared/assets/GD.svg';
import { ResponsiveProvider } from '$shared/lib';
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
@@ -27,36 +27,16 @@ import {
} from 'svelte';
interface Props {
/**
* Content snippet
*/
children: Snippet;
}
let { children }: Props = $props();
let fontsReady = $state(false);
let fontsReady = $state(true);
const theme = $derived(themeManager.value);
/**
* Sets fontsReady flag to true when font for the page logo is loaded.
*/
onMount(async () => {
if (!('fonts' in document)) {
fontsReady = true;
return;
}
const required = ['100'];
const missing = required.filter(
w => !document.fonts.check(`${w} 1em Barlow`),
);
if (missing.length > 0) {
await Promise.all(
missing.map(w => document.fonts.load(`${w} 1em Barlow`)),
);
}
fontsReady = true;
});
onMount(() => themeManager.init());
onDestroy(() => themeManager.destroy());
</script>
@@ -94,30 +74,29 @@ onDestroy(() => themeManager.destroy());
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
/>
</noscript>
<title>Compare Typography & Typefaces | GlyphDiff</title>
<title>GlyphDiff | Typography & Typefaces</title>
</svelte:head>
<ResponsiveProvider>
<div
id="app-root"
class={cn(
'min-h-screen flex flex-col bg-surface dark:bg-dark-bg',
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
theme === 'dark' ? 'dark' : '',
)}
>
<header>
<BreadcrumbHeader />
<ThemeSwitch />
</header>
<!-- <ScrollArea class="h-screen w-screen"> -->
<main class="flex-1 w-full mx-auto px-4 pt-0 pb-10 sm:px-6 sm:pt-8 sm:pb-12 md:px-8 md:pt-10 md:pb-16 lg:px-10 lg:pt-12 lg:pb-20 xl:px-16 relative">
<TooltipProvider>
{#if fontsReady}
{@render children?.()}
{/if}
</TooltipProvider>
</main>
<!-- <main class="flex-1 w-full mx-auto relative"> -->
<TooltipProvider>
{#if fontsReady}
{@render children?.()}
{/if}
</TooltipProvider>
<!-- </main> -->
<!-- </ScrollArea> -->
<footer></footer>
</div>

View File

@@ -1,5 +1,35 @@
/**
* Breadcrumb entity
*
* Tracks page sections using Intersection Observer with scroll direction
* detection. Sections appear in breadcrumbs when scrolling down and exiting
* the viewport top.
*
* @example
* ```svelte
* <script lang="ts">
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
* import { onMount } from 'svelte';
*
* onMount(() => {
* const section = document.getElementById('section');
* if (section) {
* scrollBreadcrumbsStore.add({
* index: 0,
* title: 'Section',
* element: section
* }, 80);
* }
* });
* </script>
* ```
*/
export {
handleTitleStatusChanged,
type NavigationAction,
scrollBreadcrumbsStore,
} from './model';
export { BreadcrumbHeader } from './ui';
export {
BreadcrumbHeader,
NavigationWrapper,
} from './ui';

View File

@@ -1,2 +1,2 @@
export * from './services';
export * from './store/scrollBreadcrumbsStore.svelte';
export * from './types/types.ts';

View File

@@ -1,29 +0,0 @@
import type { TitleStatusChangeHandler } from '$shared/ui';
import type { Snippet } from 'svelte';
import { scrollBreadcrumbsStore } from '../../store/scrollBreadcrumbsStore.svelte';
/**
* Updates the breadcrumb store when the title visibility status changes.
*
* @param index - Index of the section
* @param isPast - Whether the section is past the current scroll position
* @param title - Snippet for a title itself
* @param id - ID of the section
* @returns Cleanup callback
*/
export const handleTitleStatusChanged: TitleStatusChangeHandler = (
index: number,
isPast: boolean,
title?: Snippet<[{ className?: string }]>,
id?: string,
) => {
if (isPast && title) {
scrollBreadcrumbsStore.add({ index, title, id });
} else {
scrollBreadcrumbsStore.remove(index);
}
return () => {
scrollBreadcrumbsStore.remove(index);
};
};

View File

@@ -1 +0,0 @@
export { handleTitleStatusChanged } from './handleTitleStatusChanged/handleTitleStatusChanged';

View File

@@ -1,39 +1,240 @@
import type { Snippet } from 'svelte';
/**
* Scroll-based breadcrumb tracking store
*
* Tracks page sections using Intersection Observer with scroll direction
* detection. Sections appear in breadcrumbs when scrolling DOWN and exiting
* the viewport top. This creates a natural "breadcrumb trail" as users
* scroll through content.
*
* Features:
* - Scroll direction detection (up/down)
* - Intersection Observer for efficient tracking
* - Smooth scrolling to tracked sections
* - Configurable scroll offset for sticky headers
*
* @example
* ```svelte
* <script lang="ts">
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
*
* onMount(() => {
* scrollBreadcrumbsStore.add({
* index: 0,
* title: 'Introduction',
* element: document.getElementById('intro')!
* }, 80); // 80px offset for sticky header
* });
* </script>
*
* <div id="intro">Introduction</div>
* ```
*/
/**
* A breadcrumb item representing a tracked section
*/
export interface BreadcrumbItem {
/**
* Index of the item to display
*/
/** Unique index for ordering */
index: number;
/**
* ID of the item to navigate to
*/
id?: string;
/**
* Title snippet to render
*/
title: Snippet<[{ className?: string }]>;
/** Display title for the breadcrumb */
title: string;
/** DOM element to track */
element: HTMLElement;
}
/**
* Scroll-based breadcrumb tracking store
*
* Uses Intersection Observer to detect when sections scroll out of view
* and tracks scroll direction to only show sections the user has scrolled
* past while moving down the page.
*/
class ScrollBreadcrumbsStore {
/** All tracked breadcrumb items */
#items = $state<BreadcrumbItem[]>([]);
/** Set of indices that have scrolled past (exited viewport while scrolling down) */
#scrolledPast = $state<Set<number>>(new Set());
/** Intersection Observer instance */
#observer: IntersectionObserver | null = null;
/** Offset for smooth scrolling (sticky header height) */
#scrollOffset = 0;
/** Current scroll direction */
#isScrollingDown = $state(false);
/** Previous scroll Y position to determine direction */
#prevScrollY = 0;
/** Throttled scroll handler */
#handleScroll: (() => void) | null = null;
/** Listener count for cleanup */
#listenerCount = 0;
get items() {
// Keep them sorted by index for Swiss orderliness
return this.#items.sort((a, b) => a.index - b.index);
/**
* Updates scroll direction based on current position
*/
#updateScrollDirection(): void {
const currentScrollY = window.scrollY;
this.#isScrollingDown = currentScrollY > this.#prevScrollY;
this.#prevScrollY = currentScrollY;
}
add(item: BreadcrumbItem) {
if (!this.#items.find(i => i.index === item.index)) {
this.#items.push(item);
/**
* Initializes the Intersection Observer
*
* Tracks when elements enter/exit viewport with zero threshold
* (fires as soon as any part of element crosses viewport edge).
*/
#initObserver(): void {
if (this.#observer) return;
this.#observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
const item = this.#items.find(i => i.element === entry.target);
if (!item) continue;
if (!entry.isIntersecting && this.#isScrollingDown) {
// Element exited viewport while scrolling DOWN - add to breadcrumbs
this.#scrolledPast = new Set(this.#scrolledPast).add(item.index);
} else if (entry.isIntersecting && !this.#isScrollingDown) {
// Element entered viewport while scrolling UP - remove from breadcrumbs
const newSet = new Set(this.#scrolledPast);
newSet.delete(item.index);
this.#scrolledPast = newSet;
}
}
},
{
threshold: 0,
},
);
}
/**
* Attaches scroll listener for direction detection
*/
#attachScrollListener(): void {
if (this.#listenerCount === 0) {
this.#handleScroll = () => this.#updateScrollDirection();
window.addEventListener('scroll', this.#handleScroll, { passive: true });
}
this.#listenerCount++;
}
/**
* Detaches scroll listener when no items remain
*/
#detachScrollListener(): void {
this.#listenerCount = Math.max(0, this.#listenerCount - 1);
if (this.#listenerCount === 0 && this.#handleScroll) {
window.removeEventListener('scroll', this.#handleScroll);
this.#handleScroll = null;
}
}
remove(index: number) {
/**
* Disconnects observer and removes scroll listener
*/
#disconnect(): void {
if (this.#observer) {
this.#observer.disconnect();
this.#observer = null;
}
this.#detachScrollListener();
}
/** All tracked items sorted by index */
get items(): BreadcrumbItem[] {
return this.#items.slice().sort((a, b) => a.index - b.index);
}
/** Items that have scrolled past viewport top (visible in breadcrumbs) */
get scrolledPastItems(): BreadcrumbItem[] {
return this.items.filter(item => this.#scrolledPast.has(item.index));
}
/** Index of the most recently scrolled item (active section) */
get activeIndex(): number | null {
const past = this.scrolledPastItems;
return past.length > 0 ? past[past.length - 1].index : null;
}
/**
* Check if a specific index has been scrolled past
* @param index - Item index to check
*/
isScrolledPast(index: number): boolean {
return this.#scrolledPast.has(index);
}
/**
* Add a breadcrumb item to track
* @param item - Breadcrumb item with index, title, and element
* @param offset - Scroll offset in pixels (for sticky headers)
*/
add(item: BreadcrumbItem, offset = 0): void {
if (this.#items.find(i => i.index === item.index)) return;
this.#scrollOffset = offset;
this.#items.push(item);
this.#attachScrollListener();
this.#initObserver();
// Initialize scroll direction
this.#prevScrollY = window.scrollY;
this.#observer?.observe(item.element);
}
/**
* Remove a breadcrumb item from tracking
* @param index - Index of item to remove
*/
remove(index: number): void {
const item = this.#items.find(i => i.index === index);
if (!item) return;
this.#observer?.unobserve(item.element);
this.#items = this.#items.filter(i => i.index !== index);
const newSet = new Set(this.#scrolledPast);
newSet.delete(index);
this.#scrolledPast = newSet;
if (this.#items.length === 0) {
this.#disconnect();
}
}
/**
* Smooth scroll to a tracked breadcrumb item
* @param index - Index of item to scroll to
* @param container - Scroll container (window by default)
*/
scrollTo(index: number, container: HTMLElement | Window = window): void {
const item = this.#items.find(i => i.index === index);
if (!item) return;
const rect = item.element.getBoundingClientRect();
const scrollTop = container === window ? window.scrollY : (container as HTMLElement).scrollTop;
const target = rect.top + scrollTop - this.#scrollOffset;
if (container === window) {
window.scrollTo({ top: target, behavior: 'smooth' });
} else {
(container as HTMLElement).scrollTo({
top: target - (container as HTMLElement).getBoundingClientRect().top
+ (container as HTMLElement).scrollTop - window.scrollY,
behavior: 'smooth',
});
}
}
}
export function createScrollBreadcrumbsStore() {
/**
* Creates a new scroll breadcrumbs store instance
*/
export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
return new ScrollBreadcrumbsStore();
}
/**
* Singleton scroll breadcrumbs store instance
*/
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();

View File

@@ -0,0 +1,559 @@
/** @vitest-environment jsdom */
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import {
type BreadcrumbItem,
createScrollBreadcrumbsStore,
} from './scrollBreadcrumbsStore.svelte';
// Mock IntersectionObserver - class variable to track instances
let mockObserverInstances: MockIntersectionObserver[] = [];
class MockIntersectionObserver implements IntersectionObserver {
root = null;
rootMargin = '';
thresholds: number[] = [];
readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = [];
readonly observedElements = new Set<Element>();
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.callbacks.push(callback);
if (options?.rootMargin) this.rootMargin = options.rootMargin;
if (options?.threshold) {
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold];
}
mockObserverInstances.push(this);
}
observe(target: Element): void {
this.observedElements.add(target);
}
unobserve(target: Element): void {
this.observedElements.delete(target);
}
disconnect(): void {
this.observedElements.clear();
}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
// Helper method for tests to trigger intersection changes
triggerIntersection(target: Element, isIntersecting: boolean): void {
const entry: Partial<IntersectionObserverEntry> = {
target,
isIntersecting,
intersectionRatio: isIntersecting ? 1 : 0,
boundingClientRect: {} as DOMRectReadOnly,
intersectionRect: {} as DOMRectReadOnly,
rootBounds: null,
time: Date.now(),
};
this.callbacks.forEach(cb => cb([entry as IntersectionObserverEntry], this));
}
}
describe('ScrollBreadcrumbsStore', () => {
let scrollListeners: Array<() => void> = [];
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
let scrollToSpy: ReturnType<typeof vi.spyOn>;
// Helper to create mock elements
const createMockElement = (): HTMLElement => {
const el = document.createElement('div');
Object.defineProperty(el, 'getBoundingClientRect', {
value: vi.fn(() => ({
top: 100,
left: 0,
bottom: 200,
right: 100,
width: 100,
height: 100,
x: 0,
y: 100,
toJSON: () => ({}),
})),
});
return el;
};
// Helper to create breadcrumb item
const createItem = (index: number, title: string, element?: HTMLElement): BreadcrumbItem => ({
index,
title,
element: element ?? createMockElement(),
});
beforeEach(() => {
mockObserverInstances = [];
scrollListeners = [];
// Set up IntersectionObserver mock before creating store
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
// Mock window.scrollTo
scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
// Track scroll event listeners
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
(event: string, listener: EventListenerOrEventListenerObject, options?: any) => {
if (event === 'scroll') {
scrollListeners.push(listener as () => void);
}
return undefined;
},
);
removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation(
(event: string, listener: EventListenerOrEventListenerObject) => {
if (event === 'scroll') {
const index = scrollListeners.indexOf(listener as () => void);
if (index > -1) scrollListeners.splice(index, 1);
}
return undefined;
},
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Adding items', () => {
it('should add an item and track it', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
const item = createItem(0, 'Section 1', element);
store.add(item);
expect(store.items).toHaveLength(1);
expect(store.items[0]).toEqual(item);
});
it('should ignore duplicate indices', () => {
const store = createScrollBreadcrumbsStore();
const element1 = createMockElement();
const element2 = createMockElement();
store.add(createItem(0, 'First', element1));
store.add(createItem(0, 'Second', element2));
expect(store.items).toHaveLength(1);
expect(store.items[0].title).toBe('First');
});
it('should add multiple items with different indices', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
store.add(createItem(2, 'Third'));
expect(store.items).toHaveLength(3);
expect(store.items.map(i => i.index)).toEqual([0, 1, 2]);
});
it('should attach scroll listener when first item is added', () => {
const store = createScrollBreadcrumbsStore();
expect(scrollListeners).toHaveLength(0);
store.add(createItem(0, 'First'));
expect(scrollListeners).toHaveLength(1);
});
it('should initialize observer with element', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'Test', element));
expect(mockObserverInstances).toHaveLength(1);
expect(mockObserverInstances[0].observedElements.has(element)).toBe(true);
});
});
describe('Removing items', () => {
it('should remove an item by index', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
store.add(createItem(2, 'Third'));
store.remove(1);
expect(store.items).toHaveLength(2);
expect(store.items.map(i => i.index)).toEqual([0, 2]);
});
it('should do nothing when removing non-existent index', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
store.remove(999);
expect(store.items).toHaveLength(2);
});
it('should unobserve element when removed', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
expect(mockObserverInstances[0].observedElements.has(element)).toBe(true);
store.remove(0);
expect(mockObserverInstances[0].observedElements.has(element)).toBe(false);
});
it('should disconnect observer when no items remain', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
expect(addEventListenerSpy).toHaveBeenCalled();
const initialCallCount = addEventListenerSpy.mock.calls.length;
store.remove(0);
// addEventListener was called for the first item, still 1 call
expect(addEventListenerSpy.mock.calls.length).toBe(initialCallCount);
store.remove(1);
// The listener count should be 0 now - disconnect was called
// We verify the observer was disconnected
expect(mockObserverInstances[0].observedElements.size).toBe(0);
});
it('should reattach listener when adding after cleanup', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.remove(0);
expect(scrollListeners).toHaveLength(0);
store.add(createItem(1, 'Second'));
expect(scrollListeners).toHaveLength(1);
});
});
describe('Intersection Observer behavior', () => {
it('should add to scrolledPast when element exits viewport while scrolling down', () => {
// Set initial scrollY before creating store/adding items
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
// Simulate scrolling down (scrollY increases)
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
// Trigger intersection: element exits viewport while scrolling down
mockObserverInstances[0].triggerIntersection(element, false);
expect(store.isScrolledPast(0)).toBe(true);
expect(store.scrolledPastItems).toHaveLength(1);
});
it('should not add to scrolledPast when not scrolling down', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
// scrollY stays at 0 (not scrolling down)
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
scrollListeners.forEach(l => l());
// Element exits viewport
mockObserverInstances[0].triggerIntersection(element, false);
expect(store.isScrolledPast(0)).toBe(false);
});
it('should remove from scrolledPast when element enters viewport while scrolling up', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
// First, scroll down and exit viewport
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element, false);
expect(store.isScrolledPast(0)).toBe(true);
// Now scroll up (decrease scrollY) and element enters viewport
Object.defineProperty(window, 'scrollY', { value: 50, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element, true);
expect(store.isScrolledPast(0)).toBe(false);
});
});
describe('scrollTo method', () => {
it('should scroll to item by index with window as container', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
store.scrollTo(0, window);
expect(scrollToSpy).toHaveBeenCalledWith(
expect.objectContaining({
behavior: 'smooth',
}),
);
});
it('should do nothing when index does not exist', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.scrollTo(999);
expect(scrollToSpy).not.toHaveBeenCalled();
});
it('should use scroll offset when calculating target position', () => {
const store = createScrollBreadcrumbsStore();
// Reset the mock to clear previous calls
scrollToSpy.mockClear();
// Create fresh mock element with specific getBoundingClientRect
const element = document.createElement('div');
const getBoundingClientRectMock = vi.fn(() => ({
top: 200,
left: 0,
bottom: 300,
right: 100,
width: 100,
height: 100,
x: 0,
y: 200,
toJSON: () => ({}),
}));
Object.defineProperty(element, 'getBoundingClientRect', {
value: getBoundingClientRectMock,
writable: true,
configurable: true,
});
// Add item with 80px offset
store.add(createItem(0, 'Third', element), 80);
store.scrollTo(0);
// The offset should be subtracted from the element position
// 200 - 80 = 120 (but in jsdom, getBoundingClientRect might have different behavior)
// Let's just verify smooth behavior is used
expect(scrollToSpy).toHaveBeenCalledWith(
expect.objectContaining({
behavior: 'smooth',
}),
);
// Verify that the scroll position is less than the element top (offset was applied)
const scrollToCall = scrollToSpy.mock.calls[0][0] as ScrollToOptions;
expect((scrollToCall as any).top).toBeLessThan(200);
});
it('should handle HTMLElement container', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'Test', element));
const container: HTMLElement = {
scrollTop: 50,
scrollTo: vi.fn(),
getBoundingClientRect: () => ({
top: 0,
bottom: 500,
left: 0,
right: 400,
width: 400,
height: 500,
x: 0,
y: 0,
toJSON: () => ({}),
}),
} as any;
store.scrollTo(0, container);
expect(container.scrollTo).toHaveBeenCalledWith(
expect.objectContaining({
behavior: 'smooth',
}),
);
});
});
describe('Getters', () => {
it('should return items sorted by index', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(1, 'Second'));
store.add(createItem(0, 'First'));
store.add(createItem(2, 'Third'));
expect(store.items.map(i => i.index)).toEqual([0, 1, 2]);
expect(store.items.map(i => i.title)).toEqual(['First', 'Second', 'Third']);
});
it('should return empty scrolledPastItems initially', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
expect(store.scrolledPastItems).toHaveLength(0);
});
it('should return items that have been scrolled past', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
store.add(createItem(1, 'Second'));
// Simulate scrolling down and element exiting viewport
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element, false);
expect(store.scrolledPastItems).toHaveLength(1);
expect(store.scrolledPastItems[0].index).toBe(0);
});
});
describe('activeIndex getter', () => {
it('should return null when no items are scrolled past', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
expect(store.activeIndex).toBeNull();
});
it('should return the last scrolled item index', () => {
// Set initial scroll position
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
const store = createScrollBreadcrumbsStore();
const element0 = createMockElement();
const element1 = createMockElement();
store.add(createItem(0, 'First', element0));
store.add(createItem(1, 'Second', element1));
// Scroll down, first item exits
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element0, false);
expect(store.activeIndex).toBe(0);
// Second item exits
mockObserverInstances[0].triggerIntersection(element1, false);
expect(store.activeIndex).toBe(1);
});
it('should update active index when scrolling back up', () => {
const store = createScrollBreadcrumbsStore();
const element0 = createMockElement();
const element1 = createMockElement();
store.add(createItem(0, 'First', element0));
store.add(createItem(1, 'Second', element1));
// Scroll past both items
Object.defineProperty(window, 'scrollY', { value: 200, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element0, false);
mockObserverInstances[0].triggerIntersection(element1, false);
expect(store.activeIndex).toBe(1);
// Scroll back up, item 1 enters viewport
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element1, true);
expect(store.activeIndex).toBe(0);
});
});
describe('isScrolledPast', () => {
it('should return false for items not scrolled past', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
expect(store.isScrolledPast(0)).toBe(false);
expect(store.isScrolledPast(1)).toBe(false);
});
it('should return true for scrolled items', () => {
// Set initial scroll position
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
store.add(createItem(1, 'Second'));
// Scroll down, first item exits viewport
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element, false);
expect(store.isScrolledPast(0)).toBe(true);
expect(store.isScrolledPast(1)).toBe(false);
});
it('should return false for non-existent indices', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
expect(store.isScrolledPast(999)).toBe(false);
});
});
describe('Scroll direction tracking', () => {
it('should track scroll direction changes', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
// Initial scroll position
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
scrollListeners.forEach(l => l());
// Scroll down
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element, false);
// Should be in scrolledPast since we scrolled down
expect(store.isScrolledPast(0)).toBe(true);
// Scroll back up
Object.defineProperty(window, 'scrollY', { value: 50, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element, true);
// Should be removed since we scrolled up
expect(store.isScrolledPast(0)).toBe(false);
});
});
});

View File

@@ -0,0 +1,7 @@
/**
* Navigation action type for breadcrumb components
*
* A Svelte action that can be attached to navigation elements
* for scroll tracking or other behaviors.
*/
export type NavigationAction = (node: HTMLElement) => void;

View File

@@ -3,65 +3,72 @@
Fixed header for breadcrumbs navigation for sections in the page
-->
<script lang="ts">
import { smoothScroll } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { ResponsiveManager } from '$shared/lib';
import {
fly,
slide,
} from 'svelte/transition';
import { scrollBreadcrumbsStore } from '../../model';
Button,
Label,
Logo,
} from '$shared/ui';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import {
type BreadcrumbItem,
scrollBreadcrumbsStore,
} from '../../model';
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
const responsive = getContext<ResponsiveManager>('responsive');
function handleClick(item: BreadcrumbItem) {
scrollBreadcrumbsStore.scrollTo(item.index);
}
function createButtonText(item: BreadcrumbItem) {
const index = String(item.index + 1).padStart(2, '0');
if (responsive.isMobileOrTablet) {
return index;
}
return `${index} // ${item.title}`;
}
</script>
{#if scrollBreadcrumbsStore.items.length > 0}
{#if breadcrumbs.length > 0}
<div
transition:slide={{ duration: 200 }}
class="
fixed top-0 left-0 right-0 z-100
backdrop-blur-lg bg-background-20
border-b border-border-muted
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
h-10 sm:h-12
fixed top-0 left-0 right-0
h-14
md:h-16 px-4 md:px-6 lg:px-8
flex items-center justify-between
z-40
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
border-b border-black/5 dark:border-white/10
"
>
<div class="max-w-8xl mx-auto px-4 sm:px-6 h-full flex items-center gap-2 sm:gap-4">
<h1 class={cn('barlow font-extralight text-sm sm:text-base')}>
GLYPHDIFF
</h1>
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
<Logo />
<div class="h-3.5 sm:h-4 w-px bg-border-subtle hidden sm:block"></div>
<nav class="flex items-center gap-2 sm:gap-3 overflow-x-auto scrollbar-hide flex-1">
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
<div
in:fly={{ duration: 300, y: -10, x: 100, opacity: 0 }}
out:fly={{ duration: 300, y: 10, x: 100, opacity: 0 }}
class="flex items-center gap-2 sm:gap-3 whitespace-nowrap shrink-0"
>
<span class="font-mono text-[8px] sm:text-[9px] text-text-muted tracking-wider">
{String(item.index).padStart(2, '0')}
</span>
<a href={`#${item.id}`} use:smoothScroll>
{@render item.title({
className: 'text-[9px] sm:text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-foreground',
})}</a>
{#if idx < scrollBreadcrumbsStore.items.length - 1}
<div class="flex items-center gap-0.5 opacity-40">
<div class="w-1 h-px bg-text-muted"></div>
<div class="w-1 h-px bg-text-muted"></div>
<div class="w-1 h-px bg-text-muted"></div>
</div>
{/if}
<nav class="flex items-center overflow-x-auto scrollbar-hide">
{#each breadcrumbs as item, _ (item.index)}
{@const active = scrollBreadcrumbsStore.activeIndex === item.index}
{@const text = createButtonText(item)}
<div class="ml-1 md:ml-4" transition:slide={{ duration: 200, axis: 'x', easing: cubicOut }}>
<Button
class="uppercase"
variant="tertiary"
size="xs"
{active}
onclick={() => handleClick(item)}
>
<Label class="text-inherit">
{text}
</Label>
</Button>
</div>
{/each}
</nav>
<div class="flex items-center gap-1.5 sm:gap-2 opacity-50 ml-auto">
<div class="w-px h-2 sm:h-2.5 bg-border-subtle hidden sm:block"></div>
<span class="font-mono text-[7px] sm:text-[8px] text-text-muted tracking-wider">
[{scrollBreadcrumbsStore.items.length}]
</span>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,44 @@
<!--
Component: NavigationWrapper
Wrapper for breadcrumb registration with scroll tracking
-->
<script lang="ts">
import { type Snippet } from 'svelte';
import {
type NavigationAction,
scrollBreadcrumbsStore,
} from '../../model';
interface Props {
/**
* Navigation index
*/
index: number;
/**
* Navigation title
*/
title: string;
/**
* Scroll offset
* @default 96
*/
offset?: number;
/**
* Content snippet
*/
content: Snippet<[action: NavigationAction]>;
}
const { index, title, offset = 96, content }: Props = $props();
function registerBreadcrumb(node: HTMLElement) {
scrollBreadcrumbsStore.add({ index, title, element: node }, offset);
return {
destroy() {
scrollBreadcrumbsStore.remove(index);
},
};
}
</script>
{@render content(registerBreadcrumb)}

View File

@@ -1,3 +1,2 @@
import BreadcrumbHeader from './BreadcrumbHeader/BreadcrumbHeader.svelte';
export { BreadcrumbHeader };
export { default as BreadcrumbHeader } from './BreadcrumbHeader/BreadcrumbHeader.svelte';
export { default as NavigationWrapper } from './NavigationWrapper/NavigationWrapper.svelte';

View File

@@ -1,161 +0,0 @@
/**
* Fontshare API client
*
* Handles API requests to Fontshare API for fetching font metadata.
* Provides error handling, pagination support, and type-safe responses.
*
* Pagination: The Fontshare API DOES support pagination via `page` and `limit` parameters.
* However, the current implementation uses `fetchAllFontshareFonts()` to fetch all fonts upfront.
* For future optimization, consider implementing incremental pagination for large datasets.
*
* @see https://fontshare.com
*/
import { api } from '$shared/api/api';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type {
FontshareApiModel,
FontshareFont,
} from '../../model/types/fontshare';
/**
* Fontshare API parameters
*/
export interface FontshareParams extends QueryParams {
/**
* Filter by categories (e.g., ["Sans", "Serif", "Display"])
*/
categories?: string[];
/**
* Filter by tags (e.g., ["Magazines", "Branding", "Logos"])
*/
tags?: string[];
/**
* Page number for pagination (1-indexed)
*/
page?: number;
/**
* Number of items per page
*/
limit?: number;
/**
* Search query to filter fonts
*/
q?: string;
}
/**
* Fontshare API response wrapper
* Re-exported from model/types/fontshare for backward compatibility
*/
export type FontshareResponse = FontshareApiModel;
/**
* Fetch fonts from Fontshare API
*
* @param params - Query parameters for filtering fonts
* @returns Promise resolving to Fontshare API response
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all Sans category fonts
* const response = await fetchFontshareFonts({
* categories: ['Sans'],
* limit: 50
* });
*
* // Fetch fonts with specific tags
* const response = await fetchFontshareFonts({
* tags: ['Branding', 'Logos']
* });
*
* // Search fonts
* const response = await fetchFontshareFonts({
* search: 'Satoshi'
* });
* ```
*/
export async function fetchFontshareFonts(
params: FontshareParams = {},
): Promise<FontshareResponse> {
const queryString = buildQueryString(params);
const url = `https://api.fontshare.com/v2/fonts${queryString}`;
try {
const response = await api.get<FontshareResponse>(url);
return response.data;
} catch (error) {
// Re-throw ApiError with context
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to fetch Fontshare fonts: ${String(error)}`);
}
}
/**
* Fetch font by slug
* Convenience function for fetching a single font
*
* @param slug - Font slug (e.g., "satoshi", "general-sans")
* @returns Promise resolving to Fontshare font item
*
* @example
* ```ts
* const satoshi = await fetchFontshareFontBySlug('satoshi');
* ```
*/
export async function fetchFontshareFontBySlug(
slug: string,
): Promise<FontshareFont | undefined> {
const response = await fetchFontshareFonts();
return response.fonts.find(font => font.slug === slug);
}
/**
* Fetch all fonts from Fontshare
* Convenience function for fetching all available fonts
* Uses pagination to get all items
*
* @returns Promise resolving to all Fontshare fonts
*
* @example
* ```ts
* const allFonts = await fetchAllFontshareFonts();
* console.log(`Found ${allFonts.fonts.length} fonts`);
* ```
*/
export async function fetchAllFontshareFonts(
params: FontshareParams = {},
): Promise<FontshareResponse> {
const allFonts: FontshareFont[] = [];
let page = 1;
const limit = 100; // Max items per page
while (true) {
const response = await fetchFontshareFonts({
...params,
page,
limit,
});
allFonts.push(...response.fonts);
// Check if we've fetched all items
if (response.fonts.length < limit) {
break;
}
page++;
}
// Return first response with all items combined
const firstResponse = await fetchFontshareFonts({ ...params, page: 1, limit });
return {
...firstResponse,
fonts: allFonts,
};
}

View File

@@ -1,127 +0,0 @@
/**
* Google Fonts API client
*
* Handles API requests to Google Fonts API for fetching font metadata.
* Provides error handling, retry logic, and type-safe responses.
*
* Pagination: The Google Fonts API does NOT support pagination parameters.
* All fonts matching the query are returned in a single response.
* Use category, subset, or sort filters to reduce the result set if needed.
*
* @see https://developers.google.com/fonts/docs/developer_api
*/
import { api } from '$shared/api/api';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type {
FontItem,
GoogleFontsApiModel,
} from '../../model/types/google';
/**
* Google Fonts API parameters
*/
export interface GoogleFontsParams extends QueryParams {
/**
* Google Fonts API key (required for Google Fonts API v1)
*/
key?: string;
/**
* Font family name (to fetch specific font)
*/
family?: string;
/**
* Font category filter (e.g., "sans-serif", "serif", "display")
*/
category?: string;
/**
* Character subset filter (e.g., "latin", "latin-ext", "cyrillic")
*/
subset?: string;
/**
* Sort order for results
*/
sort?: 'alpha' | 'date' | 'popularity' | 'style' | 'trending';
/**
* Cap the number of fonts returned
*/
capability?: 'VF' | 'WOFF2';
}
/**
* Google Fonts API response wrapper
* Re-exported from model/types/google for backward compatibility
*/
export type GoogleFontsResponse = GoogleFontsApiModel;
/**
* Simplified font item from Google Fonts API
* Re-exported from model/types/google for backward compatibility
*/
export type GoogleFontItem = FontItem;
/**
* Google Fonts API base URL
* Note: Google Fonts API v1 requires an API key. For development/testing without a key,
* fonts may not load properly.
*/
const GOOGLE_FONTS_API_URL = 'https://www.googleapis.com/webfonts/v1/webfonts' as const;
/**
* Fetch fonts from Google Fonts API
*
* @param params - Query parameters for filtering fonts
* @returns Promise resolving to Google Fonts API response
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all sans-serif fonts sorted by popularity
* const response = await fetchGoogleFonts({
* category: 'sans-serif',
* sort: 'popularity'
* });
*
* // Fetch specific font family
* const robotoResponse = await fetchGoogleFonts({
* family: 'Roboto'
* });
* ```
*/
export async function fetchGoogleFonts(
params: GoogleFontsParams = {},
): Promise<GoogleFontsResponse> {
const queryString = buildQueryString(params);
const url = `${GOOGLE_FONTS_API_URL}${queryString}`;
try {
const response = await api.get<GoogleFontsResponse>(url);
return response.data;
} catch (error) {
// Re-throw ApiError with context
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to fetch Google Fonts: ${String(error)}`);
}
}
/**
* Fetch font by family name
* Convenience function for fetching a single font
*
* @param family - Font family name (e.g., "Roboto")
* @returns Promise resolving to Google Font item
*
* @example
* ```ts
* const roboto = await fetchGoogleFontFamily('Roboto');
* ```
*/
export async function fetchGoogleFontFamily(
family: string,
): Promise<GoogleFontItem | undefined> {
const response = await fetchGoogleFonts({ family });
return response.items.find(item => item.family === family);
}

View File

@@ -4,7 +4,7 @@
* Exports API clients and normalization utilities
*/
// Proxy API (PRIMARY - NEW)
// Proxy API (primary)
export {
fetchFontsByIds,
fetchProxyFontById,
@@ -14,25 +14,3 @@ export type {
ProxyFontsParams,
ProxyFontsResponse,
} from './proxy/proxyFonts';
// Google Fonts API (DEPRECATED - kept for backward compatibility)
export {
fetchGoogleFontFamily,
fetchGoogleFonts,
} from './google/googleFonts';
export type {
GoogleFontItem,
GoogleFontsParams,
GoogleFontsResponse,
} from './google/googleFonts';
// Fontshare API (DEPRECATED - kept for backward compatibility)
export {
fetchAllFontshareFonts,
fetchFontshareFontBySlug,
fetchFontshareFonts,
} from './fontshare/fontshare';
export type {
FontshareParams,
FontshareResponse,
} from './fontshare/fontshare';

View File

@@ -0,0 +1,171 @@
/**
* Tests for proxy API client
*/
import {
beforeEach,
describe,
expect,
test,
vi,
} from 'vitest';
import type { UnifiedFont } from '../../model/types';
import type { ProxyFontsResponse } from './proxyFonts';
vi.mock('$shared/api/api', () => ({
api: {
get: vi.fn(),
},
}));
import { api } from '$shared/api/api';
import {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
} from './proxyFonts';
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
return {
id: 'roboto',
family: 'Roboto',
provider: 'google',
category: 'sans-serif',
variants: [],
subsets: [],
...overrides,
} as UnifiedFont;
}
function mockApiGet<T>(data: T) {
vi.mocked(api.get).mockResolvedValueOnce({ data, status: 200 });
}
describe('proxyFonts', () => {
beforeEach(() => {
vi.mocked(api.get).mockReset();
});
describe('fetchProxyFonts', () => {
test('should fetch fonts with no params', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont()],
total: 1,
limit: 50,
offset: 0,
};
mockApiGet(mockResponse);
const result = await fetchProxyFonts();
expect(api.get).toHaveBeenCalledWith(PROXY_API_URL);
expect(result).toEqual(mockResponse);
});
test('should build URL with query params', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont()],
total: 1,
limit: 20,
offset: 0,
};
mockApiGet(mockResponse);
await fetchProxyFonts({ provider: 'google', category: 'sans-serif', limit: 20, offset: 0 });
const calledUrl = vi.mocked(api.get).mock.calls[0][0];
expect(calledUrl).toContain('provider=google');
expect(calledUrl).toContain('category=sans-serif');
expect(calledUrl).toContain('limit=20');
expect(calledUrl).toContain('offset=0');
});
test('should throw on invalid response (missing fonts array)', async () => {
mockApiGet({ total: 0 });
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
});
test('should throw on null response data', async () => {
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
});
});
describe('fetchProxyFontById', () => {
test('should return font matching the ID', async () => {
const targetFont = createMockFont({ id: 'satoshi', name: 'Satoshi' });
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont(), targetFont],
total: 2,
limit: 1000,
offset: 0,
};
mockApiGet(mockResponse);
const result = await fetchProxyFontById('satoshi');
expect(result).toEqual(targetFont);
});
test('should return undefined when font not found', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont()],
total: 1,
limit: 1000,
offset: 0,
};
mockApiGet(mockResponse);
const result = await fetchProxyFontById('nonexistent');
expect(result).toBeUndefined();
});
test('should search with the ID as query param', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [],
total: 0,
limit: 1000,
offset: 0,
};
mockApiGet(mockResponse);
await fetchProxyFontById('Roboto');
const calledUrl = vi.mocked(api.get).mock.calls[0][0];
expect(calledUrl).toContain('limit=1000');
expect(calledUrl).toContain('q=Roboto');
});
});
describe('fetchFontsByIds', () => {
test('should return empty array for empty input', async () => {
const result = await fetchFontsByIds([]);
expect(result).toEqual([]);
expect(api.get).not.toHaveBeenCalled();
});
test('should call batch endpoint with comma-separated IDs', async () => {
const fonts = [createMockFont({ id: 'roboto' }), createMockFont({ id: 'satoshi' })];
mockApiGet(fonts);
const result = await fetchFontsByIds(['roboto', 'satoshi']);
expect(api.get).toHaveBeenCalledWith(`${PROXY_API_URL}/batch?ids=roboto,satoshi`);
expect(result).toEqual(fonts);
});
test('should return empty array when response data is nullish', async () => {
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
const result = await fetchFontsByIds(['roboto']);
expect(result).toEqual([]);
});
});
});

View File

@@ -1,4 +1,4 @@
// Proxy API (PRIMARY)
// Proxy API (primary)
export {
fetchFontsByIds,
fetchProxyFontById,
@@ -9,32 +9,9 @@ export type {
ProxyFontsResponse,
} from './api/proxy/proxyFonts';
// Fontshare API (DEPRECATED)
export {
fetchAllFontshareFonts,
fetchFontshareFontBySlug,
fetchFontshareFonts,
} from './api/fontshare/fontshare';
export type {
FontshareParams,
FontshareResponse,
} from './api/fontshare/fontshare';
// Google Fonts API (DEPRECATED)
export {
fetchGoogleFontFamily,
fetchGoogleFonts,
} from './api/google/googleFonts';
export type {
GoogleFontItem,
GoogleFontsParams,
GoogleFontsResponse,
} from './api/google/googleFonts';
export {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './lib/normalize/normalize';
export type {
// Domain types
@@ -65,8 +42,6 @@ export type {
FontVariant,
FontWeight,
FontWeightItalic,
// Google Fonts API types
GoogleFontsApiModel,
// Normalization types
UnifiedFont,
UnifiedFontVariant,

View File

@@ -3,13 +3,31 @@ import type {
UnifiedFont,
} from '../../model';
/** Valid font weight values (100-900 in increments of 100) */
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
/**
* Constructs a URL for a font based on the provided font and weight.
* @param font - The font object.
* @param weight - The weight of the font.
* @returns The URL for the font.
* Gets the URL for a font file at a specific weight
*
* Constructs the appropriate URL for loading a font file based on
* the font object and requested weight. Handles variable fonts and
* provides fallbacks for static fonts.
*
* @param font - Unified font object containing style URLs
* @param weight - Font weight (100-900)
* @returns URL string for the font file, or undefined if not found
* @throws Error if weight is not a valid value (100-900)
*
* @example
* ```ts
* const url = getFontUrl(roboto, 700); // Returns URL for Roboto Bold
*
* // Variable fonts: backend maps weight to VF URL
* const vfUrl = getFontUrl(inter, 450); // Returns variable font URL
*
* // Fallback for missing weights
* const fallback = getFontUrl(font, 900); // Falls back to regular/400 if 900 missing
* ```
*/
export function getFontUrl(font: UnifiedFont, weight: number): string | undefined {
if (!SIZES.includes(weight)) {
@@ -18,12 +36,11 @@ export function getFontUrl(font: UnifiedFont, weight: number): string | undefine
const weightKey = weight.toString() as FontWeight;
// 1. Try exact match (Backend now maps "100".."900" to VF URL if variable)
// Try exact match (backend maps weight to VF URL for variable fonts)
if (font.styles.variants?.[weightKey]) {
return font.styles.variants[weightKey];
}
// 2. Fallbacks for Static Fonts (if exact weight missing)
// Try 'regular' or '400' as safe defaults
// Fallbacks for static fonts when exact weight is missing
return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular'];
}

View File

@@ -1,7 +1,5 @@
/**
* ============================================================================
* MOCK FONT FILTER DATA
* ============================================================================
* Mock font filter data
*
* Factory functions and preset mock data for font-related filters.
* Used in Storybook stories for font filtering components.
@@ -36,9 +34,7 @@ import type {
import type { Property } from '$shared/lib';
import { createFilter } from '$shared/lib';
// ============================================================================
// TYPE DEFINITIONS
// ============================================================================
/**
* Options for creating a mock filter
@@ -60,9 +56,7 @@ export interface MockFilters {
subsets: ReturnType<typeof createFilter<FontSubset>>;
}
// ============================================================================
// FONT CATEGORIES
// ============================================================================
/**
* Google Fonts categories
@@ -98,9 +92,7 @@ export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
];
// ============================================================================
// FONT SUBSETS
// ============================================================================
/**
* Common font subsets
@@ -114,9 +106,7 @@ export const FONT_SUBSETS: Property<FontSubset>[] = [
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
];
// ============================================================================
// FONT PROVIDERS
// ============================================================================
/**
* Font providers
@@ -126,9 +116,7 @@ export const FONT_PROVIDERS: Property<FontProvider>[] = [
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
];
// ============================================================================
// FILTER FACTORIES
// ============================================================================
/**
* Create a mock filter from properties
@@ -172,9 +160,7 @@ export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
return createFilter<FontProvider>({ properties });
}
// ============================================================================
// PRESET FILTERS
// ============================================================================
/**
* Preset mock filters - use these directly in stories
@@ -251,9 +237,7 @@ export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
}),
};
// ============================================================================
// GENERIC FILTER MOCKS
// ============================================================================
/**
* Create a mock filter with generic string properties

View File

@@ -50,9 +50,7 @@ import type {
UnifiedFont,
} from '$entities/Font/model/types';
// ============================================================================
// GOOGLE FONTS MOCKS
// ============================================================================
/**
* Options for creating a mock Google Font
@@ -186,9 +184,7 @@ export const GOOGLE_FONTS: Record<string, GoogleFontItem> = {
}),
};
// ============================================================================
// FONTHARE MOCKS
// ============================================================================
/**
* Options for creating a mock Fontshare font
@@ -399,9 +395,7 @@ export const FONTHARE_FONTS: Record<string, FontshareFont> = {
}),
};
// ============================================================================
// UNIFIED FONT MOCKS
// ============================================================================
/**
* Options for creating a mock UnifiedFont

View File

@@ -35,9 +35,7 @@ import {
generateMockFonts,
} from './fonts.mock';
// ============================================================================
// TANSTACK QUERY MOCK TYPES
// ============================================================================
/**
* Mock TanStack Query state
@@ -83,9 +81,7 @@ export interface MockQueryObserverResult<TData = unknown, TError = Error> {
isPaused?: boolean;
}
// ============================================================================
// TANSTACK QUERY MOCK FACTORIES
// ============================================================================
/**
* Create a mock query state for TanStack Query
@@ -142,9 +138,7 @@ export function createSuccessState<TData>(data: TData): MockQueryObserverResult<
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
}
// ============================================================================
// FONT STORE MOCKS
// ============================================================================
/**
* Mock UnifiedFontStore state
@@ -332,9 +326,7 @@ export const MOCK_FONT_STORE_STATES = {
}),
};
// ============================================================================
// MOCK STORE OBJECT
// ============================================================================
/**
* Create a mock store object that mimics TanStack Query behavior
@@ -469,9 +461,7 @@ export const MOCK_STORES = {
},
};
// ============================================================================
// REACTIVE STATE MOCKS
// ============================================================================
/**
* Create a reactive state object using Svelte 5 runes pattern
@@ -525,9 +515,7 @@ export function createMockComparisonStore(config: {
};
}
// ============================================================================
// MOCK DATA GENERATORS
// ============================================================================
/**
* Generate paginated font data

View File

@@ -7,41 +7,64 @@ import {
} from '@tanstack/query-core';
import type { UnifiedFont } from '../types';
/** */
/**
* Base class for font stores using TanStack Query
*
* Provides reactive font data fetching with caching, automatic refetching,
* and parameter binding. Extended by UnifiedFontStore for provider-agnostic
* font fetching.
*
* @template TParams - Type of query parameters
*/
export abstract class BaseFontStore<TParams extends Record<string, any>> {
/**
* Cleanup function for effects
* Call destroy() to remove effects and prevent memory leaks
*/
cleanup: () => void;
/** Reactive parameter bindings from external sources */
#bindings = $state<(() => Partial<TParams>)[]>([]);
/** Internal parameter state */
#internalParams = $state<TParams>({} as TParams);
/**
* Merged params from internal state and all bindings
* Automatically updates when bindings or internal params change
*/
params = $derived.by(() => {
let merged = { ...this.#internalParams };
// Loop through every "Cable" plugged into the store
// Loop through every "Cable" plugged into the store
// Merge all binding results into params
for (const getter of this.#bindings) {
const bindingResult = getter();
merged = { ...merged, ...bindingResult };
}
return merged as TParams;
});
/** TanStack Query result state */
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
/** TanStack Query observer instance */
protected observer: QueryObserver<UnifiedFont[], Error>;
/** Shared query client */
protected qc = queryClient;
/**
* Creates a new base font store
* @param initialParams - Initial query parameters
*/
constructor(initialParams: TParams) {
this.#internalParams = initialParams;
this.observer = new QueryObserver(this.qc, this.getOptions());
// Sync TanStack -> Svelte State
// Sync TanStack Query state -> Svelte state
this.observer.subscribe(r => {
this.result = r;
});
// Sync Svelte State -> TanStack Options
// Sync Svelte state changes -> TanStack Query options
this.cleanup = $effect.root(() => {
$effect(() => {
this.observer.setOptions(this.getOptions());
@@ -50,11 +73,21 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
}
/**
* Mandatory: Child must define how to fetch data and what the key is.
* Must be implemented by child class
* Returns the query key for TanStack Query caching
*/
protected abstract getQueryKey(params: TParams): QueryKey;
/**
* Must be implemented by child class
* Fetches font data from API
*/
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
/**
* Gets TanStack Query options
* @param params - Query parameters (defaults to current params)
*/
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
return {
queryKey: this.getQueryKey(params),
@@ -64,25 +97,36 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
};
}
// --- Common Getters ---
/** Array of fonts (empty array if loading/error) */
get fonts() {
return this.result.data ?? [];
}
/** Whether currently fetching initial data */
get isLoading() {
return this.result.isLoading;
}
/** Whether any fetch is in progress (including refetches) */
get isFetching() {
return this.result.isFetching;
}
/** Whether last fetch resulted in an error */
get isError() {
return this.result.isError;
}
/** Whether no fonts are loaded (not loading and empty array) */
get isEmpty() {
return !this.isLoading && this.fonts.length === 0;
}
// --- Common Actions ---
/**
* Add a reactive parameter binding
* @param getter - Function that returns partial params to merge
* @returns Unbind function to remove the binding
*/
addBinding(getter: () => Partial<TParams>) {
this.#bindings.push(getter);
@@ -91,9 +135,14 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
};
}
/**
* Update query parameters
* @param newParams - Partial params to merge with existing
*/
setParams(newParams: Partial<TParams>) {
this.#internalParams = { ...this.params, ...newParams };
}
/**
* Invalidate cache and refetch
*/
@@ -101,19 +150,22 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
}
/**
* Clean up effects and observers
*/
destroy() {
this.cleanup();
}
/**
* Manually refetch
* Manually trigger a refetch
*/
async refetch() {
await this.observer.refetch();
}
/**
* Prefetch with different params (for hover states, pagination, etc.)
* Prefetch data with different parameters
*/
async prefetch(params: TParams) {
await this.qc.prefetchQuery(this.getOptions(params));

View File

@@ -1,43 +0,0 @@
/**
* ============================================================================
* UNIFIED FONT STORE TYPES
* ============================================================================
*
* Type definitions for the unified font store infrastructure.
* Provides types for filters, sorting, and fetch parameters.
*/
import type {
FontshareParams,
GoogleFontsParams,
} from '$entities/Font/api';
import type {
FontCategory,
FontProvider,
FontSubset,
} from '$entities/Font/model/types/common';
/**
* Sort configuration
*/
export interface FontSort {
field: 'name' | 'popularity' | 'category' | 'date';
direction: 'asc' | 'desc';
}
/**
* Fetch params for unified API
*/
export interface FetchFontsParams {
providers?: FontProvider[];
categories?: FontCategory[];
subsets?: FontSubset[];
search?: string;
sort?: FontSort;
forceRefetch?: boolean;
}
/**
* Provider-specific params union
*/
export type ProviderParams = GoogleFontsParams | FontshareParams;

View File

@@ -43,7 +43,7 @@ import { BaseFontStore } from './baseFontStore.svelte';
* });
*
* // Update parameters
* store.setCategory('serif');
* store.setCategories(['serif']);
* store.nextPage();
* ```
*/
@@ -108,16 +108,20 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
this.#filterCleanup = $effect.root(() => {
$effect(() => {
const filterParams = JSON.stringify({
provider: this.params.provider,
category: this.params.category,
subset: this.params.subset,
providers: this.params.providers,
categories: this.params.categories,
subsets: this.params.subsets,
q: this.params.q,
});
// If filters changed, reset offset to 0
// If filters changed, reset offset and invalidate cache
if (filterParams !== this.#previousFilterParams) {
if (this.#previousFilterParams && this.params.offset !== 0) {
this.setParams({ offset: 0 });
if (this.#previousFilterParams) {
if (this.params.offset !== 0) {
this.setParams({ offset: 0 });
}
this.#accumulatedFonts = [];
this.invalidate();
}
this.#previousFilterParams = filterParams;
}
@@ -170,7 +174,7 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
}
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
const hasFilters = !!(params.q || params.provider || params.category || params.subset);
const hasFilters = !!(params.q || params.providers || params.categories || params.subsets);
return {
queryKey: this.getQueryKey(params),
queryFn: () => this.fetchFn(params),
@@ -221,8 +225,6 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
return response.fonts;
}
// --- Getters (proxied from BaseFontStore) ---
/**
* Get all accumulated fonts (for infinite scroll)
*/
@@ -258,27 +260,25 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
return !this.isLoading && this.fonts.length === 0;
}
// --- Provider-specific shortcuts ---
/**
* Set provider filter
* Set providers filter
*/
setProvider(provider: 'google' | 'fontshare' | undefined) {
this.setParams({ provider });
setProviders(providers: ProxyFontsParams['providers']) {
this.setParams({ providers });
}
/**
* Set category filter
* Set categories filter
*/
setCategory(category: ProxyFontsParams['category']) {
this.setParams({ category });
setCategories(categories: ProxyFontsParams['categories']) {
this.setParams({ categories });
}
/**
* Set subset filter
* Set subsets filter
*/
setSubset(subset: ProxyFontsParams['subset']) {
this.setParams({ subset });
setSubsets(subsets: ProxyFontsParams['subsets']) {
this.setParams({ subsets });
}
/**
@@ -295,8 +295,6 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
this.setParams({ sort });
}
// --- Pagination methods ---
/**
* Go to next page
*/
@@ -337,8 +335,6 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
this.setParams({ limit });
}
// --- Category shortcuts (for convenience) ---
get sansSerifFonts() {
return this.fonts.filter(f => f.category === 'sans-serif');
}

View File

@@ -1,50 +1,60 @@
/**
* ============================================================================
* DOMAIN TYPES
* ============================================================================
* Common font domain types
*
* Shared types for font entities across providers (Google, Fontshare).
* Includes categories, subsets, weights, and filter types.
*/
import type { FontCategory as FontshareFontCategory } from './fontshare';
import type { FontCategory as GoogleFontCategory } from './google';
/**
* Font category
* Unified font category across all providers
*/
export type FontCategory = GoogleFontCategory | FontshareFontCategory;
/**
* Font provider
* Font provider identifier
*/
export type FontProvider = 'google' | 'fontshare';
/**
* Font subset
* Character subset support
*/
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
/**
* Filter state
* Combined filter state for font queries
*/
export interface FontFilters {
/** Selected font providers */
providers: FontProvider[];
/** Selected font categories */
categories: FontCategory[];
/** Selected character subsets */
subsets: FontSubset[];
}
/** Filter group identifier */
export type FilterGroup = 'providers' | 'categories' | 'subsets';
/** Filter type including search query */
export type FilterType = FilterGroup | 'searchQuery';
/**
* Standard font weights
* Numeric font weights (100-900)
*/
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
/**
* Italic variant format: e.g., "100italic", "400italic", "700italic"
* Italic variant with weight: "100italic", "400italic", "700italic", etc.
*/
export type FontWeightItalic = `${FontWeight}italic`;
/**
* All possible font variants
* All possible font variant identifiers
*
* Includes:
* - Numeric weights: "400", "700", etc.
* - Italic variants: "400italic", "700italic", etc.
* - Legacy names: "regular", "italic", "bold", "bolditalic"

View File

@@ -46,6 +46,13 @@ export interface FontMetadata {
lastModified?: string;
/** Popularity rank (if available from provider) */
popularity?: number;
/**
* Normalized popularity score (0-100)
*
* Normalized across all fonts for consistent ranking
* Higher values indicate more popular fonts
*/
popularityScore?: number;
}
/**
@@ -79,6 +86,13 @@ export interface UnifiedFont {
name: string;
/** Font provider (google | fontshare) */
provider: FontProvider;
/**
* Provider badge display name
*
* Human-readable provider name for UI display
* e.g., "Google Fonts" or "Fontshare"
*/
providerBadge?: string;
/** Font category classification */
category: FontCategory;
/** Supported character subsets */

View File

@@ -16,19 +16,20 @@ import {
interface Props {
/**
* Applied font
* Font to apply
*/
font: UnifiedFont;
/**
* Font weight
* @default 400
*/
weight?: number;
/**
* Additional classes
* CSS classes
*/
className?: string;
/**
* Children
* Content snippet
*/
children?: Snippet;
}
@@ -44,7 +45,7 @@ const status = $derived(
appliedFontsManager.getFontStatus(
font.id,
weight,
font.features.isVariable,
font.features?.isVariable,
),
);

View File

@@ -28,11 +28,11 @@ interface Props extends
>
{
/**
* Callback for when visible items change
* Visible items callback
*/
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
/**
* Weight of the font
* Font weight
*/
weight: number;
/**
@@ -69,6 +69,7 @@ function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
});
}
});
// Auto-register fonts with the manager
appliedFontsManager.touch(configs);

View File

@@ -1,14 +1,55 @@
/**
* Theme management with system preference detection
*
* Manages light/dark theme state with localStorage persistence
* and automatic system preference detection. Themes are applied
* via CSS class on the document element.
*
* Features:
* - Persists user preference to localStorage
* - Detects OS-level theme preference
* - Responds to OS theme changes when in "system" mode
* - Toggle between light/dark themes
* - Reset to follow system preference
*
* @example
* ```svelte
* <script lang="ts">
* import { themeManager } from '$features/ChangeAppTheme';
*
* // Initialize once on app mount
* onMount(() => themeManager.init());
* onDestroy(() => themeManager.destroy());
* </script>
*
* <button on:click={() => themeManager.toggle()}>
* {themeManager.isDark ? 'Switch to Light' : 'Switch to Dark'}
* </button>
* ```
*/
import { createPersistentStore } from '$shared/lib';
type Theme = 'light' | 'dark';
type ThemeSource = 'system' | 'user';
/**
* Theme manager singleton
*
* Call init() on app mount and destroy() on app unmount.
* Use isDark property to conditionally apply styles.
*/
class ThemeManager {
// Private reactive state
/** Current theme value ('light' or 'dark') */
#theme = $state<Theme>('light');
/** Whether theme is controlled by user or follows system */
#source = $state<ThemeSource>('system');
/** MediaQueryList for detecting system theme changes */
#mediaQuery: MediaQueryList | null = null;
/** Persistent storage for user's theme preference */
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
/** Bound handler for system theme change events */
#systemChangeHandler = this.#onSystemChange.bind(this);
constructor() {
@@ -23,35 +64,56 @@ class ThemeManager {
}
}
/** Current theme value */
get value(): Theme {
return this.#theme;
}
/** Source of current theme ('system' or 'user') */
get source(): ThemeSource {
return this.#source;
}
/** Whether dark theme is active */
get isDark(): boolean {
return this.#theme === 'dark';
}
/** Whether theme is controlled by user (not following system) */
get isUserControlled(): boolean {
return this.#source === 'user';
}
/** Call once in root onMount */
/**
* Initialize theme manager
*
* Applies current theme to DOM and sets up system preference listener.
* Call once in root component onMount.
*/
init(): void {
this.#applyToDom(this.#theme);
this.#mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.#mediaQuery.addEventListener('change', this.#systemChangeHandler);
}
/** Call in root onDestroy */
/**
* Clean up theme manager
*
* Removes system preference listener.
* Call in root component onDestroy.
*/
destroy(): void {
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
this.#mediaQuery = null;
}
/**
* Set theme explicitly
*
* Switches to user control and applies specified theme.
*
* @param theme - Theme to apply ('light' or 'dark')
*/
setTheme(theme: Theme): void {
this.#source = 'user';
this.#theme = theme;
@@ -59,11 +121,18 @@ class ThemeManager {
this.#applyToDom(theme);
}
/**
* Toggle between light and dark themes
*/
toggle(): void {
this.setTheme(this.value === 'dark' ? 'light' : 'dark');
}
/** Hand control back to OS */
/**
* Reset to follow system preference
*
* Clears user preference and switches to system theme.
*/
resetToSystem(): void {
this.#store.clear();
this.#theme = this.#getSystemTheme();
@@ -71,10 +140,12 @@ class ThemeManager {
this.#applyToDom(this.#theme);
}
// -------------------------
// Private helpers
// -------------------------
/**
* Detect system theme preference
* @returns 'dark' if system prefers dark mode, 'light' otherwise
*/
#getSystemTheme(): Theme {
if (typeof window === 'undefined') {
return 'light';
@@ -83,10 +154,18 @@ class ThemeManager {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/**
* Apply theme to DOM
* @param theme - Theme to apply
*/
#applyToDom(theme: Theme): void {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
/**
* Handle system theme change
* Only updates if currently following system preference
*/
#onSystemChange(e: MediaQueryListEvent): void {
if (this.#source === 'system') {
this.#theme = e.matches ? 'dark' : 'light';
@@ -95,5 +174,15 @@ class ThemeManager {
}
}
// Export a singleton — one instance for the whole app
/**
* Singleton theme manager instance
*
* Use throughout the app for consistent theme state.
*/
export const themeManager = new ThemeManager();
/**
* ThemeManager class exported for testing purposes
* Use the singleton `themeManager` in application code.
*/
export { ThemeManager };

View File

@@ -0,0 +1,726 @@
/** @vitest-environment jsdom */
// ============================================================
// Mock MediaQueryListEvent for system theme change simulations
// Note: Other mocks (ResizeObserver, localStorage, matchMedia) are set up in vitest.setup.unit.ts
// ============================================================
class MockMediaQueryListEvent extends Event {
matches: boolean;
media: string;
constructor(type: string, eventInitDict: { matches: boolean; media: string }) {
super(type);
this.matches = eventInitDict.matches;
this.media = eventInitDict.media;
}
}
// ============================================================
// NOW IT'S SAFE TO IMPORT
// ============================================================
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { ThemeManager } from './ThemeManager.svelte';
/**
* Test Suite for ThemeManager
*
* Tests theme management functionality including:
* - Initial state from localStorage or system preference
* - Theme setting and persistence
* - Toggle functionality
* - System preference detection and following
* - DOM manipulation for theme application
* - MediaQueryList listener management
*/
// Storage key used by ThemeManager
const STORAGE_KEY = 'glyphdiff:theme';
// Helper type for MediaQueryList event handler
type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void;
// Helper to flush Svelte effects (they run in microtasks)
async function flushEffects() {
await Promise.resolve();
}
describe('ThemeManager', () => {
let classListMock: DOMTokenList;
let darkClassAdded = false;
let mediaQueryListeners: Map<string, Set<MediaQueryListCallback>> = new Map();
let matchMediaSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
// Reset tracking variables
darkClassAdded = false;
mediaQueryListeners.clear();
// Clear localStorage before each test
localStorage.clear();
// Mock documentElement.classList
classListMock = {
contains: (className: string) => className === 'dark' ? darkClassAdded : false,
add: vi.fn((..._classNames: string[]) => {
darkClassAdded = true;
}),
remove: vi.fn((..._classNames: string[]) => {
darkClassAdded = false;
}),
toggle: vi.fn((className: string, force?: boolean) => {
if (className === 'dark') {
if (force !== undefined) {
darkClassAdded = force;
} else {
darkClassAdded = !darkClassAdded;
}
return darkClassAdded;
}
return false;
}),
supports: vi.fn(() => true),
entries: vi.fn(() => []),
forEach: vi.fn(),
keys: vi.fn(() => []),
values: vi.fn(() => []),
length: 0,
item: vi.fn(() => null),
replace: vi.fn(() => false),
} as unknown as DOMTokenList;
// Mock document.documentElement
if (typeof document !== 'undefined' && document.documentElement) {
Object.defineProperty(document.documentElement, 'classList', {
configurable: true,
get: () => classListMock,
});
}
// Mock window.matchMedia with spy to track listeners
matchMediaSpy = vi.fn((query: string) => {
// Default to light theme (matches = false)
const mediaQueryList = {
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
if (!mediaQueryListeners.has(query)) {
mediaQueryListeners.set(query, new Set());
}
mediaQueryListeners.get(query)!.add(listener);
}),
removeEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
if (mediaQueryListeners.has(query)) {
mediaQueryListeners.get(query)!.delete(listener);
}
}),
dispatchEvent: vi.fn(),
};
return mediaQueryList;
});
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: matchMediaSpy,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
/**
* Helper to trigger a MediaQueryList change event
*/
function triggerSystemThemeChange(isDark: boolean) {
const query = '(prefers-color-scheme: dark)';
const listeners = mediaQueryListeners.get(query);
if (listeners) {
const event = new MockMediaQueryListEvent('change', {
matches: isDark,
media: query,
});
listeners.forEach(listener => listener.call({ matches: isDark, media: query } as MediaQueryList, event));
}
}
describe('Constructor - Initial State', () => {
it('should initialize with light theme when localStorage is empty and system prefers light', () => {
const manager = new ThemeManager();
expect(manager.value).toBe('light');
expect(manager.isDark).toBe(false);
expect(manager.source).toBe('system');
});
it('should initialize with system dark theme when localStorage is empty', () => {
// Mock system prefers dark theme
matchMediaSpy.mockImplementation((query: string) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
const manager = new ThemeManager();
expect(manager.value).toBe('dark');
expect(manager.isDark).toBe(true);
expect(manager.source).toBe('system');
});
it('should initialize with stored light theme from localStorage', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
const manager = new ThemeManager();
expect(manager.value).toBe('light');
expect(manager.isDark).toBe(false);
expect(manager.source).toBe('user');
});
it('should initialize with stored dark theme from localStorage', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
expect(manager.value).toBe('dark');
expect(manager.isDark).toBe(true);
expect(manager.source).toBe('user');
});
it('should ignore invalid values in localStorage and use system theme', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('invalid'));
const manager = new ThemeManager();
expect(manager.value).toBe('light');
expect(manager.source).toBe('system');
});
it('should handle null in localStorage as system theme', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(null));
const manager = new ThemeManager();
expect(manager.value).toBe('light');
expect(manager.source).toBe('system');
});
it('should be in user-controlled mode when localStorage has a valid theme', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
expect(manager.isUserControlled).toBe(true);
});
it('should not be in user-controlled mode when following system', () => {
const manager = new ThemeManager();
expect(manager.isUserControlled).toBe(false);
expect(manager.source).toBe('system');
});
});
describe('init() - Initialization', () => {
it('should apply initial theme to DOM on init', () => {
const manager = new ThemeManager();
manager.init();
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
});
it('should apply dark theme to DOM when initialized with dark theme', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.init();
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
});
it('should set up MediaQueryList listener on init', () => {
const manager = new ThemeManager();
manager.init();
expect(matchMediaSpy).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
});
it('should not fail if called multiple times', () => {
const manager = new ThemeManager();
expect(() => {
manager.init();
manager.init();
}).not.toThrow();
});
});
describe('destroy() - Cleanup', () => {
it('should remove MediaQueryList listener on destroy', () => {
const manager = new ThemeManager();
manager.init();
manager.destroy();
const listeners = mediaQueryListeners.get('(prefers-color-scheme: dark)');
expect(listeners?.size ?? 0).toBe(0);
});
it('should not fail if destroy is called before init', () => {
const manager = new ThemeManager();
expect(() => {
manager.destroy();
}).not.toThrow();
});
it('should not fail if destroy is called multiple times', () => {
const manager = new ThemeManager();
manager.init();
expect(() => {
manager.destroy();
manager.destroy();
}).not.toThrow();
});
});
describe('setTheme() - Set Explicit Theme', () => {
it('should set theme to light and update source to user', () => {
const manager = new ThemeManager();
manager.setTheme('light');
expect(manager.value).toBe('light');
expect(manager.isDark).toBe(false);
expect(manager.source).toBe('user');
});
it('should set theme to dark and update source to user', () => {
const manager = new ThemeManager();
manager.setTheme('dark');
expect(manager.value).toBe('dark');
expect(manager.isDark).toBe(true);
expect(manager.source).toBe('user');
});
it('should save theme to localStorage when set', async () => {
const manager = new ThemeManager();
manager.setTheme('dark');
await flushEffects();
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
});
it('should apply theme to DOM when set', () => {
const manager = new ThemeManager();
manager.setTheme('dark');
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
});
it('should overwrite existing localStorage value', async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
const manager = new ThemeManager();
manager.setTheme('dark');
await flushEffects();
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
});
it('should handle switching from light to dark', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
const manager = new ThemeManager();
manager.init();
manager.setTheme('dark');
expect(manager.value).toBe('dark');
expect(manager.source).toBe('user');
});
it('should handle switching from dark to light', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.init();
manager.setTheme('light');
expect(manager.value).toBe('light');
expect(manager.source).toBe('user');
});
});
describe('toggle() - Toggle Between Themes', () => {
it('should toggle from light to dark', () => {
const manager = new ThemeManager();
manager.toggle();
expect(manager.value).toBe('dark');
expect(manager.isDark).toBe(true);
expect(manager.source).toBe('user');
});
it('should toggle from dark to light', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.toggle();
expect(manager.value).toBe('light');
expect(manager.isDark).toBe(false);
expect(manager.source).toBe('user');
});
it('should save toggled theme to localStorage', async () => {
const manager = new ThemeManager();
manager.toggle();
await flushEffects();
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
});
it('should apply toggled theme to DOM', () => {
const manager = new ThemeManager();
manager.toggle();
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
});
it('should handle multiple rapid toggles', () => {
const manager = new ThemeManager();
manager.toggle();
expect(manager.value).toBe('dark');
manager.toggle();
expect(manager.value).toBe('light');
manager.toggle();
expect(manager.value).toBe('dark');
});
});
describe('resetToSystem() - Reset to System Preference', () => {
it('should clear localStorage when resetting to system', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.resetToSystem();
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('should set source to system after reset', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.resetToSystem();
expect(manager.source).toBe('system');
expect(manager.isUserControlled).toBe(false);
});
it('should detect and apply light system theme', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.resetToSystem();
expect(manager.value).toBe('light');
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
});
it('should detect and apply dark system theme', () => {
// Override matchMedia to return dark preference
matchMediaSpy.mockImplementation((query: string) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
const manager = new ThemeManager();
manager.resetToSystem();
expect(manager.value).toBe('dark');
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
});
it('should apply system theme to DOM on reset', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.resetToSystem();
expect(classListMock.toggle).toHaveBeenCalled();
});
});
describe('System Theme Change Handling', () => {
it('should update theme when system changes to dark while following system', () => {
const manager = new ThemeManager();
manager.init();
triggerSystemThemeChange(true);
expect(manager.value).toBe('dark');
expect(manager.isDark).toBe(true);
});
it('should update theme when system changes to light while following system', () => {
// Start with dark system theme
// Keep the listener tracking while overriding matches behavior
matchMediaSpy.mockImplementation((query: string) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
if (!mediaQueryListeners.has(query)) {
mediaQueryListeners.set(query, new Set());
}
mediaQueryListeners.get(query)!.add(listener);
}),
removeEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
if (mediaQueryListeners.has(query)) {
mediaQueryListeners.get(query)!.delete(listener);
}
}),
dispatchEvent: vi.fn(),
}));
const manager = new ThemeManager();
manager.init();
expect(manager.value).toBe('dark');
// Now change to light
triggerSystemThemeChange(false);
expect(manager.value).toBe('light');
expect(manager.isDark).toBe(false);
});
it('should update DOM when system theme changes while following system', () => {
const manager = new ThemeManager();
manager.init();
triggerSystemThemeChange(true);
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
});
it('should NOT update theme when system changes if user has set theme', () => {
const manager = new ThemeManager();
manager.setTheme('light'); // User explicitly sets light
manager.init();
// Simulate system changing to dark
triggerSystemThemeChange(true);
// Theme should remain light because user set it
expect(manager.value).toBe('light');
expect(manager.source).toBe('user');
});
it('should respond to system changes after resetToSystem', () => {
// Start with user-controlled dark theme
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.init();
expect(manager.value).toBe('dark');
expect(manager.source).toBe('user');
// Reset to system (which is light)
manager.resetToSystem();
expect(manager.value).toBe('light');
expect(manager.source).toBe('system');
// Now system changes to dark
triggerSystemThemeChange(true);
// Should update because we're back to following system
expect(manager.value).toBe('dark');
expect(manager.source).toBe('system');
});
it('should stop responding to system changes after setTheme is called', () => {
const manager = new ThemeManager();
manager.init();
// System changes to dark
triggerSystemThemeChange(true);
expect(manager.value).toBe('dark');
expect(manager.source).toBe('system');
// User explicitly sets light
manager.setTheme('light');
expect(manager.value).toBe('light');
expect(manager.source).toBe('user');
// System changes again
triggerSystemThemeChange(false);
// Should stay light because user set it
expect(manager.value).toBe('light');
});
it('should not trigger updates after destroy is called', () => {
const manager = new ThemeManager();
manager.init();
manager.destroy();
// This should not cause any updates since listener was removed
expect(() => {
triggerSystemThemeChange(true);
}).not.toThrow();
});
});
describe('DOM Interaction', () => {
it('should add dark class when applying dark theme', () => {
const manager = new ThemeManager();
manager.init();
manager.setTheme('dark');
// Check toggle was called with force=true for dark
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
});
it('should remove dark class when applying light theme', () => {
// Start with dark
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.init();
// Switch to light
manager.setTheme('light');
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
});
it('should not add dark class when system prefers light', () => {
const manager = new ThemeManager();
manager.init();
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
});
});
describe('Getter Properties', () => {
it('value getter should return current theme', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
expect(manager.value).toBe('dark');
});
it('source getter should return "user" when theme is user-controlled', () => {
const manager = new ThemeManager();
manager.setTheme('dark');
expect(manager.source).toBe('user');
});
it('source getter should return "system" when following system', () => {
const manager = new ThemeManager();
expect(manager.source).toBe('system');
});
it('isDark getter should return true for dark theme', () => {
const manager = new ThemeManager();
manager.setTheme('dark');
expect(manager.isDark).toBe(true);
});
it('isDark getter should return false for light theme', () => {
const manager = new ThemeManager();
expect(manager.isDark).toBe(false);
});
it('isUserControlled getter should return true when source is user', () => {
const manager = new ThemeManager();
manager.setTheme('light');
expect(manager.isUserControlled).toBe(true);
});
it('isUserControlled getter should return false when source is system', () => {
const manager = new ThemeManager();
expect(manager.isUserControlled).toBe(false);
});
});
describe('Edge Cases', () => {
it('should handle rapid setTheme calls', async () => {
const manager = new ThemeManager();
manager.setTheme('dark');
manager.setTheme('light');
manager.setTheme('dark');
manager.setTheme('light');
await flushEffects();
expect(manager.value).toBe('light');
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('light'));
});
it('should handle toggle immediately followed by setTheme', async () => {
const manager = new ThemeManager();
manager.toggle();
manager.setTheme('light');
await flushEffects();
expect(manager.value).toBe('light');
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('light'));
});
it('should handle setTheme immediately followed by resetToSystem', () => {
const manager = new ThemeManager();
manager.setTheme('dark');
manager.resetToSystem();
expect(manager.value).toBe('light');
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('should handle resetToSystem when already following system', () => {
const manager = new ThemeManager();
manager.resetToSystem();
expect(manager.value).toBe('light');
expect(manager.source).toBe('system');
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
});
describe('Type Safety', () => {
it('should accept "light" as valid theme', () => {
const manager = new ThemeManager();
expect(() => manager.setTheme('light')).not.toThrow();
});
it('should accept "dark" as valid theme', () => {
const manager = new ThemeManager();
expect(() => manager.setTheme('dark')).not.toThrow();
});
});
});

View File

@@ -23,25 +23,10 @@ const { Story } = defineMeta({
<script lang="ts">
import { themeManager } from '$features/ChangeAppTheme';
import {
onDestroy,
onMount,
} from 'svelte';
// Current theme state for display
const currentTheme = $derived(themeManager.value);
const themeSource = $derived(themeManager.source);
const isDark = $derived(themeManager.isDark);
// Initialize themeManager on mount
onMount(() => {
themeManager.init();
});
// Clean up themeManager when story unmounts
onDestroy(() => {
themeManager.destroy();
});
</script>
<Story name="Default">

View File

@@ -18,9 +18,9 @@ const theme = $derived(themeManager.value);
<IconButton onclick={() => themeManager.toggle()} size={responsive.isMobile ? 'sm' : 'md'} title="Toggle theme">
{#snippet icon()}
{#if theme === 'light'}
<MoonIcon />
<MoonIcon class={responsive.isMobile ? 'size-4' : 'size-5'} />
{:else}
<SunIcon />
<SunIcon class={responsive.isMobile ? 'size-4' : 'size-5'} />
{/if}
{/snippet}
</IconButton>

View File

@@ -20,11 +20,18 @@ import {
import { fly } from 'svelte/transition';
interface Props {
/** Font info */
/**
* Font info
*/
font: UnifiedFont;
/** Editable sample text */
/**
* Sample text
*/
text: string;
/** Position index — drives the staggered entrance delay */
/**
* Position index
* @default 0
*/
index?: number;
}

View File

@@ -9,6 +9,8 @@
import { api } from '$shared/api/api';
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const;
/**
* Filter metadata type from backend
*/
@@ -73,7 +75,7 @@ export interface ProxyFiltersResponse {
* ```
*/
export async function fetchProxyFilters(): Promise<FilterMetadata[]> {
const response = await api.get<FilterMetadata[]>('/api/v1/filters');
const response = await api.get<FilterMetadata[]>(PROXY_API_URL);
if (!response.data || !Array.isArray(response.data)) {
throw new Error('Proxy API returned invalid response');

View File

@@ -0,0 +1 @@
export * from './filters/filters';

View File

@@ -4,14 +4,16 @@ export {
mapManagerToParams,
} from './lib';
export {
FONT_CATEGORIES,
FONT_PROVIDERS,
FONT_SUBSETS,
} from './model/const/const';
export { filterManager } from './model/state/manager.svelte';
export {
SORT_MAP,
SORT_OPTIONS,
type SortApiValue,
type SortOption,
sortStore,
} from './model/store/sortStore.svelte';
export {
FilterControls,
Filters,

View File

@@ -1,14 +1,40 @@
/**
* Filter manager for font filtering
*
* Manages multiple filter groups (providers, categories, subsets)
* with debounced search input. Provides reactive state for filter
* selections and convenience methods for bulk operations.
*
* @example
* ```ts
* const manager = createFilterManager({
* queryValue: '',
* groups: [
* { id: 'providers', label: 'Provider', properties: [...] },
* { id: 'categories', label: 'Category', properties: [...] }
* ]
* });
*
* $: searchQuery = manager.debouncedQueryValue;
* $: hasFilters = manager.hasAnySelection;
* ```
*/
import { createFilter } from '$shared/lib';
import { createDebouncedState } from '$shared/lib/helpers';
import type { FilterConfig } from '../../model';
import type {
FilterConfig,
FilterGroupConfig,
} from '../../model';
/**
* Create a filter manager instance.
* - Uses debounce to update search query for better performance.
* - Manages filter instances for each group.
* Creates a filter manager instance
*
* @param config - Configuration for the filter manager.
* @returns - An instance of the filter manager.
* Manages multiple filter groups with debounced search. Each group
* contains filterable properties that can be selected/deselected.
*
* @param config - Configuration with query value and filter groups
* @returns Filter manager instance with reactive state and methods
*/
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
const search = createDebouncedState(config.queryValue ?? '');
@@ -28,37 +54,68 @@ export function createFilterManager<TValue extends string>(config: FilterConfig<
);
return {
// Getter for queryValue (immediate value for UI)
/**
* Replace all filter groups with new config
* Used when dynamic filter data loads from backend
*/
setGroups(newGroups: FilterGroupConfig<TValue>[]) {
groups.length = 0;
groups.push(
...newGroups.map(g => ({
id: g.id,
label: g.label,
instance: createFilter({ properties: g.properties }),
})),
);
},
/**
* Current search query value (immediate, for UI binding)
* Updates instantly as user types
*/
get queryValue() {
return search.immediate;
},
// Setter for queryValue
/**
* Set the search query value
*/
set queryValue(value) {
search.immediate = value;
},
// Getter for queryValue (debounced value for logic)
/**
* Debounced search query value (for API calls)
* Updates after delay to reduce API requests
*/
get debouncedQueryValue() {
return search.debounced;
},
// Direct array reference (reactive)
/**
* All filter groups (reactive)
*/
get groups() {
return groups;
},
// Derived values
/**
* Whether any filter has an active selection
*/
get hasAnySelection() {
return hasAnySelection;
},
// Global action
/**
* Deselect all filters across all groups
*/
deselectAllGlobal: () => {
groups.forEach(group => group.instance.deselectAll());
},
// Helper to get group by id
/**
* Get a specific filter group by ID
* @param id - Group identifier
*/
getGroup: (id: string) => {
return groups.find(g => g.id === id);
},

View File

@@ -0,0 +1,784 @@
import type { Property } from '$shared/lib';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { createFilterManager } from './filterManager.svelte';
/**
* Test Suite for createFilterManager Helper Function
*
* This suite tests the filter manager logic including:
* - Debounced query state (immediate vs delayed)
* - Filter group creation and management
* - hasAnySelection derived state
* - getGroup() method
* - deselectAllGlobal() method
*
* Mocking Strategy:
* - We test the actual implementation without mocking createDebouncedState
* and createFilter since they are simple reactive helpers
* - For timing tests, we use vi.useFakeTimers() to control debounce delays
*
* NOTE: Svelte 5's $derived runs in microtasks, so we need to flush effects
* after state changes to test reactive behavior. This is a limitation of unit
* testing Svelte 5 reactive code in Node.js.
*/
// Helper to flush Svelte effects (they run in microtasks)
async function flushEffects() {
await Promise.resolve();
await Promise.resolve();
}
// Helper to create test properties
function createTestProperties(count: number, selectedIndices: number[] = []): Property<string>[] {
return Array.from({ length: count }, (_, i) => ({
id: `prop-${i}`,
name: `Property ${i}`,
value: `value-${i}`,
selected: selectedIndices.includes(i),
}));
}
// Helper to create test filter groups
function createTestGroups(count: number, propertiesPerGroup = 3) {
return Array.from({ length: count }, (_, i) => ({
id: `group-${i}`,
label: `Group ${i}`,
properties: createTestProperties(propertiesPerGroup),
}));
}
describe('createFilterManager - Initialization', () => {
it('creates manager with empty query value', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(2),
});
expect(manager.queryValue).toBe('');
expect(manager.debouncedQueryValue).toBe('');
});
it('creates manager with initial query value', () => {
const manager = createFilterManager({
queryValue: 'search term',
groups: createTestGroups(1),
});
expect(manager.queryValue).toBe('search term');
expect(manager.debouncedQueryValue).toBe('search term');
});
it('creates manager with undefined query value (defaults to empty string)', () => {
const manager = createFilterManager({
groups: createTestGroups(1),
});
expect(manager.queryValue).toBe('');
expect(manager.debouncedQueryValue).toBe('');
});
it('creates filter groups for each config group', () => {
const groups = createTestGroups(3);
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.groups).toHaveLength(3);
expect(manager.groups[0].id).toBe('group-0');
expect(manager.groups[1].id).toBe('group-1');
expect(manager.groups[2].id).toBe('group-2');
});
it('creates filter instances for each group', () => {
const groups = createTestGroups(2, 5);
const manager = createFilterManager({
queryValue: '',
groups,
});
manager.groups.forEach(group => {
expect(group.instance).toBeDefined();
expect(group.instance.properties).toHaveLength(5);
expect(typeof group.instance.toggleProperty).toBe('function');
expect(typeof group.instance.selectAll).toBe('function');
expect(typeof group.instance.deselectAll).toBe('function');
});
});
it('preserves group labels', () => {
const groups = [
{ id: 'providers', label: 'Providers', properties: createTestProperties(2) },
{ id: 'categories', label: 'Categories', properties: createTestProperties(3) },
];
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.groups[0].label).toBe('Providers');
expect(manager.groups[1].label).toBe('Categories');
});
it('handles single group', () => {
const groups = createTestGroups(1);
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.groups).toHaveLength(1);
expect(manager.groups[0].id).toBe('group-0');
});
});
describe('createFilterManager - Debounced Query', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('immediate query value updates instantly', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
manager.queryValue = 'new search';
expect(manager.queryValue).toBe('new search');
expect(manager.debouncedQueryValue).toBe('');
});
it('debounced query value updates after default delay (300ms)', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
manager.queryValue = 'search term';
expect(manager.debouncedQueryValue).toBe('');
vi.advanceTimersByTime(299);
expect(manager.debouncedQueryValue).toBe('');
vi.advanceTimersByTime(1);
expect(manager.debouncedQueryValue).toBe('search term');
});
it('rapid query changes reset the debounce timer', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
manager.queryValue = 'a';
vi.advanceTimersByTime(100);
manager.queryValue = 'ab';
vi.advanceTimersByTime(100);
manager.queryValue = 'abc';
vi.advanceTimersByTime(100);
expect(manager.debouncedQueryValue).toBe('');
expect(manager.queryValue).toBe('abc');
vi.advanceTimersByTime(200);
expect(manager.debouncedQueryValue).toBe('abc');
});
it('handles empty string in query', () => {
const manager = createFilterManager({
queryValue: 'initial',
groups: createTestGroups(1),
});
manager.queryValue = '';
vi.advanceTimersByTime(300);
expect(manager.queryValue).toBe('');
expect(manager.debouncedQueryValue).toBe('');
});
it('preserves initial query value until changed', () => {
const manager = createFilterManager({
queryValue: 'initial search',
groups: createTestGroups(1),
});
expect(manager.queryValue).toBe('initial search');
expect(manager.debouncedQueryValue).toBe('initial search');
vi.advanceTimersByTime(500);
expect(manager.queryValue).toBe('initial search');
expect(manager.debouncedQueryValue).toBe('initial search');
});
});
describe('createFilterManager - hasAnySelection Derived State', () => {
it('returns false when no filters are selected', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(3, 3),
});
expect(manager.hasAnySelection).toBe(false);
});
it('returns true when one filter in one group is selected', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
manager.groups[0].instance.selectProperty('prop-0');
// Verify underlying state changed
expect(manager.groups[0].instance.selectedCount).toBe(1);
// hasAnySelection derived state requires reactive environment
// This is tested in component/E2E tests
});
it('returns true when multiple filters across groups are selected', () => {
const groups = createTestGroups(3, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
manager.groups[0].instance.selectProperty('prop-0');
manager.groups[1].instance.selectProperty('prop-1');
manager.groups[2].instance.selectProperty('prop-2');
// Verify underlying state changed
expect(manager.groups[0].instance.selectedCount).toBe(1);
expect(manager.groups[1].instance.selectedCount).toBe(1);
expect(manager.groups[2].instance.selectedCount).toBe(1);
});
it('returns false after deselecting all filters', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
manager.groups[0].instance.selectProperty('prop-0');
expect(manager.groups[0].instance.selectedCount).toBe(1);
manager.groups[0].instance.deselectAll();
expect(manager.groups[0].instance.selectedCount).toBe(0);
});
it('reacts to selection changes in individual groups', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
manager.groups[0].instance.selectProperty('prop-0');
expect(manager.groups[0].instance.selectedCount).toBe(1);
manager.groups[1].instance.selectProperty('prop-1');
expect(manager.groups[1].instance.selectedCount).toBe(1);
manager.groups[0].instance.deselectProperty('prop-0');
expect(manager.groups[0].instance.selectedCount).toBe(0);
expect(manager.groups[1].instance.selectedCount).toBe(1); // Still selected
manager.groups[1].instance.deselectProperty('prop-1');
expect(manager.groups[1].instance.selectedCount).toBe(0);
});
it('handles groups with initially selected properties', () => {
const groups = [
{
id: 'group-0',
label: 'Group 0',
properties: createTestProperties(3, [0, 1]),
},
{
id: 'group-1',
label: 'Group 1',
properties: createTestProperties(3, []),
},
];
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.hasAnySelection).toBe(true);
});
it('returns false when all groups are empty', () => {
const groups = [
{ id: 'group-0', label: 'Group 0', properties: [] },
{ id: 'group-1', label: 'Group 1', properties: [] },
];
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.hasAnySelection).toBe(false);
});
});
describe('createFilterManager - getGroup() Method', () => {
it('returns the correct group by ID', () => {
const groups = createTestGroups(3);
const manager = createFilterManager({
queryValue: '',
groups,
});
const group = manager.getGroup('group-1');
expect(group).toBeDefined();
expect(group?.id).toBe('group-1');
expect(group?.label).toBe('Group 1');
});
it('returns undefined for non-existent group ID', () => {
const groups = createTestGroups(2);
const manager = createFilterManager({
queryValue: '',
groups,
});
const group = manager.getGroup('non-existent');
expect(group).toBeUndefined();
});
it('returns group with accessible filter instance', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
const group = manager.getGroup('group-0');
expect(group?.instance).toBeDefined();
expect(group?.instance.properties).toHaveLength(3);
group?.instance.selectProperty('prop-0');
expect(group?.instance.selectedProperties).toHaveLength(1);
});
it('returns first group when requested', () => {
const groups = createTestGroups(3);
const manager = createFilterManager({
queryValue: '',
groups,
});
const group = manager.getGroup('group-0');
expect(group?.id).toBe('group-0');
expect(group?.label).toBe('Group 0');
});
it('returns last group when requested', () => {
const groups = createTestGroups(5);
const manager = createFilterManager({
queryValue: '',
groups,
});
const group = manager.getGroup('group-4');
expect(group?.id).toBe('group-4');
expect(group?.label).toBe('Group 4');
});
});
describe('createFilterManager - deselectAllGlobal() Method', () => {
it('deselects all filters across all groups', () => {
const groups = createTestGroups(3, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
// Select some filters in each group
manager.groups[0].instance.selectProperty('prop-0');
manager.groups[1].instance.selectProperty('prop-1');
manager.groups[2].instance.selectProperty('prop-2');
expect(manager.groups[0].instance.selectedCount).toBe(1);
expect(manager.groups[1].instance.selectedCount).toBe(1);
expect(manager.groups[2].instance.selectedCount).toBe(1);
manager.deselectAllGlobal();
expect(manager.groups[0].instance.selectedCount).toBe(0);
expect(manager.groups[1].instance.selectedCount).toBe(0);
expect(manager.groups[2].instance.selectedCount).toBe(0);
});
it('handles deselecting when nothing is selected', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(() => manager.deselectAllGlobal()).not.toThrow();
expect(manager.groups[0].instance.selectedCount).toBe(0);
expect(manager.groups[1].instance.selectedCount).toBe(0);
expect(manager.hasAnySelection).toBe(false);
});
it('handles deselecting with empty groups', () => {
const groups = [
{ id: 'group-0', label: 'Group 0', properties: [] },
{ id: 'group-1', label: 'Group 1', properties: [] },
];
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(() => manager.deselectAllGlobal()).not.toThrow();
expect(manager.hasAnySelection).toBe(false);
});
it('can select filters after global deselect', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
// Select and then deselect
manager.groups[0].instance.selectProperty('prop-0');
expect(manager.groups[0].instance.selectedCount).toBe(1);
manager.deselectAllGlobal();
expect(manager.groups[0].instance.selectedCount).toBe(0);
// Select again
manager.groups[0].instance.selectProperty('prop-1');
expect(manager.groups[0].instance.selectedCount).toBe(1);
});
it('handles partially selected groups', () => {
const groups = createTestGroups(3, 5);
const manager = createFilterManager({
queryValue: '',
groups,
});
// Partial selection in each group
manager.groups[0].instance.selectProperty('prop-0');
manager.groups[0].instance.selectProperty('prop-1');
manager.groups[1].instance.selectProperty('prop-2');
manager.groups[2].instance.selectProperty('prop-4');
expect(manager.groups[0].instance.selectedCount).toBe(2);
expect(manager.groups[1].instance.selectedCount).toBe(1);
expect(manager.groups[2].instance.selectedCount).toBe(1);
manager.deselectAllGlobal();
expect(manager.groups[0].instance.selectedCount).toBe(0);
expect(manager.groups[1].instance.selectedCount).toBe(0);
expect(manager.groups[2].instance.selectedCount).toBe(0);
});
});
describe('createFilterManager - Complex Scenarios', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('handles query changes and filter selections together', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
manager.queryValue = 'search';
manager.groups[0].instance.selectProperty('prop-0');
expect(manager.queryValue).toBe('search');
expect(manager.groups[0].instance.selectedCount).toBe(1);
expect(manager.debouncedQueryValue).toBe('');
vi.advanceTimersByTime(300);
expect(manager.debouncedQueryValue).toBe('search');
});
it('handles real-world filtering workflow', () => {
const groups = [
{
id: 'categories',
label: 'Categories',
properties: [
{ id: 'sans', name: 'Sans Serif', value: 'sans-serif' },
{ id: 'serif', name: 'Serif', value: 'serif' },
{ id: 'display', name: 'Display', value: 'display' },
],
},
{
id: 'subsets',
label: 'Subsets',
properties: [
{ id: 'latin', name: 'Latin', value: 'latin' },
{ id: 'cyrillic', name: 'Cyrillic', value: 'cyrillic' },
],
},
];
const manager = createFilterManager({
queryValue: '',
groups,
});
// Initial state
expect(manager.hasAnySelection).toBe(false);
// Select a category
const categoryGroup = manager.getGroup('categories');
categoryGroup?.instance.selectProperty('sans');
expect(categoryGroup?.instance.selectedCount).toBe(1);
// Type in search
manager.queryValue = 'roboto';
expect(manager.queryValue).toBe('roboto');
expect(manager.debouncedQueryValue).toBe('');
// Wait for debounce
vi.advanceTimersByTime(300);
expect(manager.debouncedQueryValue).toBe('roboto');
// Clear all filters
manager.deselectAllGlobal();
expect(categoryGroup?.instance.selectedCount).toBe(0);
});
it('manages multiple independent filter groups correctly', () => {
const groups = createTestGroups(4, 5);
const manager = createFilterManager({
queryValue: '',
groups,
});
// Select different filters in different groups
manager.groups[0].instance.selectProperty('prop-0');
manager.groups[1].instance.selectAll();
manager.groups[2].instance.selectProperty('prop-2');
expect(manager.groups[0].instance.selectedCount).toBe(1);
expect(manager.groups[1].instance.selectedCount).toBe(5);
expect(manager.groups[2].instance.selectedCount).toBe(1);
expect(manager.groups[3].instance.selectedCount).toBe(0);
// Deselect all globally
manager.deselectAllGlobal();
manager.groups.forEach(group => {
expect(group.instance.selectedCount).toBe(0);
});
});
it('handles toggle operations via getGroup', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
queryValue: '',
groups,
});
const group = manager.getGroup('group-0');
expect(group?.instance.selectedCount).toBe(0);
group?.instance.toggleProperty('prop-0');
expect(group?.instance.selectedCount).toBe(1);
group?.instance.toggleProperty('prop-0');
expect(group?.instance.selectedCount).toBe(0);
});
});
describe('createFilterManager - Interface Compliance', () => {
it('exposes queryValue getter', () => {
const manager = createFilterManager({
queryValue: 'test',
groups: createTestGroups(1),
});
expect(() => {
const _ = manager.queryValue;
}).not.toThrow();
});
it('exposes queryValue setter', () => {
const manager = createFilterManager({
queryValue: 'test',
groups: createTestGroups(1),
});
expect(() => {
manager.queryValue = 'new value';
}).not.toThrow();
});
it('exposes debouncedQueryValue getter', () => {
const manager = createFilterManager({
queryValue: 'test',
groups: createTestGroups(1),
});
expect(() => {
const _ = manager.debouncedQueryValue;
}).not.toThrow();
});
it('exposes groups getter', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
expect(() => {
const _ = manager.groups;
}).not.toThrow();
});
it('exposes hasAnySelection getter', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
expect(() => {
const _ = manager.hasAnySelection;
}).not.toThrow();
});
it('exposes getGroup method', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
expect(typeof manager.getGroup).toBe('function');
});
it('exposes deselectAllGlobal method', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
expect(typeof manager.deselectAllGlobal).toBe('function');
});
it('does not expose debouncedQueryValue setter', () => {
const manager = createFilterManager({
queryValue: '',
groups: createTestGroups(1),
});
// TypeScript should prevent this, but we can check the runtime behavior
expect(manager).not.toHaveProperty('set debouncedQueryValue');
});
});
describe('createFilterManager - Edge Cases', () => {
it('handles single property groups', () => {
const groups: Array<{
id: string;
label: string;
properties: Property<string>[];
}> = [
{
id: 'single-prop-group',
label: 'Single Property',
properties: [{ id: 'only', name: 'Only', value: 'only-value' }],
},
];
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.groups).toHaveLength(1);
expect(manager.groups[0].instance.properties).toHaveLength(1);
expect(manager.hasAnySelection).toBe(false);
manager.groups[0].instance.selectProperty('only');
expect(manager.groups[0].instance.selectedCount).toBe(1);
});
it('handles groups with duplicate property IDs (same ID, different groups)', () => {
const groups = [
{
id: 'group-0',
label: 'Group 0',
properties: [{ id: 'same-id', name: 'Same 0', value: 'value-0' }],
},
{
id: 'group-1',
label: 'Group 1',
properties: [{ id: 'same-id', name: 'Same 1', value: 'value-1' }],
},
];
const manager = createFilterManager({
queryValue: '',
groups,
});
// Each group should have its own filter instance
expect(manager.groups[0].instance.properties[0].id).toBe('same-id');
expect(manager.groups[1].instance.properties[0].id).toBe('same-id');
// Selecting in one group should not affect the other
manager.groups[0].instance.selectProperty('same-id');
expect(manager.groups[0].instance.selectedCount).toBe(1);
expect(manager.groups[1].instance.selectedCount).toBe(0);
});
it('handles initially selected properties in groups', () => {
const groups = [
{
id: 'preselected',
label: 'Preselected',
properties: createTestProperties(3, [0, 2]),
},
];
const manager = createFilterManager({
queryValue: '',
groups,
});
expect(manager.hasAnySelection).toBe(true);
expect(manager.groups[0].instance.selectedCount).toBe(2);
});
});

View File

@@ -3,4 +3,13 @@ export type {
FilterGroupConfig,
} from './types/filter';
export { filtersStore } from './state/filters.svelte';
export { filterManager } from './state/manager.svelte';
export {
SORT_MAP,
SORT_OPTIONS,
type SortApiValue,
type SortOption,
sortStore,
} from './store/sortStore.svelte';

View File

@@ -15,8 +15,8 @@
* ```
*/
import { fetchProxyFilters } from '$entities/Font/api/proxy/filters';
import type { FilterMetadata } from '$entities/Font/api/proxy/filters';
import { fetchProxyFilters } from '$features/GetFonts/api/filters/filters';
import type { FilterMetadata } from '$features/GetFonts/api/filters/filters';
import { queryClient } from '$shared/api/queryClient';
import {
type QueryKey,
@@ -32,9 +32,6 @@ import {
* Provides reactive access to filter data
*/
class FiltersStore {
/** Cleanup function for effects */
cleanup: () => void;
/** TanStack Query result state */
protected result = $state<QueryObserverResult<FilterMetadata[], Error>>({} as any);
@@ -54,13 +51,6 @@ class FiltersStore {
this.observer.subscribe(r => {
this.result = r;
});
// Sync Svelte state changes -> TanStack Query options
this.cleanup = $effect.root(() => {
$effect(() => {
this.observer.setOptions(this.getOptions());
});
});
}
/**
@@ -119,10 +109,10 @@ class FiltersStore {
}
/**
* Clean up effects and observers
* Clean up observer subscription
*/
destroy() {
this.cleanup();
this.observer.destroy();
}
}

View File

@@ -1,39 +1,39 @@
/**
* Filter manager singleton
*
* Creates filterManager with empty groups initially, then reactively
* populates groups when filtersStore loads data from backend.
*/
import { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
import { filtersStore } from './filters.svelte';
export const filterManager = createFilterManager({
queryValue: '',
groups: [],
});
/**
* Creates initial filter config
*
* Uses dynamic filters from backend with empty state initially
* Reactively sync backend filter metadata into filterManager groups.
* When filtersStore.filters resolves, setGroups replaces the empty groups.
*/
function createInitialConfig() {
const dynamicFilters = filtersStore.filters;
$effect.root(() => {
$effect(() => {
const dynamicFilters = filtersStore.filters;
// If filters are loaded, use them
if (dynamicFilters.length > 0) {
return {
queryValue: '',
groups: dynamicFilters.map(filter => ({
id: filter.id,
label: filter.name,
properties: filter.options.map(opt => ({
id: opt.id,
name: opt.name,
value: opt.value,
count: opt.count,
selected: false,
if (dynamicFilters.length > 0) {
filterManager.setGroups(
dynamicFilters.map(filter => ({
id: filter.id,
label: filter.name,
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
id: opt.id,
name: opt.name,
value: opt.value,
selected: false,
})),
})),
})),
};
}
// No filters loaded yet - return empty state
return {
queryValue: '',
groups: [],
};
}
const initialConfig = createInitialConfig();
export const filterManager = createFilterManager(initialConfig);
);
}
});
});

View File

@@ -0,0 +1,41 @@
/**
* Sort store — manages the current sort option for font listings.
*
* Display labels are mapped to API values through SORT_MAP so that
* the UI layer never has to know about the wire format.
*/
export type SortOption = 'Name' | 'Popularity' | 'Newest';
export const SORT_OPTIONS: SortOption[] = ['Name', 'Popularity', 'Newest'] as const;
export const SORT_MAP: Record<SortOption, 'name' | 'popularity' | 'lastModified'> = {
Name: 'name',
Popularity: 'popularity',
Newest: 'lastModified',
};
export type SortApiValue = (typeof SORT_MAP)[SortOption];
function createSortStore(initial: SortOption = 'Popularity') {
let current = $state<SortOption>(initial);
return {
/** Current display label (e.g. 'Popularity') */
get value() {
return current;
},
/** Mapped API value (e.g. 'popularity') */
get apiValue(): SortApiValue {
return SORT_MAP[current];
},
/** Set the active sort option by its display label */
set(option: SortOption) {
current = option;
},
};
}
export const sortStore = createSortStore();

View File

@@ -4,28 +4,41 @@
Sits below the filter list, separated by a top border.
-->
<script lang="ts">
import { unifiedFontStore } from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Button } from '$shared/ui';
import { Label } from '$shared/ui';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { filterManager } from '../../model';
type SortOption = 'Name' | 'Popularity' | 'Newest';
const SORT_OPTIONS: SortOption[] = ['Name', 'Popularity', 'Newest'];
import {
getContext,
untrack,
} from 'svelte';
import {
SORT_OPTIONS,
filterManager,
sortStore,
} from '../../model';
interface Props {
sort?: SortOption;
onSortChange?: (v: SortOption) => void;
/**
* CSS classes
*/
class?: string;
}
const {
sort = 'Popularity',
onSortChange,
class: className,
}: Props = $props();
$effect(() => {
const apiSort = sortStore.apiValue;
untrack(() => unifiedFontStore.setSort(apiSort));
});
const responsive = getContext<ResponsiveManager>('responsive');
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
function handleReset() {
filterManager.deselectAllGlobal();
}
@@ -34,28 +47,27 @@ function handleReset() {
<div
class={cn(
'flex flex-col md:flex-row justify-between items-start md:items-center',
'gap-4 md:gap-6',
'gap-1 md:gap-6',
'pt-6 mt-6 md:pt-8 md:mt-8',
'border-t border-foreground/5 dark:border-white/10',
className,
)}
>
<!-- Left: Sort By label + options -->
<div class="flex flex-col md:flex-row items-start md:items-center gap-3 md:gap-8 w-full md:w-auto">
<!-- Sort By label + options -->
<div class="flex flex-col md:flex-row items-start md:items-center gap-2 md:gap-8 w-full md:w-auto">
<Label variant="muted" size="sm">Sort By:</Label>
<div class="flex gap-3 md:gap-4">
{#each SORT_OPTIONS as option}
<!--
Ghost button with red-only hover (no bg lift).
active prop turns text [#ff3b30] for the selected sort.
class overrides: Space_Grotesk bold tracking-wide, no padding bg.
-->
<Button
variant="ghost"
active={sort === option}
onclick={() => onSortChange?.(option)}
class="text-xs font-bold uppercase tracking-wide font-primary"
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
active={sortStore.value === option}
onclick={() => sortStore.set(option)}
class={cn(
'font-bold uppercase tracking-wide font-primary, px-0',
isMobileOrTabletPortrait ? 'text-[0.5625rem]' : 'text-[0.625rem]',
)}
>
{option}
</Button>
@@ -63,23 +75,19 @@ function handleReset() {
</div>
</div>
<!-- Right: Reset_Filters -->
<!--
Bare ghost, red hover. Space_Mono to match Swiss technical text pattern.
Icon uses CSS group-hover for the spin — no Tween needed.
-->
<!-- Reset_Filters -->
<Button
variant="ghost"
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
onclick={handleReset}
class="
group
text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest
text-neutral-400
"
class={cn(
'group text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest text-neutral-400',
isMobileOrTabletPortrait && 'px-0',
)}
iconPosition="left"
>
{#snippet icon()}
<RefreshCwIcon class="size-4 transition-transform duration-300 group-hover:rotate-180" />
<RefreshCwIcon class="size-3 transition-transform duration-300 group-hover:rotate-180" />
{/snippet}
Reset_Filters
</Button>

View File

@@ -1,3 +1,15 @@
/**
* Typography control manager
*
* Manages a collection of typography controls (font size, weight, line height,
* letter spacing) with persistent storage. Supports responsive scaling
* through a multiplier system.
*
* The font size control uses a multiplier system to allow responsive scaling
* while preserving the user's base size preference. The multiplier is applied
* when displaying/editing, but the base size is what's stored.
*/
import {
type ControlDataModel,
type ControlModel,
@@ -17,14 +29,37 @@ import {
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
/**
* A control with its instance
*/
export interface Control extends ControlOnlyFields<ControlId> {
instance: TypographyControl;
}
/**
* Storage schema for typography settings
*/
export interface TypographySettings {
fontSize: number;
fontWeight: number;
lineHeight: number;
letterSpacing: number;
}
/**
* Typography control manager class
*
* Manages multiple typography controls with persistent storage and
* responsive scaling support for font size.
*/
export class TypographyControlManager {
/** Map of controls keyed by ID */
#controls = new SvelteMap<string, Control>();
/** Responsive multiplier for font size display */
#multiplier = $state(1);
/** Persistent storage for settings */
#storage: PersistentStore<TypographySettings>;
/** Base font size (user preference, unscaled) */
#baseSize = $state(DEFAULT_FONT_SIZE);
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
@@ -85,6 +120,9 @@ export class TypographyControlManager {
});
}
/**
* Gets initial value for a control from storage or defaults
*/
#getInitialValue(id: string, saved: TypographySettings): number {
if (id === 'font_size') return saved.fontSize * this.#multiplier;
if (id === 'font_weight') return saved.fontWeight;
@@ -93,11 +131,17 @@ export class TypographyControlManager {
return 0;
}
// --- Getters / Setters ---
/** Current multiplier for responsive scaling */
get multiplier() {
return this.#multiplier;
}
/**
* Set the multiplier and update font size display
*
* When multiplier changes, the font size control's display value
* is updated to reflect the new scale while preserving base size.
*/
set multiplier(value: number) {
if (this.#multiplier === value) return;
this.#multiplier = value;
@@ -109,7 +153,10 @@ export class TypographyControlManager {
}
}
/** The scaled size for CSS usage */
/**
* The scaled size for CSS usage
* Returns baseSize * multiplier for actual rendering
*/
get renderedSize() {
return this.#baseSize * this.#multiplier;
}
@@ -118,6 +165,7 @@ export class TypographyControlManager {
get baseSize() {
return this.#baseSize;
}
set baseSize(val: number) {
this.#baseSize = val;
const ctrl = this.#controls.get('font_size')?.instance;
@@ -162,6 +210,9 @@ export class TypographyControlManager {
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
}
/**
* Reset all controls to default values
*/
reset() {
this.#storage.clear();
const defaults = this.#storage.value;
@@ -185,21 +236,11 @@ export class TypographyControlManager {
}
/**
* Storage schema for typography settings
*/
export interface TypographySettings {
fontSize: number;
fontWeight: number;
lineHeight: number;
letterSpacing: number;
}
/**
* Creates a typography control manager that handles a collection of typography controls.
* Creates a typography control manager
*
* @param configs - Array of control configurations.
* @param storageId - Persistent storage identifier.
* @returns - Typography control manager instance.
* @param configs - Array of control configurations
* @param storageId - Persistent storage identifier
* @returns Typography control manager instance
*/
export function createTypographyControlManager(
configs: ControlModel<ControlId>[],

View File

@@ -0,0 +1,723 @@
/** @vitest-environment jsdom */
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} from '../../model';
import {
TypographyControlManager,
type TypographySettings,
} from './controlManager.svelte';
/**
* Test Strategy for TypographyControlManager
*
* This test suite validates the TypographyControlManager state management logic.
* These are unit tests for the manager logic, separate from component rendering.
*
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
* after state changes to test reactive behavior. This is a limitation of unit
* testing Svelte 5 reactive code in Node.js.
*
* Test Coverage:
* 1. Initialization: Loading from storage, creating controls with correct values
* 2. Multiplier System: Changing multiplier updates font size display
* 3. Base Size Proxy: UI changes update #baseSize via the proxy effect
* 4. Storage Sync: Changes to controls sync to storage (via $effect)
* 5. Reset Functionality: Clearing storage resets all controls
* 6. Rendered Size: base * multiplier calculation
* 7. Control Getters: Return correct control instances
*/
// Helper to flush Svelte effects (they run in microtasks)
async function flushEffects() {
await Promise.resolve();
await Promise.resolve();
}
describe('TypographyControlManager - Unit Tests', () => {
let mockStorage: TypographySettings;
let mockPersistentStore: {
value: TypographySettings;
clear: () => void;
};
const createMockPersistentStore = (initialValue: TypographySettings) => {
let value = initialValue;
return {
get value() {
return value;
},
set value(v: TypographySettings) {
value = v;
},
clear() {
value = {
fontSize: DEFAULT_FONT_SIZE,
fontWeight: DEFAULT_FONT_WEIGHT,
lineHeight: DEFAULT_LINE_HEIGHT,
letterSpacing: DEFAULT_LETTER_SPACING,
};
},
};
};
beforeEach(() => {
// Reset mock storage with default values before each test
mockStorage = {
fontSize: DEFAULT_FONT_SIZE,
fontWeight: DEFAULT_FONT_WEIGHT,
lineHeight: DEFAULT_LINE_HEIGHT,
letterSpacing: DEFAULT_LETTER_SPACING,
};
mockPersistentStore = createMockPersistentStore(mockStorage);
});
describe('Initialization', () => {
it('creates manager with default values from storage', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE);
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
});
it('creates manager with saved values from storage', () => {
mockStorage = {
fontSize: 72,
fontWeight: 700,
lineHeight: 1.8,
letterSpacing: 0.05,
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.baseSize).toBe(72);
expect(manager.weight).toBe(700);
expect(manager.height).toBe(1.8);
expect(manager.spacing).toBe(0.05);
});
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE);
});
it('returns all controls via controls getter', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
const controls = manager.controls;
expect(controls).toHaveLength(4);
expect(controls.map(c => c.id)).toEqual([
'font_size',
'font_weight',
'line_height',
'letter_spacing',
]);
});
it('returns individual controls via specific getters', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.sizeControl).toBeDefined();
expect(manager.weightControl).toBeDefined();
expect(manager.heightControl).toBeDefined();
expect(manager.spacingControl).toBeDefined();
// Control instances have value, min, max, step, isAtMax, isAtMin, increase, decrease
expect(manager.sizeControl).toHaveProperty('value');
expect(manager.weightControl).toHaveProperty('value');
expect(manager.heightControl).toHaveProperty('value');
expect(manager.spacingControl).toHaveProperty('value');
});
it('control instances have expected interface', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
const ctrl = manager.sizeControl!;
expect(typeof ctrl.value).toBe('number');
expect(typeof ctrl.min).toBe('number');
expect(typeof ctrl.max).toBe('number');
expect(typeof ctrl.step).toBe('number');
expect(typeof ctrl.isAtMax).toBe('boolean');
expect(typeof ctrl.isAtMin).toBe('boolean');
expect(typeof ctrl.increase).toBe('function');
expect(typeof ctrl.decrease).toBe('function');
});
});
describe('Multiplier System', () => {
it('has default multiplier of 1', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.multiplier).toBe(1);
});
it('updates multiplier when set', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.multiplier = 0.75;
expect(manager.multiplier).toBe(0.75);
manager.multiplier = 0.5;
expect(manager.multiplier).toBe(0.5);
});
it('does not update multiplier if set to same value', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
const originalSizeValue = manager.sizeControl?.value;
manager.multiplier = 1; // Same as default
expect(manager.sizeControl?.value).toBe(originalSizeValue);
});
it('updates font size control display value when multiplier changes', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Initial state: base = 48, multiplier = 1, display = 48
expect(manager.baseSize).toBe(48);
expect(manager.sizeControl?.value).toBe(48);
// Change multiplier to 0.75
manager.multiplier = 0.75;
// Display should be 48 * 0.75 = 36
expect(manager.sizeControl?.value).toBe(36);
// Change multiplier to 0.5
manager.multiplier = 0.5;
// Display should be 48 * 0.5 = 24
expect(manager.sizeControl?.value).toBe(24);
// Base size should remain unchanged
expect(manager.baseSize).toBe(48);
});
it('updates font size control display value when multiplier increases', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Start with multiplier 0.5
manager.multiplier = 0.5;
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5);
// Increase to 0.75
manager.multiplier = 0.75;
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.75);
// Increase to 1.0
manager.multiplier = 1;
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE);
});
});
describe('Base Size Setter', () => {
it('updates baseSize when set directly', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 72;
expect(manager.baseSize).toBe(72);
});
it('updates size control value when baseSize is set', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 60;
expect(manager.sizeControl?.value).toBe(60);
});
it('applies multiplier to size control when baseSize is set', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.multiplier = 0.5;
manager.baseSize = 60;
expect(manager.sizeControl?.value).toBe(30); // 60 * 0.5
});
});
describe('Rendered Size Calculation', () => {
it('calculates renderedSize as baseSize * multiplier', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 1);
});
it('updates renderedSize when multiplier changes', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.multiplier = 0.5;
expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 0.5);
manager.multiplier = 0.75;
expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 0.75);
});
it('updates renderedSize when baseSize changes', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 72;
expect(manager.renderedSize).toBe(72);
manager.multiplier = 0.5;
expect(manager.renderedSize).toBe(36);
});
});
describe('Base Size Proxy Effect (UI -> baseSize)', () => {
// NOTE: The proxy effect that updates baseSize when the control value changes
// runs in a $effect, which is asynchronous in unit tests. We test the
// synchronous behavior here (baseSize setter) and note that the full
// proxy effect behavior should be tested in E2E tests.
it('does NOT immediately update baseSize from control change (effect is async)', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
const originalBaseSize = manager.baseSize;
// Change the control value directly
manager.sizeControl!.value = 60;
// baseSize is NOT updated immediately because the effect runs in microtasks
expect(manager.baseSize).toBe(originalBaseSize);
});
it('updates baseSize via direct setter (synchronous)', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 60;
expect(manager.baseSize).toBe(60);
expect(manager.sizeControl?.value).toBe(60);
});
});
describe('Storage Sync (Controls -> Storage)', () => {
// NOTE: Storage sync happens via $effect which runs in microtasks.
// In unit tests, we verify the initial sync and test async behavior.
it('has initial values in storage from constructor', () => {
mockStorage = {
fontSize: 60,
fontWeight: 500,
lineHeight: 1.6,
letterSpacing: 0.02,
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Initial values are loaded from storage
expect(manager.baseSize).toBe(60);
expect(manager.weight).toBe(500);
expect(manager.height).toBe(1.6);
expect(manager.spacing).toBe(0.02);
});
it('syncs to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 72;
// Storage is NOT updated immediately
expect(mockPersistentStore.value.fontSize).toBe(DEFAULT_FONT_SIZE);
// After flushing effects, storage should be updated
await flushEffects();
expect(mockPersistentStore.value.fontSize).toBe(72);
});
it('syncs control changes to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.weightControl!.value = 700;
// After flushing effects
await flushEffects();
expect(mockPersistentStore.value.fontWeight).toBe(700);
});
it('syncs height control changes to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.heightControl!.value = 1.8;
await flushEffects();
expect(mockPersistentStore.value.lineHeight).toBe(1.8);
});
it('syncs spacing control changes to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.spacingControl!.value = 0.05;
await flushEffects();
expect(mockPersistentStore.value.letterSpacing).toBe(0.05);
});
});
describe('Control Value Getters', () => {
it('returns current weight value', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
manager.weightControl!.value = 700;
expect(manager.weight).toBe(700);
});
it('returns current height value', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
manager.heightControl!.value = 1.8;
expect(manager.height).toBe(1.8);
});
it('returns current spacing value', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
manager.spacingControl!.value = 0.05;
expect(manager.spacing).toBe(0.05);
});
it('returns default value when control is not found', () => {
// Create a manager with empty configs (no controls)
const manager = new TypographyControlManager([], mockPersistentStore);
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
});
});
describe('Reset Functionality', () => {
it('resets all controls to default values', () => {
mockStorage = {
fontSize: 72,
fontWeight: 700,
lineHeight: 1.8,
letterSpacing: 0.05,
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Modify values
manager.baseSize = 80;
manager.weightControl!.value = 900;
manager.heightControl!.value = 2.0;
manager.spacingControl!.value = 0.1;
// Reset
manager.reset();
// Check all values are reset to defaults
expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE);
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
});
it('calls storage.clear() on reset', () => {
const clearSpy = vi.fn();
mockPersistentStore = {
get value() {
return mockStorage;
},
set value(v: TypographySettings) {
mockStorage = v;
},
clear: clearSpy,
};
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.reset();
expect(clearSpy).toHaveBeenCalled();
});
it('respects multiplier when resetting font size control', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.multiplier = 0.5;
manager.baseSize = 80;
manager.reset();
// Font size control should show default * multiplier
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5);
expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE);
});
});
describe('Complex Scenarios', () => {
it('handles changing multiplier then modifying baseSize', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Change multiplier
manager.multiplier = 0.5;
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5);
// Change baseSize
manager.baseSize = 60;
expect(manager.sizeControl?.value).toBe(30); // 60 * 0.5
expect(manager.baseSize).toBe(60);
// Change multiplier again
manager.multiplier = 1;
expect(manager.sizeControl?.value).toBe(60); // 60 * 1
expect(manager.baseSize).toBe(60);
});
it('maintains correct renderedSize throughout changes', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Initial: 48 * 1 = 48
expect(manager.renderedSize).toBe(48);
// Change baseSize: 60 * 1 = 60
manager.baseSize = 60;
expect(manager.renderedSize).toBe(60);
// Change multiplier: 60 * 0.5 = 30
manager.multiplier = 0.5;
expect(manager.renderedSize).toBe(30);
// Change baseSize again: 72 * 0.5 = 36
manager.baseSize = 72;
expect(manager.renderedSize).toBe(36);
});
it('handles multiple control changes in sequence', async () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Change multiple controls
manager.baseSize = 72;
manager.weightControl!.value = 700;
manager.heightControl!.value = 1.8;
manager.spacingControl!.value = 0.05;
// After flushing effects, verify all are synced to storage
await flushEffects();
expect(mockPersistentStore.value.fontSize).toBe(72);
expect(mockPersistentStore.value.fontWeight).toBe(700);
expect(mockPersistentStore.value.lineHeight).toBe(1.8);
expect(mockPersistentStore.value.letterSpacing).toBe(0.05);
});
});
describe('Edge Cases', () => {
it('handles multiplier of 1 (no change)', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.multiplier = 1;
expect(manager.sizeControl?.value).toBe(48);
expect(manager.baseSize).toBe(48);
});
it('handles very small multiplier', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 100;
manager.multiplier = 0.1;
expect(manager.sizeControl?.value).toBe(10);
expect(manager.renderedSize).toBe(10);
});
it('handles large base size with multiplier', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 100;
manager.multiplier = 0.75;
expect(manager.sizeControl?.value).toBe(75);
expect(manager.renderedSize).toBe(75);
});
it('handles floating point precision in multiplier', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 48;
manager.multiplier = 0.5;
// 48 * 0.5 = 24 (exact, no rounding needed)
expect(manager.sizeControl?.value).toBe(24);
expect(manager.renderedSize).toBe(24);
// 48 * 0.33 = 15.84 -> rounds to 16 (step precision is 1)
manager.multiplier = 0.33;
expect(manager.sizeControl?.value).toBe(16);
expect(manager.renderedSize).toBeCloseTo(15.84);
});
it('handles control methods (increase/decrease)', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
const initialWeight = manager.weight;
manager.weightControl!.increase();
expect(manager.weight).toBe(initialWeight + 100);
manager.weightControl!.decrease();
expect(manager.weight).toBe(initialWeight);
});
it('handles control boundary conditions', () => {
const manager = new TypographyControlManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
const sizeCtrl = manager.sizeControl!;
// Test min boundary
sizeCtrl.value = 5;
expect(sizeCtrl.value).toBe(sizeCtrl.min); // Should clamp to MIN_FONT_SIZE (8)
// Test max boundary
sizeCtrl.value = 200;
expect(sizeCtrl.value).toBe(sizeCtrl.max); // Should clamp to MAX_FONT_SIZE (100)
});
});
});

View File

@@ -43,7 +43,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Font Size',
controlLabel: 'Size',
},
{
id: 'font_weight',
@@ -54,7 +54,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Font Weight',
controlLabel: 'Weight',
},
{
id: 'line_height',
@@ -65,7 +65,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Line Height',
controlLabel: 'Leading',
},
{
id: 'letter_spacing',
@@ -76,7 +76,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
increaseLabel: 'Increase Letter Spacing',
decreaseLabel: 'Decrease Letter Spacing',
controlLabel: 'Letter Spacing',
controlLabel: 'Tracking',
},
];

View File

@@ -1,95 +0,0 @@
<!--
Component: TypographyMenu
Floating controls bar for typography settings.
Warm surface, sharp corners, Settings icon header, dividers between units.
Mobile: same bar with overflow-x-auto — no drawer.
-->
<script lang="ts">
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { ComboControl } from '$shared/ui';
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
controlManager,
} from '../model';
interface Props {
class?: string;
hidden?: boolean;
}
const { class: className, hidden = false }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
/**
* Sets the common font size multiplier based on the current responsive state.
*/
$effect(() => {
if (!responsive) return;
switch (true) {
case responsive.isMobile:
controlManager.multiplier = MULTIPLIER_S;
break;
case responsive.isTablet:
controlManager.multiplier = MULTIPLIER_M;
break;
case responsive.isDesktop:
controlManager.multiplier = MULTIPLIER_L;
break;
default:
controlManager.multiplier = MULTIPLIER_L;
}
});
</script>
{#if !hidden}
<div
class={cn('w-full md:w-auto', className)}
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
>
<div
class={cn(
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
'bg-[#f3f0e9]/95 dark:bg-[#121212]/95 backdrop-blur-xl',
'border border-black/5 dark:border-white/10',
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
responsive?.isMobile && 'overflow-x-auto',
)}
>
<!-- Header: icon + label -->
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-black/5 dark:border-white/10 mr-1 text-[#1a1a1a] dark:text-[#e5e5e5] shrink-0">
<Settings2Icon
size={responsive?.isMobile ? 12 : 14}
class="text-[#ff3b30]"
/>
<span
class="text-[0.5625rem] md:text-[0.625rem] font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
>
GLOBAL_CONTROLS
</span>
</div>
<!-- Controls with dividers between each -->
{#each controlManager.controls as control, i (control.id)}
{#if i > 0}
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
{/if}
<ComboControl
control={control.instance}
label={control.controlLabel}
increaseLabel={control.increaseLabel}
decreaseLabel={control.decreaseLabel}
controlLabel={control.controlLabel}
/>
{/each}
</div>
</div>
{/if}

View File

@@ -0,0 +1,193 @@
<!--
Component: TypographyMenu
Floating controls bar for typography settings.
Warm surface, sharp corners, Settings icon header, dividers between units.
Mobile: popover with slider controls anchored to settings button.
Desktop: inline bar with combo controls.
-->
<script lang="ts">
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
Button,
ComboControl,
ControlGroup,
Slider,
} from '$shared/ui';
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import XIcon from '@lucide/svelte/icons/x';
import { Popover } from 'bits-ui';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
controlManager,
} from '../../model';
interface Props {
/**
* CSS classes
*/
class?: string;
/**
* Hidden state
* @default false
*/
hidden?: boolean;
}
const { class: className, hidden = false }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
let isOpen = $state(false);
/**
* Sets the common font size multiplier based on the current responsive state.
*/
$effect(() => {
if (!responsive) return;
switch (true) {
case responsive.isMobile:
controlManager.multiplier = MULTIPLIER_S;
break;
case responsive.isTablet:
controlManager.multiplier = MULTIPLIER_M;
break;
case responsive.isDesktop:
controlManager.multiplier = MULTIPLIER_L;
break;
default:
controlManager.multiplier = MULTIPLIER_L;
}
});
</script>
{#if !hidden}
{#if responsive.isMobile}
<Popover.Root bind:open={isOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<button
{...props}
class={cn(
'inline-flex items-center justify-center',
'size-8 p-0',
'border border-transparent rounded-none',
'transition-colors duration-150',
'hover:bg-white/50 dark:hover:bg-white/5',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
isOpen && 'bg-white dark:bg-[#1e1e1e] border-black/5 dark:border-white/10 shadow-sm',
className,
)}
>
<Settings2Icon class="size-4" />
</button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="top"
align="start"
sideOffset={8}
class={cn(
'z-50 w-72',
'bg-[#f3f0e9] dark:bg-[#1e1e1e]',
'border border-black/5 dark:border-white/10',
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
'rounded-none p-4',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=top]:slide-in-from-bottom-2',
'data-[side=bottom]:slide-in-from-top-2',
)}
interactOutsideBehavior="close"
escapeKeydownBehavior="close"
>
<!-- Header -->
<div class="flex items-center justify-between mb-3 pb-3 border-b border-black/5 dark:border-white/10">
<div class="flex items-center gap-1.5">
<Settings2Icon size={12} class="text-[#ff3b30]" />
<span
class="text-[0.5625rem] font-mono uppercase tracking-widest font-bold text-[#1a1a1a] dark:text-[#e5e5e5]"
>
CONTROLS
</span>
</div>
<Popover.Close>
{#snippet child({ props })}
<button
{...props}
class="inline-flex items-center justify-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="Close controls"
>
<XIcon class="size-3.5 text-neutral-500" />
</button>
{/snippet}
</Popover.Close>
</div>
<!-- Controls -->
{#each controlManager.controls as control (control.id)}
<ControlGroup label={control.controlLabel ?? ''}>
<Slider
bind:value={control.instance.value}
min={control.instance.min}
max={control.instance.max}
step={control.instance.step}
/>
</ControlGroup>
{/each}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
{:else}
<div
class={cn('w-full md:w-auto', className)}
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
>
<div
class={cn(
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
'bg-[#f3f0e9]/95 dark:bg-[#121212]/95 backdrop-blur-xl',
'border border-black/5 dark:border-white/10',
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
)}
>
<!-- Header: icon + label -->
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-black/5 dark:border-white/10 mr-1 text-[#1a1a1a] dark:text-[#e5e5e5] shrink-0">
<Settings2Icon
size={14}
class="text-[#ff3b30]"
/>
<span
class="text-[0.5625rem] md:text-[0.625rem] font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
>
GLOBAL_CONTROLS
</span>
</div>
<!-- Controls with dividers between each -->
{#each controlManager.controls as control, i (control.id)}
{#if i > 0}
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
{/if}
<ComboControl
control={control.instance}
label={control.controlLabel}
increaseLabel={control.increaseLabel}
decreaseLabel={control.decreaseLabel}
controlLabel={control.controlLabel}
/>
{/each}
</div>
</div>
{/if}
{/if}

View File

@@ -1 +1 @@
export { default as TypographyMenu } from './TypographyMenu.svelte';
export { default as TypographyMenu } from './TypographyMenu/TypographyMenu.svelte';

View File

@@ -4,149 +4,22 @@
-->
<script lang="ts">
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
Logo,
Section,
} from '$shared/ui';
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
import { FontSearch } from '$widgets/FontSearch';
import { SampleList } from '$widgets/SampleList';
import CodeIcon from '@lucide/svelte/icons/code';
import EyeIcon from '@lucide/svelte/icons/eye';
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
import ScanSearchIcon from '@lucide/svelte/icons/search';
import {
type Snippet,
getContext,
} from 'svelte';
import { ComparisonView } from '$widgets/ComparisonView';
import { FontSearchSection } from '$widgets/FontSearch';
import { SampleListSection } from '$widgets/SampleList';
import { cubicIn } from 'svelte/easing';
import { fade } from 'svelte/transition';
let searchContainer: HTMLElement;
let isExpanded = $state(true);
const responsive = getContext<ResponsiveManager>('responsive');
function handleTitleStatusChanged(
index: number,
isPast: boolean,
title?: Snippet<[{ className?: string }]>,
id?: string,
) {
if (isPast && title) {
scrollBreadcrumbsStore.add({ index, title, id });
} else {
scrollBreadcrumbsStore.remove(index);
}
return () => {
scrollBreadcrumbsStore.remove(index);
};
}
</script>
<!-- Font List -->
<div
class="p-2 sm:p-3 md:p-4 h-full grid gap-3 sm:gap-4 grid-cols-[max-content_1fr]"
class="h-full flex flex-col gap-3 sm:gap-4"
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
>
<Section
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-8"
onTitleStatusChange={handleTitleStatusChanged}
>
{#snippet icon({ className })}
<CodeIcon class={className} />
{/snippet}
{#snippet description({ className })}
<span class={className}> Project_Codename </span>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'col-start-0 col-span-2')}>
<Logo />
</div>
{/snippet}
</Section>
<Section
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
index={1}
id="optical_comparator"
onTitleStatusChange={handleTitleStatusChanged}
stickyTitle={responsive.isDesktopLarge}
stickyOffset="4rem"
>
{#snippet icon({ className })}
<EyeIcon class={className} />
{/snippet}
{#snippet title({ className })}
<h1 class={className}>
Optical<br />Comparator
</h1>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
<ComparisonSlider />
</div>
{/snippet}
</Section>
<Section
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
index={2}
id="query_module"
onTitleStatusChange={handleTitleStatusChanged}
stickyTitle={responsive.isDesktopLarge}
stickyOffset="4rem"
>
{#snippet icon({ className })}
<ScanSearchIcon class={className} />
{/snippet}
{#snippet title({ className })}
<h2 class={className}>
Query<br />Module
</h2>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
<FontSearch bind:showFilters={isExpanded} />
</div>
{/snippet}
</Section>
<Section
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
index={3}
id="sample_set"
onTitleStatusChange={handleTitleStatusChanged}
stickyTitle={responsive.isDesktopLarge}
stickyOffset="4rem"
>
{#snippet icon({ className })}
<LineSquiggleIcon class={className} />
{/snippet}
{#snippet title({ className })}
<h2 class={className}>
Sample<br />Set
</h2>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
<SampleList />
</div>
{/snippet}
</Section>
<section class="w-auto">
<ComparisonView />
</section>
<main class="w-full pt-0 pb-10 sm:px-6 sm:pt-16 sm:pb-12 md:px-8 md:pt-32 md:pb-16 lg:px-10 lg:pt-48 lg:pb-20 xl:px-16">
<FontSearchSection />
<SampleListSection index={1} />
</main>
</div>
<style>
.content {
/* Tells the browser to skip rendering off-screen content */
content-visibility: auto;
/* Helps the browser reserve space without calculating everything */
contain-intrinsic-size: 1px 1000px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

282
src/shared/api/api.test.ts Normal file
View File

@@ -0,0 +1,282 @@
/**
* Tests for API client
*/
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from 'vitest';
import {
ApiError,
api,
} from './api';
describe('api', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('GET requests', () => {
test('should return data and status on successful request', async () => {
const mockData = { id: 1, name: 'Test' };
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockData,
} as Response);
const result = await api.get<{ id: number; name: string }>('/api/test');
expect(result.data).toEqual(mockData);
expect(result.status).toBe(200);
expect(fetch).toHaveBeenCalledWith(
'/api/test',
expect.objectContaining({
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
);
});
});
describe('POST requests', () => {
test('should send JSON body and return response', async () => {
const requestBody = { name: 'Alice', email: 'alice@example.com' };
const mockResponse = { id: 1, ...requestBody };
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => mockResponse,
} as Response);
const result = await api.post<{ id: number; name: string; email: string }>(
'/api/users',
requestBody,
);
expect(result.data).toEqual(mockResponse);
expect(result.status).toBe(201);
expect(fetch).toHaveBeenCalledWith(
'/api/users',
expect.objectContaining({
method: 'POST',
body: JSON.stringify(requestBody),
headers: { 'Content-Type': 'application/json' },
}),
);
});
test('should handle POST with undefined body', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
} as Response);
await api.post('/api/users', undefined);
expect(fetch).toHaveBeenCalledWith(
'/api/users',
expect.objectContaining({
method: 'POST',
body: undefined,
}),
);
});
});
describe('PUT requests', () => {
test('should send JSON body and return response', async () => {
const requestBody = { id: 1, name: 'Updated' };
const mockResponse = { ...requestBody };
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockResponse,
} as Response);
const result = await api.put<{ id: number; name: string }>('/api/users/1', requestBody);
expect(result.data).toEqual(mockResponse);
expect(result.status).toBe(200);
expect(fetch).toHaveBeenCalledWith(
'/api/users/1',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify(requestBody),
headers: { 'Content-Type': 'application/json' },
}),
);
});
});
describe('DELETE requests', () => {
test('should return data and status on successful deletion', async () => {
const mockData = { message: 'Deleted successfully' };
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockData,
} as Response);
const result = await api.delete<{ message: string }>('/api/users/1');
expect(result.data).toEqual(mockData);
expect(result.status).toBe(200);
expect(fetch).toHaveBeenCalledWith(
'/api/users/1',
expect.objectContaining({
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
}),
);
});
});
describe('error handling', () => {
test('should throw ApiError on non-OK response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
json: async () => ({ error: 'Resource not found' }),
} as Response);
await expect(api.get('/api/not-found')).rejects.toThrow(ApiError);
});
test('should include status code in ApiError', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: async () => ({}),
} as Response);
try {
await api.get('/api/error');
expect.fail('Should have thrown ApiError');
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).status).toBe(500);
}
});
test('should include message in ApiError', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
json: async () => ({}),
} as Response);
try {
await api.get('/api/forbidden');
expect.fail('Should have thrown ApiError');
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).message).toBe('Request failed: Forbidden');
}
});
test('should include response object in ApiError', async () => {
const mockResponse = {
ok: false,
status: 401,
statusText: 'Unauthorized',
json: async () => ({}),
} as Response;
vi.mocked(fetch).mockResolvedValueOnce(mockResponse);
try {
await api.get('/api/unauthorized');
expect.fail('Should have thrown ApiError');
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).response).toBe(mockResponse);
}
});
test('should have correct error name', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => ({}),
} as Response);
try {
await api.get('/api/bad-request');
expect.fail('Should have thrown ApiError');
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).name).toBe('ApiError');
}
});
});
describe('headers', () => {
test('should accept custom headers (replaces defaults)', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
} as Response);
await api.get('/api/test', {
headers: { 'X-Custom-Header': 'custom-value' },
});
expect(fetch).toHaveBeenCalledWith(
'/api/test',
expect.objectContaining({
headers: {
'X-Custom-Header': 'custom-value',
},
}),
);
});
test('should allow overriding default Content-Type', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
} as Response);
await api.get('/api/test', {
headers: { 'Content-Type': 'text/plain' },
});
expect(fetch).toHaveBeenCalledWith(
'/api/test',
expect.objectContaining({
headers: { 'Content-Type': 'text/plain' },
}),
);
});
});
describe('empty response handling', () => {
test('should handle empty JSON response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 204,
json: async () => null,
} as Response);
const result = await api.get<null>('/api/empty');
expect(result.data).toBeNull();
expect(result.status).toBe(204);
});
});
});

View File

@@ -1,9 +1,50 @@
/**
* HTTP API client with error handling
*
* Provides a typed wrapper around fetch for JSON APIs.
* Automatically handles JSON serialization and error responses.
*
* @example
* ```ts
* import { api } from '$shared/api';
*
* // GET request
* const users = await api.get<User[]>('/api/users');
*
* // POST request
* const newUser = await api.post<User>('/api/users', { name: 'Alice' });
*
* // Error handling
* try {
* const data = await api.get('/api/data');
* } catch (error) {
* if (error instanceof ApiError) {
* console.error(error.status, error.message);
* }
* }
* ```
*/
import type { ApiResponse } from '$shared/types/common';
/**
* Custom error class for API failures
*
* Includes HTTP status code and the original Response object
* for debugging and error handling.
*/
export class ApiError extends Error {
/**
* Creates a new API error
* @param status - HTTP status code
* @param message - Error message
* @param response - Original fetch Response object
*/
constructor(
/** HTTP status code */
public status: number,
message: string,
/** Original Response object for inspection */
public response?: Response,
) {
super(message);
@@ -11,6 +52,16 @@ export class ApiError extends Error {
}
}
/**
* Internal request handler
*
* Performs fetch with JSON headers and throws ApiError on failure.
*
* @param url - Request URL
* @param options - Fetch options (method, headers, body, etc.)
* @returns Response data and status code
* @throws ApiError when response is not OK
*/
async function request<T>(
url: string,
options?: RequestInit,
@@ -39,9 +90,28 @@ async function request<T>(
};
}
/**
* API client methods
*
* Provides typed methods for common HTTP verbs.
* All methods return ApiResponse with data and status.
*/
export const api = {
/**
* Performs a GET request
* @param url - Request URL
* @param options - Additional fetch options
* @returns Response data
*/
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
/**
* Performs a POST request with JSON body
* @param url - Request URL
* @param body - Request body (will be JSON stringified)
* @param options - Additional fetch options
* @returns Response data
*/
post: <T>(url: string, body?: unknown, options?: RequestInit) =>
request<T>(url, {
...options,
@@ -49,6 +119,13 @@ export const api = {
body: JSON.stringify(body),
}),
/**
* Performs a PUT request with JSON body
* @param url - Request URL
* @param body - Request body (will be JSON stringified)
* @param options - Additional fetch options
* @returns Response data
*/
put: <T>(url: string, body?: unknown, options?: RequestInit) =>
request<T>(url, {
...options,
@@ -56,5 +133,11 @@ export const api = {
body: JSON.stringify(body),
}),
/**
* Performs a DELETE request
* @param url - Request URL
* @param options - Additional fetch options
* @returns Response data
*/
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
};

View File

@@ -1,24 +1,33 @@
import { QueryClient } from '@tanstack/query-core';
/**
* Query client instance
* TanStack Query client instance
*
* Configured for optimal caching and refetching behavior.
* Used by all font stores for data fetching and caching.
*
* Cache behavior:
* - Data stays fresh for 5 minutes (staleTime)
* - Unused data is garbage collected after 10 minutes (gcTime)
* - No refetch on window focus (reduces unnecessary network requests)
* - 3 retries with exponential backoff on failure
*/
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
/**
* Default staleTime: 5 minutes
*/
/** Data remains fresh for 5 minutes after fetch */
staleTime: 5 * 60 * 1000,
/**
* Default gcTime: 10 minutes
*/
/** Unused cache entries are removed after 10 minutes */
gcTime: 10 * 60 * 1000,
/** Don't refetch when window regains focus */
refetchOnWindowFocus: false,
/** Refetch on mount if data is stale */
refetchOnMount: true,
/** Retry failed requests up to 3 times */
retry: 3,
/**
* Exponential backoff
* Exponential backoff for retries
* 1s, 2s, 4s, 8s... capped at 30s
*/
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
},

View File

@@ -1,25 +1,83 @@
/**
* Interface representing a line of text with its measured width.
* Character-by-character font comparison helper
*
* Creates utilities for comparing two fonts character by character.
* Used by the ComparisonView widget to render morphing text effects
* where characters transition between font A and font B based on
* slider position.
*
* Features:
* - Responsive text measurement using canvas
* - Binary search for optimal line breaking
* - Character proximity calculation for morphing effects
* - Handles CSS transforms correctly (uses offsetWidth)
*
* @example
* ```svelte
* <script lang="ts">
* import { createCharacterComparison } from '$shared/lib/helpers';
*
* const comparison = createCharacterComparison(
* () => text,
* () => fontA,
* () => fontB,
* () => weight,
* () => size
* );
*
* $: lines = comparison.lines;
* </script>
*
* <canvas bind:this={measureCanvas} hidden></canvas>
* <div bind:this={container}>
* {#each lines as line}
* <span>{line.text}</span>
* {/each}
* </div>
* ```
*/
/**
* Represents a single line of text with its measured width
*/
export interface LineData {
/**
* Line's text
*/
/** The text content of the line */
text: string;
/**
* It's width
*/
/** Maximum width between both fonts in pixels */
width: number;
}
/**
* Creates a helper for splitting text into lines and calculating character proximity.
* This is used by the ComparisonSlider (TestTen) to render morphing text.
* Creates a character comparison helper for morphing text effects
*
* @param text - The text to split and measure
* @param fontA - The first font definition
* @param fontB - The second font definition
* @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState)
* Measures text in both fonts to determine line breaks and calculates
* character-level proximity for morphing animations.
*
* @param text - Getter for the text to compare
* @param fontA - Getter for the first font (left/top side)
* @param fontB - Getter for the second font (right/bottom side)
* @param weight - Getter for the current font weight
* @param size - Getter for the controlled font size
* @returns Character comparison instance with lines and proximity calculations
*
* @example
* ```ts
* const comparison = createCharacterComparison(
* () => $sampleText,
* () => $selectedFontA,
* () => $selectedFontB,
* () => $fontWeight,
* () => $fontSize
* );
*
* // Call when DOM is ready
* comparison.breakIntoLines(container, canvas);
*
* // Get character state for morphing
* const state = comparison.getCharState(5, sliderPosition, lineEl, container);
* // state.proximity: 0-1 value for opacity/interpolation
* // state.isPast: true if slider is past this character
* ```
*/
export function createCharacterComparison<
T extends { name: string; id: string } | undefined = undefined,
@@ -33,17 +91,22 @@ export function createCharacterComparison<
let lines = $state<LineData[]>([]);
let containerWidth = $state(0);
/**
* Type guard to check if a font is defined
*/
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
return font !== undefined;
}
/**
* Measures text width using a canvas context.
* Measures text width using canvas 2D context
*
* @param ctx - Canvas rendering context
* @param text - Text string to measure
* @param fontFamily - Font family name
* @param fontSize - Font size in pixels
* @param fontWeight - Font weight
* @param fontWeight - Font weight (100-900)
* @param fontFamily - Font family name (optional, returns 0 if missing)
* @returns Width of text in pixels
*/
function measureText(
ctx: CanvasRenderingContext2D,
@@ -58,8 +121,13 @@ export function createCharacterComparison<
}
/**
* Determines the appropriate font size based on window width.
* Matches the Tailwind breakpoints used in the component.
* Gets responsive font size based on viewport width
*
* Matches Tailwind breakpoints used in the component:
* - < 640px: 64px
* - 640-767px: 80px
* - 768-1023px: 96px
* - >= 1024px: 112px
*/
function getFontSize() {
if (typeof window === 'undefined') {
@@ -75,13 +143,14 @@ export function createCharacterComparison<
}
/**
* Breaks the text into lines based on the container width and measure canvas.
* Populates the `lines` state.
* Breaks text into lines based on container width
*
* @param container - The container element to measure width from
* @param measureCanvas - The canvas element used for text measurement
* Measures text in BOTH fonts and uses the wider width to prevent
* layout shifts. Uses binary search for efficient word breaking.
*
* @param container - Container element to measure width from
* @param measureCanvas - Hidden canvas element for text measurement
*/
function breakIntoLines(
container: HTMLElement | undefined,
measureCanvas: HTMLCanvasElement | undefined,
@@ -90,13 +159,11 @@ export function createCharacterComparison<
return;
}
// Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues
// getBoundingClientRect() returns transformed dimensions, which causes incorrect line breaking
// when PerspectivePlan applies scale() transforms (e.g., scale(0.5) in settings mode)
// Use offsetWidth to avoid CSS transform scaling issues
// getBoundingClientRect() includes transform scale which breaks calculations
const width = container.offsetWidth;
containerWidth = width;
// Padding considerations - matches the container padding
const padding = window.innerWidth < 640 ? 48 : 96;
const availableWidth = width - padding;
const ctx = measureCanvas.getContext('2d');
@@ -106,17 +173,19 @@ export function createCharacterComparison<
const controlledFontSize = size();
const fontSize = getFontSize();
const currentWeight = weight(); // Get current weight
const currentWeight = weight();
const words = text().split(' ');
const newLines: LineData[] = [];
let currentLineWords: string[] = [];
/**
* Adds a line to the output using the wider font's width
*/
function pushLine(words: string[]) {
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
return;
}
const lineText = words.join(' ');
// Measure both fonts at the CURRENT weight
const widthA = measureText(
ctx!,
lineText,
@@ -139,7 +208,7 @@ export function createCharacterComparison<
const testLine = currentLineWords.length > 0
? currentLineWords.join(' ') + ' ' + word
: word;
// Measure with both fonts and use the wider one to prevent layout shifts
// Measure with both fonts - use wider to prevent shifts
const widthA = measureText(
ctx,
testLine,
@@ -163,6 +232,7 @@ export function createCharacterComparison<
currentLineWords = [];
}
// Check if word alone fits
const wordWidthA = measureText(
ctx,
word,
@@ -180,16 +250,16 @@ export function createCharacterComparison<
const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
if (wordAloneWidth <= availableWidth) {
// If word fits start new line with it
currentLineWords = [word];
} else {
// Word doesn't fit - binary search to find break point
let remainingWord = word;
while (remainingWord.length > 0) {
let low = 1;
let high = remainingWord.length;
let bestBreak = 1;
// Binary Search to find the maximum characters that fit
// Binary search for maximum characters that fit
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const testFragment = remainingWord.slice(0, mid);
@@ -236,13 +306,16 @@ export function createCharacterComparison<
}
/**
* precise calculation of character state based on global slider position.
* Calculates character proximity to slider position
*
* @param charIndex - Index of the character in the line
* @param sliderPos - Current slider position (0-100)
* @param lineElement - The line element
* @param container - The container element
* @returns Object containing proximity (0-1) and isPast (boolean)
* Used for morphing effects - returns how close a character is to
* the slider and whether it's on the "past" side.
*
* @param charIndex - Index of character within its line
* @param sliderPos - Slider position (0-100, percent across container)
* @param lineElement - The line element containing the character
* @param container - The container element for position calculations
* @returns Proximity (0-1, 1 = at slider) and isPast (true = right of slider)
*/
function getCharState(
charIndex: number,
@@ -262,14 +335,15 @@ export function createCharacterComparison<
return { proximity: 0, isPast: false };
}
// Get the actual bounding box of the character
// Get character bounding box relative to container
const charRect = charElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
// Calculate character center relative to container
// Calculate character center as percentage of container width
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
const charGlobalPercent = (charCenter / containerWidth) * 100;
// Calculate proximity (1.0 = at slider, 0.0 = 5% away)
const distance = Math.abs(sliderPos - charGlobalPercent);
const range = 5;
const proximity = Math.max(0, 1 - distance / range);
@@ -279,15 +353,22 @@ export function createCharacterComparison<
}
return {
/** Reactive array of broken lines */
get lines() {
return lines;
},
/** Container width in pixels */
get containerWidth() {
return containerWidth;
},
/** Break text into lines based on current container and fonts */
breakIntoLines,
/** Get character state for morphing calculations */
getCharState,
};
}
/**
* Type representing a character comparison instance
*/
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;

View File

@@ -0,0 +1,312 @@
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { createCharacterComparison } from './createCharacterComparison.svelte';
type Font = { name: string; id: string };
const fontA: Font = { name: 'Roboto', id: 'roboto' };
const fontB: Font = { name: 'Open Sans', id: 'open-sans' };
function createMockCanvas(charWidth = 10): HTMLCanvasElement {
return {
getContext: () => ({
font: '',
measureText: (text: string) => ({ width: text.length * charWidth }),
}),
} as unknown as HTMLCanvasElement;
}
function createMockContainer(offsetWidth = 500): HTMLElement {
return {
offsetWidth,
getBoundingClientRect: () => ({
left: 0,
width: offsetWidth,
top: 0,
right: offsetWidth,
bottom: 0,
height: 0,
}),
} as unknown as HTMLElement;
}
describe('createCharacterComparison', () => {
beforeEach(() => {
// Mock window.innerWidth for getFontSize and padding calculations
Object.defineProperty(globalThis, 'window', {
value: { innerWidth: 1024 },
writable: true,
configurable: true,
});
});
describe('Initial State', () => {
it('should initialize with empty lines and zero container width', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
expect(comparison.lines).toEqual([]);
expect(comparison.containerWidth).toBe(0);
});
});
describe('breakIntoLines', () => {
it('should not break lines when container or canvas is undefined', () => {
const comparison = createCharacterComparison(
() => 'Hello world',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(undefined, undefined);
expect(comparison.lines).toEqual([]);
comparison.breakIntoLines(createMockContainer(), undefined);
expect(comparison.lines).toEqual([]);
});
it('should not break lines when fonts are undefined', () => {
const comparison = createCharacterComparison(
() => 'Hello world',
() => undefined,
() => undefined,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(), createMockCanvas());
expect(comparison.lines).toEqual([]);
});
it('should produce a single line when text fits within container', () => {
// charWidth=10, padding=96 (innerWidth>=640), availableWidth=500-96=404
// "Hello" = 5 chars * 10 = 50px, fits easily
const comparison = createCharacterComparison(
() => 'Hello',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
expect(comparison.lines).toHaveLength(1);
expect(comparison.lines[0].text).toBe('Hello');
});
it('should break text into multiple lines when it overflows', () => {
// charWidth=10, container=200, padding=96, availableWidth=104
// "Hello world test" => "Hello" (50px), "Hello world" (110px > 104)
// So "Hello" goes on line 1, "world" (50px) fits, "world test" (100px) fits
const comparison = createCharacterComparison(
() => 'Hello world test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(200), createMockCanvas(10));
expect(comparison.lines.length).toBeGreaterThan(1);
// All original text should be preserved across lines
const reconstructed = comparison.lines.map(l => l.text).join(' ');
expect(reconstructed).toBe('Hello world test');
});
it('should update containerWidth after breaking lines', () => {
const comparison = createCharacterComparison(
() => 'Hi',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(750), createMockCanvas(10));
expect(comparison.containerWidth).toBe(750);
});
it('should use smaller padding on narrow viewports', () => {
Object.defineProperty(globalThis, 'window', {
value: { innerWidth: 500 },
writable: true,
configurable: true,
});
// container=150, padding=48 (innerWidth<640), availableWidth=102
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, fits in 102
const comparison = createCharacterComparison(
() => 'ABCDEFGHIJ',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
expect(comparison.lines).toHaveLength(1);
expect(comparison.lines[0].text).toBe('ABCDEFGHIJ');
});
it('should break a single long word using binary search', () => {
// container=150, padding=96, availableWidth=54
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, doesn't fit as single word
// Binary search should split it
const comparison = createCharacterComparison(
() => 'ABCDEFGHIJ',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
expect(comparison.lines.length).toBeGreaterThan(1);
const reconstructed = comparison.lines.map(l => l.text).join('');
expect(reconstructed).toBe('ABCDEFGHIJ');
});
it('should store max width between both fonts for each line', () => {
// Use a canvas where measureText returns text.length * charWidth
// Both fonts measure the same, so width = text.length * charWidth
const comparison = createCharacterComparison(
() => 'Hi',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
expect(comparison.lines[0].width).toBe(20); // "Hi" = 2 chars * 10
});
});
describe('getCharState', () => {
it('should return zero proximity and isPast=false when containerWidth is 0', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
const state = comparison.getCharState(0, 50, undefined, undefined);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
});
it('should return zero proximity when charElement is not found', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
// First break lines to set containerWidth
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
const lineEl = { children: [] } as unknown as HTMLElement;
const container = createMockContainer(500);
const state = comparison.getCharState(0, 50, lineEl, container);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
});
it('should calculate proximity based on distance from slider', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
// Character centered at 250px in a 500px container = 50%
const charEl = {
getBoundingClientRect: () => ({ left: 240, width: 20 }),
};
const lineEl = { children: [charEl] } as unknown as HTMLElement;
const container = createMockContainer(500);
// Slider at 50% => charCenter at 250px => charGlobalPercent = 50%
// distance = |50 - 50| = 0, proximity = max(0, 1 - 0/5) = 1
const state = comparison.getCharState(0, 50, lineEl, container);
expect(state.proximity).toBe(1);
expect(state.isPast).toBe(false);
});
it('should return isPast=true when slider is past the character', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
// Character centered at 100px => 20% of 500px
const charEl = {
getBoundingClientRect: () => ({ left: 90, width: 20 }),
};
const lineEl = { children: [charEl] } as unknown as HTMLElement;
const container = createMockContainer(500);
// Slider at 80% => past the character at 20%
const state = comparison.getCharState(0, 80, lineEl, container);
expect(state.isPast).toBe(true);
});
it('should return zero proximity when character is far from slider', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
// Character at 10% of container, slider at 90% => distance = 80%, range = 5%
const charEl = {
getBoundingClientRect: () => ({ left: 45, width: 10 }),
};
const lineEl = { children: [charEl] } as unknown as HTMLElement;
const container = createMockContainer(500);
const state = comparison.getCharState(0, 90, lineEl, container);
expect(state.proximity).toBe(0);
});
});
});

View File

@@ -1,48 +1,102 @@
/**
* Generic entity store using Svelte 5's reactive SvelteMap
*
* Provides O(1) lookups by ID and granular reactivity for entity collections.
* Ideal for managing collections of objects with unique identifiers.
*
* @example
* ```ts
* interface User extends Entity {
* id: string;
* name: string;
* }
*
* const store = createEntityStore<User>([
* { id: '1', name: 'Alice' },
* { id: '2', name: 'Bob' }
* ]);
*
* // Access is reactive in Svelte components
* const allUsers = store.all;
* const alice = store.getById('1');
* ```
*/
import { SvelteMap } from 'svelte/reactivity';
/**
* Base entity interface requiring an ID field
*/
export interface Entity {
/** Unique identifier for the entity */
id: string;
}
/**
* Svelte 5 Entity Store
* Uses SvelteMap for O(1) lookups and granular reactivity.
* Reactive entity store with O(1) lookups
*
* Uses SvelteMap internally for reactive state that automatically
* triggers updates when entities are added, removed, or modified.
*/
export class EntityStore<T extends Entity> {
// SvelteMap is a reactive version of the native Map
/** Reactive map of entities keyed by ID */
#entities = new SvelteMap<string, T>();
/**
* Creates a new entity store with optional initial data
* @param initialEntities - Initial entities to populate the store
*/
constructor(initialEntities: T[] = []) {
this.setAll(initialEntities);
}
// --- Selectors (Equivalent to Selectors) ---
/** Get all entities as an array */
/**
* Get all entities as an array
* @returns Array of all entities in the store
*/
get all() {
return Array.from(this.#entities.values());
}
/** Select a single entity by ID */
/**
* Get a single entity by ID
* @param id - Entity ID to look up
* @returns The entity if found, undefined otherwise
*/
getById(id: string) {
return this.#entities.get(id);
}
/** Select multiple entities by IDs */
/**
* Get multiple entities by their IDs
* @param ids - Array of entity IDs to look up
* @returns Array of found entities (undefined IDs are filtered out)
*/
getByIds(ids: string[]) {
return ids.map(id => this.#entities.get(id)).filter((e): e is T => !!e);
}
// --- Actions (CRUD) ---
/**
* Add a single entity to the store
* @param entity - Entity to add (updates if ID already exists)
*/
addOne(entity: T) {
this.#entities.set(entity.id, entity);
}
/**
* Add multiple entities to the store
* @param entities - Array of entities to add
*/
addMany(entities: T[]) {
entities.forEach(e => this.addOne(e));
}
/**
* Update an existing entity by merging changes
* @param id - ID of entity to update
* @param changes - Partial changes to merge into existing entity
*/
updateOne(id: string, changes: Partial<T>) {
const entity = this.#entities.get(id);
if (entity) {
@@ -50,32 +104,61 @@ export class EntityStore<T extends Entity> {
}
}
/**
* Remove a single entity by ID
* @param id - ID of entity to remove
*/
removeOne(id: string) {
this.#entities.delete(id);
}
/**
* Remove multiple entities by their IDs
* @param ids - Array of entity IDs to remove
*/
removeMany(ids: string[]) {
ids.forEach(id => this.#entities.delete(id));
}
/**
* Replace all entities in the store
* Clears existing entities and adds new ones
* @param entities - New entities to populate the store with
*/
setAll(entities: T[]) {
this.#entities.clear();
this.addMany(entities);
}
/**
* Check if an entity exists in the store
* @param id - Entity ID to check
* @returns true if entity exists, false otherwise
*/
has(id: string) {
return this.#entities.has(id);
}
/**
* Remove all entities from the store
*/
clear() {
this.#entities.clear();
}
}
/**
* Creates a new EntityStore instance with the given initial entities.
* @param initialEntities The initial entities to populate the store with.
* @returns - A new EntityStore instance.
* Creates a new entity store instance
* @param initialEntities - Initial entities to populate the store with
* @returns A new EntityStore instance
*
* @example
* ```ts
* const store = createEntityStore([
* { id: '1', name: 'Item 1' },
* { id: '2', name: 'Item 2' }
* ]);
* ```
*/
export function createEntityStore<T extends Entity>(initialEntities: T[] = []) {
return new EntityStore<T>(initialEntities);

View File

@@ -1,35 +1,80 @@
/**
* Filter state management for multi-select property filtering
*
* Creates reactive state for managing filterable properties with selection state.
* Commonly used for category filters, tag selection, and other multi-select UIs.
*
* @example
* ```ts
* const filter = createFilter({
* properties: [
* { id: 'sans', name: 'Sans Serif', value: 'sans-serif', selected: false },
* { id: 'serif', name: 'Serif', value: 'serif', selected: false }
* ]
* });
*
* // Access state
* filter.selectedProperties; // Currently selected items
* filter.selectedCount; // Number of selected items
*
* // Modify state
* filter.toggleProperty('sans');
* filter.selectAll();
* ```
*/
/**
* A filterable property with selection state
*
* @template TValue - The type of the property value (typically string)
*/
export interface Property<TValue extends string> {
/**
* Property identifier
*/
/** Unique identifier for the property */
id: string;
/**
* Property name
*/
/** Human-readable display name */
name: string;
/**
* Property value
*/
/** Underlying value for filtering logic */
value: TValue;
/**
* Property selected state
*/
/** Whether the property is currently selected */
selected?: boolean;
}
/**
* Initial state configuration for a filter
*
* @template TValue - The type of property values
*/
export interface FilterModel<TValue extends string> {
/**
* Properties
*/
/** Array of filterable properties */
properties: Property<TValue>[];
}
/**
* Create a filter store.
* @param initialState - Initial state of filter store
* Creates a reactive filter store for managing multi-select state
*
* Provides methods for toggling, selecting, and deselecting properties
* along with derived state for selected items and counts.
*
* @param initialState - Initial configuration of properties and their selection state
* @returns Filter instance with reactive properties and methods
*
* @example
* ```ts
* // Create category filter
* const categoryFilter = createFilter({
* properties: [
* { id: 'sans', name: 'Sans Serif', value: 'sans-serif' },
* { id: 'serif', name: 'Serif', value: 'serif' },
* { id: 'display', name: 'Display', value: 'display' }
* ]
* });
*
* // In a Svelte component
* $: selected = categoryFilter.selectedProperties;
* ```
*/
export function createFilter<TValue extends string>(initialState: FilterModel<TValue>) {
// We map the initial properties into a reactive state array
// Map initial properties to reactive state with defaulted selection
const properties = $state(
initialState.properties.map(p => ({
...p,
@@ -41,41 +86,77 @@ export function createFilter<TValue extends string>(initialState: FilterModel<TV
const findProp = (id: string) => properties.find(p => p.id === id);
return {
/**
* All properties with their current selection state
*/
get properties() {
return properties;
},
/**
* Only properties that are currently selected
*/
get selectedProperties() {
return properties.filter(p => p.selected);
},
/**
* Count of currently selected properties
*/
get selectedCount() {
return properties.filter(p => p.selected)?.length;
},
/**
* Toggle the selection state of a property
* @param id - Property ID to toggle
*/
toggleProperty(id: string) {
const property = findProp(id);
if (property) {
property.selected = !property.selected;
}
},
/**
* Select a property (idempotent - safe if already selected)
* @param id - Property ID to select
*/
selectProperty(id: string) {
const property = findProp(id);
if (property) {
property.selected = true;
}
},
/**
* Deselect a property (idempotent - safe if already deselected)
* @param id - Property ID to deselect
*/
deselectProperty(id: string) {
const property = findProp(id);
if (property) {
property.selected = false;
}
},
/**
* Select all properties
*/
selectAll() {
properties.forEach(property => property.selected = true);
},
/**
* Deselect all properties
*/
deselectAll() {
properties.forEach(property => property.selected = false);
},
};
}
/**
* Type representing a filter instance
*/
export type Filter = ReturnType<typeof createFilter>;

View File

@@ -1,10 +1,66 @@
/**
* Reusable persistent storage utility using Svelte 5 runes
* Persistent localStorage-backed reactive state
*
* Automatically syncs state with localStorage.
* Creates reactive state that automatically syncs with localStorage.
* Values persist across browser sessions and are restored on page load.
*
* Handles edge cases:
* - SSR safety (no localStorage on server)
* - JSON parse errors (falls back to default)
* - Storage quota errors (logs warning, doesn't crash)
*
* @example
* ```ts
* // Store user preferences
* const preferences = createPersistentStore('user-prefs', {
* theme: 'dark',
* fontSize: 16,
* sidebarOpen: true
* });
*
* // Access reactive state
* $: currentTheme = preferences.value.theme;
*
* // Update (auto-saves to localStorage)
* preferences.value.theme = 'light';
*
* // Clear stored value
* preferences.clear();
* ```
*/
/**
* Creates a reactive store backed by localStorage
*
* The value is loaded from localStorage on initialization and automatically
* saved whenever it changes. Uses Svelte 5's $effect for reactive sync.
*
* @param key - localStorage key for storing the value
* @param defaultValue - Default value if no stored value exists
* @returns Persistent store with getter/setter and clear method
*
* @example
* ```ts
* // Simple value
* const counter = createPersistentStore('counter', 0);
* counter.value++;
*
* // Complex object
* interface Settings {
* theme: 'light' | 'dark';
* fontSize: number;
* }
* const settings = createPersistentStore<Settings>('app-settings', {
* theme: 'light',
* fontSize: 16
* });
* ```
*/
export function createPersistentStore<T>(key: string, defaultValue: T) {
// Initialize from storage or default
/**
* Load value from localStorage or return default
* Safely handles missing keys, parse errors, and SSR
*/
const loadFromStorage = (): T => {
if (typeof window === 'undefined') {
return defaultValue;
@@ -21,6 +77,7 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
let value = $state<T>(loadFromStorage());
// Sync to storage whenever value changes
// Wrapped in $effect.root to prevent memory leaks
$effect.root(() => {
$effect(() => {
if (typeof window === 'undefined') {
@@ -29,18 +86,27 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
// Quota exceeded or privacy mode - log but don't crash
console.warn(`[createPersistentStore] Error saving ${key}:`, error);
}
});
});
return {
/**
* Current value (getter/setter)
* Changes automatically persist to localStorage
*/
get value() {
return value;
},
set value(v: T) {
value = v;
},
/**
* Remove value from localStorage and reset to default
*/
clear() {
if (typeof window !== 'undefined') {
localStorage.removeItem(key);
@@ -50,4 +116,7 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
};
}
/**
* Type representing a persistent store instance
*/
export type PersistentStore<T> = ReturnType<typeof createPersistentStore<T>>;

View File

@@ -1,61 +1,83 @@
/**
* 3D perspective animation state manager
*
* Manages smooth transitions between "front" (interactive) and "back" (background)
* visual states using Svelte springs. Used for creating depth-based UI effects
* like settings panels, modal transitions, and spatial navigation.
*
* @example
* ```svelte
* <script lang="ts">
* import { createPerspectiveManager } from '$shared/lib/helpers';
*
* const perspective = createPerspectiveManager({
* depthStep: 100,
* scaleStep: 0.5,
* blurStep: 4
* });
* </script>
*
* <div
* style="transform: scale({perspective.isBack ? 0.5 : 1});
* filter: blur({perspective.isBack ? 4 : 0}px)"
* >
* <button on:click={perspective.toggle}>Toggle View</button>
* </div>
* ```
*/
import { Spring } from 'svelte/motion';
/**
* Configuration options for perspective effects
*/
export interface PerspectiveConfig {
/**
* How many px to move back per level
*/
/** Z-axis translation per level in pixels */
depthStep?: number;
/**
* Scale reduction per level
*/
/** Scale reduction per level (0-1) */
scaleStep?: number;
/**
* Blur amount per level
*/
/** Blur amount per level in pixels */
blurStep?: number;
/**
* Opacity reduction per level
*/
/** Opacity reduction per level (0-1) */
opacityStep?: number;
/**
* Parallax intensity per level
*/
/** Parallax movement intensity per level */
parallaxIntensity?: number;
/**
* Horizontal offset for each plan (x-axis positioning)
* Positive = right, Negative = left
*/
/** Horizontal offset - positive for right, negative for left */
horizontalOffset?: number;
/**
* Layout mode: 'center' (default) or 'split' for Swiss-style side-by-side
*/
/** Layout mode: 'center' for centered, 'split' for side-by-side */
layoutMode?: 'center' | 'split';
}
/**
* Manages perspective state with a simple boolean flag.
* Manages perspective state with spring-based transitions
*
* Drastically simplified from the complex camera/index system.
* Just manages whether content is in "back" or "front" state.
* Simplified from a complex camera system to just track back/front state.
* The spring value animates between 0 (front) and 1 (back) for smooth
* visual transitions of scale, blur, opacity, and position.
*
* @example
* ```typescript
* ```ts
* const perspective = createPerspectiveManager({
* depthStep: 100,
* scaleStep: 0.5,
* blurStep: 4,
* blurStep: 4
* });
*
* // Toggle back/front
* perspective.toggle();
* // Check state (reactive)
* console.log(perspective.isBack); // false
*
* // Check state
* const isBack = perspective.isBack; // reactive boolean
* // Toggle with animation
* perspective.toggle(); // Smoothly animates to back position
*
* // Direct control
* perspective.setBack(); // Go to back
* perspective.setFront(); // Go to front
* ```
*/
export class PerspectiveManager {
/**
* Spring for smooth back/front transitions
* Spring animation state
* Animates between 0 (front) and 1 (back) with configurable physics
*/
spring = new Spring(0, {
stiffness: 0.2,
@@ -63,20 +85,30 @@ export class PerspectiveManager {
});
/**
* Reactive boolean: true when in back position (blurred, scaled down)
* Reactive state: true when in back position
*
* Content should appear blurred, scaled down, and less interactive
* when this is true. Derived from spring value > 0.5.
*/
isBack = $derived(this.spring.current > 0.5);
/**
* Reactive boolean: true when in front position (fully visible, interactive)
* Reactive state: true when in front position
*
* Content should be fully visible, sharp, and interactive
* when this is true. Derived from spring value < 0.5.
*/
isFront = $derived(this.spring.current < 0.5);
/**
* Configuration values for style computation
* Internal configuration with defaults applied
*/
private config: Required<PerspectiveConfig>;
/**
* Creates a new perspective manager
* @param config - Configuration for visual effects
*/
constructor(config: PerspectiveConfig = {}) {
this.config = {
depthStep: config.depthStep ?? 100,
@@ -90,8 +122,10 @@ export class PerspectiveManager {
}
/**
* Toggle between front (0) and back (1) positions.
* Smooth spring animation handles the transition.
* Toggle between front and back positions
*
* Uses spring animation for smooth transition. Toggles based on
* current state - if spring < 0.5 goes to 1, otherwise goes to 0.
*/
toggle = () => {
const target = this.spring.current < 0.5 ? 1 : 0;
@@ -99,31 +133,40 @@ export class PerspectiveManager {
};
/**
* Force to back position
* Force to back position (blurred, scaled down)
*/
setBack = () => {
this.spring.target = 1;
};
/**
* Force to front position
* Force to front position (fully visible, interactive)
*/
setFront = () => {
this.spring.target = 0;
};
/**
* Get configuration for style computation
* @internal
* Get current configuration
* @internal Used by components to compute styles
*/
getConfig = () => this.config;
}
/**
* Factory function to create a PerspectiveManager instance.
* Factory function to create a perspective manager
*
* @param config - Configuration options
* @param config - Configuration options for visual effects
* @returns Configured PerspectiveManager instance
*
* @example
* ```ts
* const perspective = createPerspectiveManager({
* scaleStep: 0.6,
* blurStep: 8,
* layoutMode: 'split'
* });
* ```
*/
export function createPerspectiveManager(config: PerspectiveConfig = {}) {
return new PerspectiveManager(config);

View File

@@ -1,66 +1,96 @@
// $shared/lib/createResponsiveManager.svelte.ts
/**
* Responsive breakpoint tracking using Svelte 5 runes
*
* Provides reactive viewport dimensions and breakpoint detection that
* automatically updates on window resize. Includes touch device detection
* and orientation tracking.
*
* Default breakpoints match Tailwind CSS:
* - xs: < 640px (mobile)
* - sm: 640px (mobile)
* - md: 768px (tablet portrait)
* - lg: 1024px (tablet)
* - xl: 1280px (desktop)
* - 2xl: 1536px (desktop large)
*
* @example
* ```svelte
* <script lang="ts">
* import { responsiveManager } from '$shared/lib/helpers';
*
* // Singleton is auto-initialized
* </script>
*
* {#if responsiveManager.isMobile}
* <MobileNav />
* {:else}
* <DesktopNav />
* {/if}
*
* <p>Viewport: {responsiveManager.width}x{responsiveManager.height}</p>
* <p>Breakpoint: {responsiveManager.currentBreakpoint}</p>
* ```
*/
/**
* Breakpoint definitions following common device sizes
* Customize these values to match your design system
* Breakpoint definitions for responsive design
*
* Values represent the minimum width (in pixels) for each breakpoint.
* Customize to match your design system's breakpoints.
*/
export interface Breakpoints {
/** Mobile devices (portrait phones) */
/** Mobile devices - default 640px */
mobile: number;
/** Tablet portrait */
/** Tablet portrait - default 768px */
tabletPortrait: number;
/** Tablet landscape */
/** Tablet landscape - default 1024px */
tablet: number;
/** Desktop */
/** Desktop - default 1280px */
desktop: number;
/** Large desktop */
/** Large desktop - default 1536px */
desktopLarge: number;
}
/**
* Default breakpoints (matches common Tailwind-like breakpoints)
* Default breakpoint values (Tailwind CSS compatible)
*/
const DEFAULT_BREAKPOINTS: Breakpoints = {
mobile: 640, // sm
tabletPortrait: 768, // md
tablet: 1024, // lg
desktop: 1280, // xl
desktopLarge: 1536, // 2xl
mobile: 640,
tabletPortrait: 768,
tablet: 1024,
desktop: 1280,
desktopLarge: 1536,
};
/**
* Orientation type
* Device orientation type
*/
export type Orientation = 'portrait' | 'landscape';
/**
* Creates a reactive responsive manager that tracks viewport size and breakpoints.
* Creates a responsive manager for tracking viewport state
*
* Provides reactive getters for:
* - Current breakpoint detection (isMobile, isTablet, etc.)
* - Viewport dimensions (width, height)
* - Device orientation (portrait/landscape)
* - Custom breakpoint matching
* Tracks viewport dimensions, calculates breakpoint states, and detects
* device capabilities (touch, orientation). Uses ResizeObserver for
* accurate tracking and falls back to window resize events.
*
* @param customBreakpoints - Optional custom breakpoint values
* @returns Responsive manager instance with reactive properties
*
* @example
* ```svelte
* <script lang="ts">
* const responsive = createResponsiveManager();
* </script>
* ```ts
* // Use defaults
* const responsive = createResponsiveManager();
*
* {#if responsive.isMobile}
* <MobileNav />
* {:else if responsive.isTablet}
* <TabletNav />
* {:else}
* <DesktopNav />
* {/if}
* // Custom breakpoints
* const custom = createResponsiveManager({
* mobile: 480,
* desktop: 1024
* });
*
* <p>Width: {responsive.width}px</p>
* <p>Orientation: {responsive.orientation}</p>
* // In component
* $: isMobile = responsive.isMobile;
* $: cols = responsive.isDesktop ? 3 : 1;
* ```
*/
export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) {
@@ -69,7 +99,7 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
...customBreakpoints,
};
// Reactive state
// Reactive viewport dimensions
let width = $state(typeof window !== 'undefined' ? window.innerWidth : 0);
let height = $state(typeof window !== 'undefined' ? window.innerHeight : 0);
@@ -90,12 +120,12 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
const isMobileOrTablet = $derived(width < breakpoints.desktop);
const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait);
// Orientation
// Orientation detection
const orientation = $derived<Orientation>(height > width ? 'portrait' : 'landscape');
const isPortrait = $derived(orientation === 'portrait');
const isLandscape = $derived(orientation === 'landscape');
// Touch device detection (best effort)
// Touch device detection (best effort heuristic)
const isTouchDevice = $derived(
typeof window !== 'undefined'
&& ('ontouchstart' in window || navigator.maxTouchPoints > 0),
@@ -103,7 +133,11 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
/**
* Initialize responsive tracking
* Call this in an $effect or component mount
*
* Sets up ResizeObserver on document.documentElement and falls back
* to window resize event listener. Returns cleanup function.
*
* @returns Cleanup function to remove listeners
*/
function init() {
if (typeof window === 'undefined') return;
@@ -130,9 +164,17 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
}
/**
* Check if current width matches a custom breakpoint
* Check if current viewport matches a custom breakpoint range
*
* @param min - Minimum width (inclusive)
* @param max - Maximum width (exclusive)
* @param max - Optional maximum width (exclusive)
* @returns true if viewport width matches the range
*
* @example
* ```ts
* responsive.matches(768, 1024); // true for tablet only
* responsive.matches(1280); // true for desktop and larger
* ```
*/
function matches(min: number, max?: number): boolean {
if (max !== undefined) {
@@ -142,7 +184,7 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
}
/**
* Get the current breakpoint name
* Current breakpoint name based on viewport width
*/
const currentBreakpoint = $derived<keyof Breakpoints | 'xs'>(
(() => {
@@ -158,16 +200,17 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
case isDesktopLarge:
return 'desktopLarge';
default:
return 'xs'; // Fallback for very small screens
return 'xs';
}
})(),
);
return {
// Dimensions
/** Viewport width in pixels */
get width() {
return width;
},
/** Viewport height in pixels */
get height() {
return height;
},
@@ -227,6 +270,12 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
};
}
/**
* Singleton responsive manager instance
*
* Auto-initializes on the client side. Use this throughout the app
* rather than creating multiple instances.
*/
export const responsiveManager = createResponsiveManager();
if (typeof window !== 'undefined') {

View File

@@ -0,0 +1,107 @@
/**
* Tests for createResponsiveManager
*/
import {
describe,
expect,
it,
} from 'vitest';
import { createResponsiveManager } from './createResponsiveManager.svelte';
describe('createResponsiveManager', () => {
describe('initialization', () => {
it('should create with default breakpoints', () => {
const manager = createResponsiveManager();
expect(manager.breakpoints).toEqual({
mobile: 640,
tabletPortrait: 768,
tablet: 1024,
desktop: 1280,
desktopLarge: 1536,
});
});
it('should merge custom breakpoints with defaults', () => {
const manager = createResponsiveManager({ mobile: 480, desktop: 1200 });
expect(manager.breakpoints.mobile).toBe(480);
expect(manager.breakpoints.desktop).toBe(1200);
expect(manager.breakpoints.tablet).toBe(1024); // default preserved
});
it('should have initial width and height from window', () => {
const manager = createResponsiveManager();
// In test environment, window dimensions come from jsdom/mocks
expect(typeof manager.width).toBe('number');
expect(typeof manager.height).toBe('number');
});
});
describe('matches', () => {
it('should return true when width is above min', () => {
const manager = createResponsiveManager();
// width is 0 in node env (no window), so matches(0) should be true
expect(manager.matches(0)).toBe(true);
});
it('should return false when width is below min', () => {
const manager = createResponsiveManager();
// width is 0, so matches(100) should be false
expect(manager.matches(100)).toBe(false);
});
it('should handle range with max', () => {
const manager = createResponsiveManager();
// width is 0, so matches(0, 100) should be true (0 >= 0 && 0 < 100)
expect(manager.matches(0, 100)).toBe(true);
// matches(1, 100) should be false (0 >= 1 is false)
expect(manager.matches(1, 100)).toBe(false);
});
});
describe('breakpoint states at width 0', () => {
it('should report isMobile when width is 0', () => {
const manager = createResponsiveManager();
expect(manager.isMobile).toBe(true);
expect(manager.isTabletPortrait).toBe(false);
expect(manager.isTablet).toBe(false);
expect(manager.isDesktop).toBe(false);
expect(manager.isDesktopLarge).toBe(false);
});
it('should report correct convenience groupings', () => {
const manager = createResponsiveManager();
expect(manager.isMobileOrTablet).toBe(true);
expect(manager.isTabletOrDesktop).toBe(false);
});
});
describe('orientation', () => {
it('should detect portrait when height > width', () => {
// Default: width=0, height=0 => not portrait (0 > 0 is false)
const manager = createResponsiveManager();
expect(manager.orientation).toBe('landscape');
expect(manager.isLandscape).toBe(true);
expect(manager.isPortrait).toBe(false);
});
});
describe('init', () => {
it('should return undefined in non-browser environment', () => {
const manager = createResponsiveManager();
const cleanup = manager.init();
// In node test env, window is undefined so init returns early
expect(cleanup).toBeUndefined();
});
});
});

View File

@@ -1,46 +1,98 @@
/**
* Numeric control with bounded values and step precision
*
* Creates a reactive control for numeric values that enforces min/max bounds
* and rounds to a specific step increment. Commonly used for typography controls
* like font size, line height, and letter spacing.
*
* @example
* ```ts
* const fontSize = createTypographyControl({
* value: 16,
* min: 12,
* max: 72,
* step: 1
* });
*
* // Access current value
* fontSize.value; // 16
* fontSize.isAtMin; // false
*
* // Modify value (automatically clamped and rounded)
* fontSize.increase();
* fontSize.value = 100; // Will be clamped to max (72)
* ```
*/
import {
clampNumber,
roundToStepPrecision,
} from '$shared/lib/utils';
/**
* Core numeric control configuration
* Defines the bounds and stepping behavior for a control
*/
export interface ControlDataModel {
/**
* Control value
*/
/** Current numeric value */
value: number;
/**
* Minimal possible value
*/
/** Minimum allowed value (inclusive) */
min: number;
/**
* Maximal possible value
*/
/** Maximum allowed value (inclusive) */
max: number;
/**
* Step size for increase/decrease
*/
/** Step size for increment/decrement operations */
step: number;
}
/**
* Full control model including accessibility labels
*
* @template T - Type for the control identifier
*/
export interface ControlModel<T extends string = string> extends ControlDataModel {
/**
* Control identifier
*/
/** Unique identifier for the control */
id: T;
/**
* Area label for increase button
*/
/** ARIA label for the increase button */
increaseLabel?: string;
/**
* Area label for decrease button
*/
/** ARIA label for the decrease button */
decreaseLabel?: string;
/**
* Control area label
*/
/** ARIA label for the control area */
controlLabel?: string;
}
/**
* Creates a reactive numeric control with bounds and stepping
*
* The control automatically:
* - Clamps values to the min/max range
* - Rounds values to the step precision
* - Tracks whether at min/max bounds
*
* @param initialState - Initial value, bounds, and step configuration
* @returns Typography control instance with reactive state and methods
*
* @example
* ```ts
* // Font size control: 12-72px in 1px increments
* const fontSize = createTypographyControl({
* value: 16,
* min: 12,
* max: 72,
* step: 1
* });
*
* // Line height control: 1.0-2.0 in 0.1 increments
* const lineHeight = createTypographyControl({
* value: 1.5,
* min: 1.0,
* max: 2.0,
* step: 0.1
* });
*
* // Direct assignment (auto-clamped)
* fontSize.value = 100; // Becomes 72 (max)
* ```
*/
export function createTypographyControl<T extends ControlDataModel>(
initialState: T,
) {
@@ -49,12 +101,17 @@ export function createTypographyControl<T extends ControlDataModel>(
let min = $state(initialState.min);
let step = $state(initialState.step);
// Derived state for boundary detection
const { isAtMax, isAtMin } = $derived({
isAtMax: value >= max,
isAtMin: value <= min,
});
return {
/**
* Current control value (getter/setter)
* Setting automatically clamps to bounds and rounds to step precision
*/
get value() {
return value;
},
@@ -64,27 +121,45 @@ export function createTypographyControl<T extends ControlDataModel>(
value = rounded;
}
},
/** Maximum allowed value */
get max() {
return max;
},
/** Minimum allowed value */
get min() {
return min;
},
/** Step increment size */
get step() {
return step;
},
/** Whether the value is at or exceeds the maximum */
get isAtMax() {
return isAtMax;
},
/** Whether the value is at or below the minimum */
get isAtMin() {
return isAtMin;
},
/**
* Increase value by one step (clamped to max)
*/
increase() {
value = roundToStepPrecision(
clampNumber(value + step, min, max),
step,
);
},
/**
* Decrease value by one step (clamped to min)
*/
decrease() {
value = roundToStepPrecision(
clampNumber(value - step, min, max),
@@ -94,4 +169,7 @@ export function createTypographyControl<T extends ControlDataModel>(
};
}
/**
* Type representing a typography control instance
*/
export type TypographyControl = ReturnType<typeof createTypographyControl>;

View File

@@ -291,7 +291,7 @@ export function createVirtualizer<T>(
},
};
} else {
containerHeight = node.offsetHeight;
containerHeight = node.clientHeight;
const handleScroll = () => {
scrollOffset = node.scrollTop;

View File

@@ -56,6 +56,11 @@ function createMockContainer(height = 500, scrollTop = 0): any {
configurable: true,
writable: true,
});
Object.defineProperty(container, 'clientHeight', {
value: height,
configurable: true,
writable: true,
});
Object.defineProperty(container, 'scrollTop', {
value: scrollTop,
writable: true,

View File

@@ -1,3 +1,27 @@
/**
* Reactive helper factories using Svelte 5 runes
*
* Provides composable state management patterns for common UI needs:
* - Filter management with multi-selection
* - Typography controls with bounds and stepping
* - Virtual scrolling for large lists
* - Debounced state for search inputs
* - Entity stores with O(1) lookups
* - Character-by-character font comparison
* - Persistent localStorage-backed state
* - Responsive breakpoint tracking
* - 3D perspective animations
*
* @example
* ```ts
* import { createFilter, createVirtualizer, createTypographyControl } from '$shared/lib/helpers';
*
* const filter = createFilter({ properties: [...] });
* const virtualizer = createVirtualizer(() => ({ count: 1000, estimateSize: () => 50 }));
* const control = createTypographyControl({ value: 16, min: 12, max: 72, step: 1 });
* ```
*/
export {
createFilter,
type Filter,

View File

@@ -1,3 +1,9 @@
/**
* Shared library
*
* Reusable utilities, helpers, and providers for the application.
*/
export {
type CharacterComparison,
type ControlDataModel,

View File

@@ -11,6 +11,9 @@ import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
/**
* Content snippet
*/
children: Snippet;
}

View File

@@ -15,15 +15,15 @@ import type {
interface Props {
/**
* The Lucide icon component
* Lucide icon component
*/
icon: Component;
/**
* CSS classes to apply to the icon
* CSS classes
*/
class?: string;
/**
* Additional icon-specific attributes
* Additional attributes
*/
attrs?: Record<string, unknown>;
}

View File

@@ -15,17 +15,22 @@ import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
/**
* Content snippet
*/
children: Snippet;
/**
* Initial viewport width for the responsive context (default: 1280)
* Initial viewport width
* @default 1280
*/
initialWidth?: number;
/**
* Initial viewport height for the responsive context (default: 720)
* Initial viewport height
* @default 720
*/
initialHeight?: number;
/**
* Tooltip provider options
* Tooltip delay duration
*/
tooltipDelayDuration?: number;
/**

View File

@@ -1,56 +1,46 @@
/**
* Build query string from URL parameters
* Builds URL query strings from parameter objects
*
* Generic, type-safe function to build properly encoded query strings
* from URL parameters. Supports primitives, arrays, and optional values.
*
* @param params - Object containing query parameters
* @returns Encoded query string (empty string if no parameters)
* Creates properly encoded query strings from typed parameter objects.
* Handles primitives, arrays, and omits null/undefined values.
*
* @example
* ```ts
* buildQueryString({ category: 'serif', subsets: ['latin', 'latin-ext'] })
* // Returns: "category=serif&subsets=latin&subsets=latin-ext"
* // Returns: "?category=serif&subsets=latin%2Clatin-ext"
*
* buildQueryString({ limit: 50, page: 1 })
* // Returns: "limit=50&page=1"
* // Returns: "?limit=50&page=1"
*
* buildQueryString({})
* // Returns: ""
*
* buildQueryString({ search: 'hello world', active: true })
* // Returns: "search=hello%20world&active=true"
* // Returns: "?search=hello%20world&active=true"
* ```
*/
/**
* Query parameter value type
* Supports primitives, arrays, and excludes null/undefined
* Supported query parameter value types
*/
export type QueryParamValue = string | number | boolean | string[] | number[];
/**
* Query parameters object
* Query parameters object with optional values
*/
export type QueryParams = Record<string, QueryParamValue | undefined | null>;
/**
* Build query string from URL parameters
* Builds a URL query string from a parameters object
*
* Handles:
* - Primitive values (string, number, boolean)
* - Arrays (multiple values with same key)
* - Optional values (excludes undefined/null)
* - Proper URL encoding
*
* Edge cases:
* - Empty object → empty string
* - No parameters → empty string
* - Nested objects → flattens to string representation
* - Special characters → proper encoding
* - Primitive values (string, number, boolean) - converted to strings
* - Arrays - comma-separated values
* - null/undefined - omitted from output
* - Special characters - URL encoded
*
* @param params - Object containing query parameters
* @returns Encoded query string (with "?" prefix if non-empty)
* @returns Encoded query string with "?" prefix, or empty string if no params
*/
export function buildQueryString(params: QueryParams): string {
const searchParams = new URLSearchParams();
@@ -61,12 +51,14 @@ export function buildQueryString(params: QueryParams): string {
continue;
}
// Handle arrays (multiple values with same key)
// Handle arrays (comma-separated values)
if (Array.isArray(value)) {
for (const item of value) {
if (item !== undefined && item !== null) {
searchParams.append(key, String(item));
}
const joined = value
.filter(item => item !== undefined && item !== null)
.map(String)
.join(',');
if (joined) {
searchParams.append(key, joined);
}
} else {
// Handle primitives

View File

@@ -1,9 +1,20 @@
/**
* Clamp a number within a range.
* @param value The number to clamp.
* @param min minimum value
* @param max maximum value
* @returns The clamped number.
* Clamps a number within a specified range
*
* Ensures a value falls between minimum and maximum bounds.
* Values below min return min, values above max return max.
*
* @param value - The number to clamp
* @param min - Minimum allowed value (inclusive)
* @param max - Maximum allowed value (inclusive)
* @returns The clamped number
*
* @example
* ```ts
* clampNumber(5, 0, 10); // 5
* clampNumber(-5, 0, 10); // 0
* clampNumber(15, 0, 10); // 10
* ```
*/
export function clampNumber(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);

View File

@@ -1,28 +1,24 @@
/**
* ============================================================================
* DEBOUNCE UTILITY
* ============================================================================
*
* Creates a debounced function that delays execution until after wait milliseconds
* have elapsed since the last time it was invoked.
*
* @example
* ```typescript
* const debouncedSearch = debounce((query: string) => {
* console.log('Searching for:', query);
* }, 300);
*
* debouncedSearch('hello');
* debouncedSearch('hello world'); // Only this will execute after 300ms
* ```
*/
/**
* Creates a debounced version of a function
*
* Delays function execution until after `wait` milliseconds have elapsed
* since the last invocation. Useful for rate-limiting expensive operations
* like API calls or expensive DOM updates.
*
* @example
* ```ts
* const search = debounce((query: string) => {
* console.log('Searching for:', query);
* }, 300);
*
* search('a');
* search('ab');
* search('abc'); // Only this triggers the function after 300ms
* ```
*
* @param fn - The function to debounce
* @param wait - The delay in milliseconds
* @returns A debounced function that will execute after the specified delay
* @returns A debounced function that executes after the delay
*/
export function debounce<T extends (...args: any[]) => any>(
fn: T,

View File

@@ -1,14 +1,19 @@
/**
* Get the number of decimal places in a number
* Counts the number of decimal places in a number
*
* For example:
* - 1 -> 0
* - 0.1 -> 1
* - 0.01 -> 2
* - 0.05 -> 2
* Returns the length of the decimal portion of a number.
* Used to determine precision for rounding operations.
*
* @param step - The step number to analyze
* @returns The number of decimal places
* @param step - The number to analyze
* @returns The number of decimal places (0 for integers)
*
* @example
* ```ts
* getDecimalPlaces(1); // 0
* getDecimalPlaces(0.1); // 1
* getDecimalPlaces(0.01); // 2
* getDecimalPlaces(0.005); // 3
* ```
*/
export function getDecimalPlaces(step: number): number {
const str = step.toString();

View File

@@ -1,5 +1,12 @@
/**
* Shared utility functions
*
* Provides common utilities for:
* - Number manipulation (clamping, precision, decimal places)
* - Function execution control (debounce, throttle)
* - Array operations (split by predicate)
* - URL handling (query string building)
* - DOM interactions (smooth scrolling)
*/
export {

View File

@@ -1,19 +1,25 @@
import { getDecimalPlaces } from '$shared/lib/utils';
/**
* Round a value to the precision of the given step
* Rounds a value to match the precision of a given step
*
* This fixes floating-point precision errors that occur with decimal steps.
* For example, with step=0.05, adding it repeatedly can produce values like
* 1.3499999999999999 instead of 1.35.
* Fixes floating-point precision errors that occur with decimal arithmetic.
* For example, repeatedly adding 0.05 can produce 1.3499999999999999
* instead of 1.35 due to IEEE 754 floating-point representation.
*
* We use toFixed() to round to the appropriate decimal places instead of
* Math.round(value / step) * step, which doesn't always work correctly
* due to floating-point arithmetic errors.
* Uses toFixed() instead of Math.round() for correct decimal rounding.
*
* @param value - The value to round
* @param step - The step to round to (defaults to 1)
* @param step - The step size to match precision of (default: 1)
* @returns The rounded value
*
* @example
* ```ts
* roundToStepPrecision(1.3499999999999999, 0.05); // 1.35
* roundToStepPrecision(1.2345, 0.01); // 1.23
* roundToStepPrecision(1.2345, 0.1); // 1.2
* roundToStepPrecision(1.5, 1); // 2
* ```
*/
export function roundToStepPrecision(value: number, step: number = 1): number {
if (step <= 0) {

View File

@@ -1,6 +1,17 @@
/**
* Smoothly scrolls to the target element when an anchor element is clicked.
* @param node - The anchor element to listen for clicks on.
* Svelte action for smooth anchor scrolling
*
* Intercepts anchor link clicks to smoothly scroll to the target element
* instead of jumping instantly. Updates URL hash without causing scroll.
*
* @example
* ```svelte
* <a href="#section" use:smoothScroll>Go to Section</a>
* <div id="section">Section Content</div>
* ```
*
* @param node - The anchor element to attach to
* @returns Action object with destroy method
*/
export function smoothScroll(node: HTMLAnchorElement) {
const handleClick = (event: MouseEvent) => {
@@ -17,7 +28,7 @@ export function smoothScroll(node: HTMLAnchorElement) {
block: 'start',
});
// Update URL hash without jumping
// Update URL hash without triggering scroll
history.pushState(null, '', hash);
}
};

View File

@@ -1,8 +1,26 @@
/**
* Splits an array into two arrays based on a callback function.
* @param array The array to split.
* @param callback The callback function to determine which array to push each item to.
* @returns - An array containing two arrays, the first array contains items that passed the callback, the second array contains items that failed the callback.
* Splits an array into two groups based on a predicate
*
* Partitions an array into pass/fail groups using a callback function.
* More efficient than calling filter() twice.
*
* @param array - The array to split
* @param callback - Predicate function (true = first array, false = second)
* @returns Tuple of [passing items, failing items]
*
* @example
* ```ts
* const numbers = [1, 2, 3, 4, 5, 6];
* const [even, odd] = splitArray(numbers, n => n % 2 === 0);
* // even: [2, 4, 6]
* // odd: [1, 3, 5]
*
* const users = [
* { name: 'Alice', active: true },
* { name: 'Bob', active: false }
* ];
* const [active, inactive] = splitArray(users, u => u.active);
* ```
*/
export function splitArray<T>(array: T[], callback: (item: T) => boolean) {
return array.reduce<[T[], T[]]>(

View File

@@ -1,9 +1,23 @@
/**
* Throttle function execution to a maximum frequency.
* Throttles a function to limit execution frequency
*
* @param fn Function to throttle.
* @param wait Maximum time between function calls.
* @returns Throttled function.
* Ensures a function executes at most once per `wait` milliseconds.
* Unlike debounce, throttled functions execute on the leading edge
* and trailing edge if called repeatedly.
*
* @example
* ```ts
* const logScroll = throttle(() => {
* console.log('Scroll position:', window.scrollY);
* }, 100);
*
* window.addEventListener('scroll', logScroll);
* // Will log at most once every 100ms
* ```
*
* @param fn - Function to throttle
* @param wait - Minimum time between executions in milliseconds
* @returns Throttled function
*/
export function throttle<T extends (...args: any[]) => any>(
fn: T,
@@ -20,7 +34,7 @@ export function throttle<T extends (...args: any[]) => any>(
lastCall = now;
fn(...args);
} else {
// Schedule for end of wait period
// Schedule for end of wait period (trailing edge)
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
lastCall = Date.now();

View File

@@ -1,4 +1,14 @@
/**
* Standard API response wrapper
*
* Encapsulates response data with HTTP status code
* for consistent error handling across API calls.
*
* @template T - Type of the response data
*/
export interface ApiResponse<T> {
/** Response payload data */
data: T;
/** HTTP status code */
status: number;
}

View File

@@ -9,6 +9,7 @@ import {
labelSizeConfig,
} from '$shared/ui/Label/config';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
type BadgeVariant = 'default' | 'accent' | 'success' | 'warning' | 'info';
@@ -20,12 +21,29 @@ const badgeVariantConfig: Record<BadgeVariant, string> = {
info: 'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400',
};
interface Props {
interface Props extends HTMLAttributes<HTMLSpanElement> {
/**
* Visual variant
* @default 'default'
*/
variant?: BadgeVariant;
/**
* Badge size
* @default 'xs'
*/
size?: LabelSize;
/** Renders a small filled circle before the text. */
/**
* Show status dot
* @default false
*/
dot?: boolean;
/**
* Content snippet
*/
children?: Snippet;
/**
* CSS classes
*/
class?: string;
}
@@ -35,6 +53,7 @@ let {
dot = false,
children,
class: className,
...rest
}: Props = $props();
</script>
@@ -46,6 +65,7 @@ let {
badgeVariantConfig[variant],
className,
)}
{...rest}
>
{#if dot}
<span class="w-1 h-1 rounded-full bg-current"></span>

View File

@@ -13,18 +13,43 @@ import type {
} from './types';
interface Props extends HTMLButtonAttributes {
/**
* Visual style variant
* @default 'secondary'
*/
variant?: ButtonVariant;
/**
* Button size
* @default 'md'
*/
size?: ButtonSize;
/** Svelte snippet rendered as the icon. */
/**
* Icon snippet
*/
icon?: Snippet;
/**
* Icon placement
* @default 'left'
*/
iconPosition?: IconPosition;
/**
* Active toggle state
* @default false
*/
active?: boolean;
/**
* When true (default), adds `active:scale-[0.97]` on tap via CSS.
* Primary variant is excluded from scale — it shifts via translate instead.
* Tap animation
* Primary uses translate, others use scale
* @default true
*/
animate?: boolean;
/**
* Content snippet
*/
children?: Snippet;
/**
* CSS classes
*/
class?: string;
}
@@ -45,7 +70,6 @@ let {
// Square sizing when icon is present but there is no text label
const isIconOnly = $derived(!!icon && !children);
// ── Variant base styles ──────────────────────────────────────────────────────
const variantStyles: Record<ButtonVariant, string> = {
primary: cn(
'bg-swiss-red text-white',
@@ -125,7 +149,6 @@ const variantStyles: Record<ButtonVariant, string> = {
),
};
// ── Size styles ───────────────────────────────────────────────────────────────
const sizeStyles: Record<ButtonSize, string> = {
xs: 'h-6 px-2 text-[9px] gap-1',
sm: 'h-8 px-3 text-[10px] gap-1.5',
@@ -143,12 +166,11 @@ const iconSizeStyles: Record<ButtonSize, string> = {
xl: 'h-14 w-14 p-3',
};
// ── Active state overrides (per variant) ─────────────────────────────────────
const activeStyles: Partial<Record<ButtonVariant, string>> = {
secondary: 'bg-paper dark:bg-paper shadow-sm border-black/20 dark:border-white/20',
tertiary:
'bg-paper dark:bg-[#1e1e1e] border-black/10 dark:border-white/10 shadow-sm text-neutral-900 dark:text-neutral-100',
ghost: 'bg-transparent dark:bg-transparent text-brnad dark:text-brand',
ghost: 'bg-transparent dark:bg-transparent text-brand dark:text-brand',
outline: 'bg-surface dark:bg-paper border-brand',
icon: 'bg-paper dark:bg-paper text-brand border-black/5 dark:border-white/10',
};

View File

@@ -0,0 +1,91 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import ButtonGroup from './ButtonGroup.svelte';
const { Story } = defineMeta({
title: 'Shared/ButtonGroup',
component: ButtonGroup,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Wraps buttons in a warm-surface pill with a 1px gap and subtle border. Use for segmented controls, view toggles, or any mutually exclusive button set.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
class: {
control: 'text',
description: 'Additional CSS classes',
},
},
});
</script>
<script lang="ts">
import { Button } from '$shared/ui/Button';
</script>
<Story name="Default">
{#snippet template(args)}
<ButtonGroup {...args}>
<Button variant="tertiary">Option 1</Button>
<Button variant="tertiary">Option 2</Button>
<Button variant="tertiary">Option 3</Button>
</ButtonGroup>
{/snippet}
</Story>
<Story name="Horizontal">
{#snippet template(args)}
<ButtonGroup {...args}>
<Button variant="tertiary">Day</Button>
<Button variant="tertiary" active>Week</Button>
<Button variant="tertiary">Month</Button>
<Button variant="tertiary">Year</Button>
</ButtonGroup>
{/snippet}
</Story>
<Story name="Vertical">
{#snippet template(args)}
<ButtonGroup {...args} class="flex-col">
<Button variant="tertiary">Top</Button>
<Button variant="tertiary" active>Middle</Button>
<Button variant="tertiary">Bottom</Button>
</ButtonGroup>
{/snippet}
</Story>
<Story name="With Icons">
{#snippet template(args)}
<ButtonGroup {...args}>
<Button variant="tertiary">Grid</Button>
<Button variant="tertiary" active>List</Button>
<Button variant="tertiary">Map</Button>
</ButtonGroup>
{/snippet}
</Story>
<Story
name="Dark Mode"
parameters={{
backgrounds: {
default: 'dark',
},
}}
>
{#snippet template(args)}
<div class="p-8 bg-background text-foreground">
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
<ButtonGroup {...args}>
<Button variant="tertiary">Option A</Button>
<Button variant="tertiary" active>Option B</Button>
<Button variant="tertiary">Option C</Button>
</ButtonGroup>
</div>
{/snippet}
</Story>

View File

@@ -9,7 +9,13 @@ import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
interface Props extends HTMLAttributes<HTMLDivElement> {
/**
* Content snippet
*/
children?: Snippet;
/**
* CSS classes
*/
class?: string;
}

View File

@@ -0,0 +1,148 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import IconButton from './IconButton.svelte';
const { Story } = defineMeta({
title: 'Shared/IconButton',
component: IconButton,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Icon-only button variant. Convenience wrapper that defaults variant to "icon" and enforces icon-only usage.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
variant: {
control: 'select',
options: ['icon', 'ghost', 'secondary'],
defaultValue: 'icon',
},
size: {
control: 'select',
options: ['xs', 'sm', 'md', 'lg', 'xl'],
defaultValue: 'md',
},
active: {
control: 'boolean',
defaultValue: false,
},
animate: {
control: 'boolean',
defaultValue: true,
},
},
});
</script>
<script lang="ts">
import MoonIcon from '@lucide/svelte/icons/moon';
import SearchIcon from '@lucide/svelte/icons/search';
import TrashIcon from '@lucide/svelte/icons/trash-2';
</script>
<Story
name="Default"
args={{ variant: 'icon', size: 'md', active: false, animate: true }}
>
{#snippet template(args)}
<div class="flex items-center gap-4">
<IconButton {...args}>
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
<IconButton {...args} size="sm">
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
<IconButton {...args} size="lg">
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
</div>
{/snippet}
</Story>
<Story
name="Variants"
args={{ size: 'md', active: false, animate: true }}
>
{#snippet template(args)}
<div class="flex items-center gap-4">
<IconButton {...args} variant="icon">
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
<IconButton {...args} variant="ghost">
{#snippet icon()}
<MoonIcon />
{/snippet}
</IconButton>
<IconButton {...args} variant="secondary">
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
</div>
{/snippet}
</Story>
<Story
name="Active State"
args={{ size: 'md', animate: true }}
>
{#snippet template(args)}
<div class="flex items-center gap-4">
<IconButton {...args} active={false} variant="icon">
{#snippet icon()}
<TrashIcon />
{/snippet}
</IconButton>
<IconButton {...args} active={true} variant="icon">
{#snippet icon()}
<TrashIcon />
{/snippet}
</IconButton>
</div>
{/snippet}
</Story>
<Story
name="Dark Mode"
parameters={{
backgrounds: {
default: 'dark',
},
}}
>
{#snippet template(args)}
<div class="p-8 bg-background text-foreground">
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
<div class="flex items-center gap-4">
<IconButton {...args} variant="icon">
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
<IconButton {...args} variant="ghost">
{#snippet icon()}
<MoonIcon />
{/snippet}
</IconButton>
<IconButton {...args} variant="secondary">
{#snippet icon()}
<SearchIcon />
{/snippet}
</IconButton>
</div>
</div>
{/snippet}
</Story>

View File

@@ -12,6 +12,10 @@ import type { ButtonVariant } from './types';
type BaseProps = Exclude<ComponentProps<typeof Button>, 'children' | 'iconPosition'>;
interface Props extends BaseProps {
/**
* Visual variant
* @default 'icon'
*/
variant?: Extract<ButtonVariant, 'icon' | 'ghost' | 'secondary'>;
}

View File

@@ -0,0 +1,138 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import ToggleButton from './ToggleButton.svelte';
const { Story } = defineMeta({
title: 'Shared/ToggleButton',
component: ToggleButton,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Toggle button with selected/active states. Accepts `selected` prop as alias for `active`, matching common toggle patterns.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'tertiary', 'outline', 'ghost'],
defaultValue: 'tertiary',
},
size: {
control: 'select',
options: ['xs', 'sm', 'md', 'lg', 'xl'],
defaultValue: 'md',
},
selected: {
control: 'boolean',
description: 'Selected state (alias for active)',
},
active: {
control: 'boolean',
defaultValue: false,
},
animate: {
control: 'boolean',
defaultValue: true,
},
},
});
</script>
<script lang="ts">
let selected = $state(false);
</script>
<Story
name="Default"
args={{ variant: 'tertiary', size: 'md', selected: false, animate: true }}
>
{#snippet template(args)}
<ToggleButton {...args}>Toggle Me</ToggleButton>
{/snippet}
</Story>
<Story
name="Selected/Unselected"
args={{ variant: 'tertiary', size: 'md', animate: true }}
>
{#snippet template(args)}
<div class="flex items-center gap-4">
<ToggleButton {...args} selected={false}>
Unselected
</ToggleButton>
<ToggleButton {...args} selected={true}>
Selected
</ToggleButton>
</div>
{/snippet}
</Story>
<Story
name="Variants"
args={{ size: 'md', selected: true, animate: true }}
>
{#snippet template(args)}
<div class="flex items-center gap-4">
<ToggleButton {...args} variant="primary">
Primary
</ToggleButton>
<ToggleButton {...args} variant="secondary">
Secondary
</ToggleButton>
<ToggleButton {...args} variant="tertiary">
Tertiary
</ToggleButton>
<ToggleButton {...args} variant="outline">
Outline
</ToggleButton>
<ToggleButton {...args} variant="ghost">
Ghost
</ToggleButton>
</div>
{/snippet}
</Story>
<Story
name="Interactive"
args={{ variant: 'tertiary', size: 'md', animate: true }}
>
{#snippet template(args)}
<div class="flex items-center gap-4">
<ToggleButton {...args} selected={selected} onclick={() => selected = !selected}>
Click to toggle
</ToggleButton>
<span class="text-sm text-muted-foreground">Currently: {selected ? 'selected' : 'unselected'}</span>
</div>
{/snippet}
</Story>
<Story
name="Dark Mode"
parameters={{
backgrounds: {
default: 'dark',
},
}}
>
{#snippet template(args)}
<div class="p-8 bg-background text-foreground">
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
<div class="flex items-center gap-4">
<ToggleButton {...args} variant="primary" selected={true}>
Primary
</ToggleButton>
<ToggleButton {...args} variant="secondary" selected={false}>
Secondary
</ToggleButton>
<ToggleButton {...args} variant="tertiary" selected={false}>
Tertiary
</ToggleButton>
</div>
</div>
{/snippet}
</Story>

View File

@@ -10,12 +10,14 @@ import Button from './Button.svelte';
type BaseProps = ComponentProps<typeof Button>;
interface Props extends BaseProps {
/** Alias for `active`. Takes precedence if both are provided. */
/**
* Selected state alias for active
*/
selected?: boolean;
}
let {
variant = 'secondary',
variant = 'tertiary',
size = 'md',
icon,
iconPosition = 'left',

View File

@@ -18,12 +18,36 @@ import PlusIcon from '@lucide/svelte/icons/plus';
import TechText from '../TechText/TechText.svelte';
interface Props {
/**
* Typography control
*/
control: TypographyControl;
/**
* Control label
*/
label?: string;
/**
* CSS classes
*/
class?: string;
/**
* Reduced layout
* @default false
*/
reduced?: boolean;
/**
* Increase button label
* @default 'Increase'
*/
increaseLabel?: string;
/**
* Decrease button label
* @default 'Decrease'
*/
decreaseLabel?: string;
/**
* Control aria label
*/
controlLabel?: string;
}
@@ -39,10 +63,6 @@ let {
let open = $state(false);
function toggleOpen() {
open = !open;
}
// Smart value formatting matching the Figma design
const formattedValue = $derived(() => {
const v = control.value;
@@ -55,7 +75,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
</script>
<!--
── REDUCED MODE ────────────────────────────────────────────────────────────
REDUCED MODE
Inline slider + value. No buttons, no popover.
-->
{#if reduced}
@@ -85,12 +105,15 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
<div class={cn('flex items-center px-1 relative', className)}>
<!-- Decrease button -->
<Button
variant="secondary"
variant="icon"
size="sm"
onclick={control.decrease}
disabled={control.isAtMin}
aria-label={decreaseLabel}
>
<MinusIcon class="size-3.5 stroke-2" />
{#snippet icon()}
<MinusIcon class="size-3.5 stroke-2" />
{/snippet}
</Button>
<!-- Trigger -->
@@ -152,12 +175,15 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
<!-- Increase button -->
<Button
variant="secondary"
variant="icon"
size="sm"
onclick={control.increase}
disabled={control.isAtMax}
aria-label={increaseLabel}
>
<PlusIcon class="size-3.5 stroke-2" />
{#snippet icon()}
<PlusIcon class="size-3.5 stroke-2" />
{/snippet}
</Button>
</div>
{/if}

View File

@@ -56,7 +56,7 @@ let longValue = $state(
}}
>
{#snippet template(args)}
<ContentEditable bind:text={value} {...args} />
<ContentEditable {...args} />
{/snippet}
</Story>
@@ -70,7 +70,7 @@ let longValue = $state(
}}
>
{#snippet template(args)}
<ContentEditable bind:text={smallValue} {...args} />
<ContentEditable {...args} />
{/snippet}
</Story>
@@ -84,7 +84,7 @@ let longValue = $state(
}}
>
{#snippet template(args)}
<ContentEditable bind:text={largeValue} {...args} />
<ContentEditable {...args} />
{/snippet}
</Story>
@@ -98,7 +98,7 @@ let longValue = $state(
}}
>
{#snippet template(args)}
<ContentEditable bind:text={spacedValue} {...args} />
<ContentEditable {...args} />
{/snippet}
</Story>
@@ -112,6 +112,6 @@ let longValue = $state(
}}
>
{#snippet template(args)}
<ContentEditable bind:text={longValue} {...args} />
<ContentEditable {...args} />
{/snippet}
</Story>

View File

@@ -5,19 +5,22 @@
<script lang="ts">
interface Props {
/**
* Visible text (bindable)
* Text content
*/
text: string;
/**
* Font size in pixels
* @default 48
*/
fontSize?: number;
/**
* Line height
* @default 1.2
*/
lineHeight?: number;
/**
* Letter spacing in pixels
* @default 0
*/
letterSpacing?: number;
}

View File

@@ -0,0 +1,32 @@
<!--
Component: ControlGroup
Labeled container for form controls
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
interface Props {
/**
* Group label
*/
label: string;
/**
* Content snippet
*/
children: Snippet;
/**
* CSS classes
*/
class?: string;
}
const { label, children, class: className }: Props = $props();
</script>
<div class={cn('flex flex-col gap-3 py-6 border-b border-black/5 dark:border-white/10 last:border-0', className)}>
<div class="flex justify-between items-center text-[0.6875rem] font-primary font-bold tracking-tight text-neutral-900 dark:text-neutral-100 uppercase leading-none">
{label}
</div>
{@render children?.()}
</div>

View File

@@ -6,7 +6,14 @@
import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props {
/**
* Divider orientation
* @default 'horizontal'
*/
orientation?: 'horizontal' | 'vertical';
/**
* CSS classes
*/
class?: string;
}

View File

@@ -7,19 +7,43 @@ import type { Filter } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Button } from '$shared/ui';
import { Label } from '$shared/ui';
import DotIcon from '@lucide/svelte/icons/dot';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import { cubicOut } from 'svelte/easing';
import { draw } from 'svelte/transition';
import {
draw,
fly,
} from 'svelte/transition';
interface Props {
/** Label for this filter group (e.g., "Provider", "Tags") */
/**
* Group label
*/
displayedLabel: string;
/** Filter entity */
/**
* Filter entity
*/
filter: Filter;
/**
* CSS classes
*/
class?: string;
}
const { displayedLabel, filter, class: className }: Props = $props();
const MAX_DISPLAYED_OPTIONS = 10;
const hasMore = $derived(filter.properties.length > MAX_DISPLAYED_OPTIONS);
let showMore = $state(false);
let displayedProperties = $state(filter.properties.slice(0, MAX_DISPLAYED_OPTIONS));
$effect(() => {
if (showMore) {
displayedProperties = filter.properties;
} else {
displayedProperties = filter.properties.slice(0, MAX_DISPLAYED_OPTIONS);
}
});
</script>
{#snippet icon()}
@@ -55,17 +79,35 @@ const { displayedLabel, filter, class: className }: Props = $props();
</Label>
<div class="flex flex-col gap-1">
{#each filter.properties as property (property.id)}
<Button
variant="tertiary"
active={property.selected}
onclick={() => (property.selected = !property.selected)}
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
iconPosition="right"
icon={property.selected ? icon : undefined}
>
<span>{property.name}</span>
</Button>
{#each displayedProperties as property (property.id)}
<div transition:fly={{ y: 20, duration: 200, easing: cubicOut }}>
<Button
variant="tertiary"
active={property.selected}
onclick={() => (property.selected = !property.selected)}
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
iconPosition="right"
icon={property.selected ? icon : undefined}
>
<span>{property.name}</span>
</Button>
</div>
{/each}
{#if hasMore}
<Button
variant="icon"
onclick={() => (showMore = !showMore)}
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
iconPosition="left"
>
{#snippet icon()}
{#if showMore}
<ChevronUpIcon class="size-4" />
{:else}
<EllipsisIcon class="size-4" />
{/if}
{/snippet}
</Button>
{/if}
</div>
</div>

View File

@@ -7,10 +7,16 @@ import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
interface Props {
/**
* Content snippet
*/
children?: Snippet;
/**
* CSS classes
*/
class?: string;
/**
* Custom render function for full control
* Custom render snippet
*/
render?: Snippet<[{ class: string }]>;
}

View File

@@ -15,29 +15,53 @@ import type {
} from './types';
interface Props extends Omit<HTMLInputAttributes, 'size'> {
/**
* Visual style variant
* @default 'default'
*/
variant?: InputVariant;
/**
* Input size
* @default 'md'
*/
size?: InputSize;
/** Marks the input as invalid — red border + ring, red helper text. */
/**
* Invalid state
*/
error?: boolean;
/** Helper / error message rendered below the input. */
/**
* Helper text
*/
helperText?: string;
/** Show an animated × button when the input has a value. */
/**
* Show clear button
* @default false
*/
showClearButton?: boolean;
/** Called when the clear button is clicked. */
/**
* Clear button callback
*/
onclear?: () => void;
/**
* Snippet for the left icon slot.
* Receives `size` as an argument for convenient icon sizing.
* @example {#snippet leftIcon(size)}<SearchIcon size={inputIconSize[size]} />{/snippet}
* Left icon snippet
*/
leftIcon?: Snippet<[InputSize]>;
/**
* Snippet for the right icon slot (rendered after the clear button).
* Receives `size` as an argument.
* Right icon snippet
*/
rightIcon?: Snippet<[InputSize]>;
/**
* Full width
* @default false
*/
fullWidth?: boolean;
/**
* Input value
*/
value?: string | number | readonly string[];
/**
* CSS classes
*/
class?: string;
}
@@ -56,15 +80,13 @@ let {
...rest
}: Props = $props();
// ── Size config ──────────────────────────────────────────────────────────────
const sizeConfig: Record<InputSize, { input: string; text: string; height: string; clearIcon: number }> = {
sm: { input: 'px-3 py-1.5', text: 'text-sm', height: 'h-8', clearIcon: 12 },
md: { input: 'px-4 py-2', text: 'text-base', height: 'h-10', clearIcon: 14 },
lg: { input: 'px-4 py-3', text: 'text-lg', height: 'h-12', clearIcon: 16 },
xl: { input: 'px-4 py-3', text: 'text-xl', height: 'h-14', clearIcon: 18 },
lg: { input: 'px-4 py-3', text: 'text-lg md:text-xl', height: 'h-12', clearIcon: 16 },
xl: { input: 'px-4 py-3', text: 'text-xl md:text-2xl', height: 'h-14', clearIcon: 18 },
};
// ── Variant config ───────────────────────────────────────────────────────────
const variantConfig: Record<InputVariant, { base: string; focus: string; error: string }> = {
default: {
base: 'bg-paper dark:bg-paper border border-black/5 dark:border-white/10',

Some files were not shown because too many files have changed in this diff Show More