feature/comparison-slider #19

Merged
ilia merged 129 commits from feature/comparison-slider into main 2026-02-02 09:23:46 +00:00
106 changed files with 4276 additions and 1069 deletions

2
.gitignore vendored
View File

@@ -35,6 +35,8 @@ vite.config.ts.timestamp-*
/docs /docs
AGENTS.md AGENTS.md
*.md
!README.md
*storybook.log *storybook.log
storybook-static storybook-static

View File

@@ -16,7 +16,7 @@
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm" "https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
], ],
"typescript": { "typescript": {
"lineWidth": 100, "lineWidth": 120,
"indentWidth": 4, "indentWidth": 4,
"useTabs": false, "useTabs": false,
"semiColons": "prefer", "semiColons": "prefer",
@@ -41,7 +41,7 @@
"lineWidth": 100 "lineWidth": 100
}, },
"markup": { "markup": {
"printWidth": 100, "printWidth": 120,
"indentWidth": 4, "indentWidth": 4,
"useTabs": false, "useTabs": false,
"quotes": "double", "quotes": "double",

View File

@@ -117,6 +117,8 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: 'Karla', system-ui, sans-serif;
font-optical-sizing: auto;
} }
} }
@@ -138,3 +140,25 @@
.peer:focus-visible ~ * { .peer:focus-visible ~ * {
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
} }
@keyframes nudge {
0%, 100% {
transform: translateY(0) scale(1) rotate(0deg);
}
2% {
transform: translateY(-2px) scale(1.1) rotate(-1deg);
}
4% {
transform: translateY(0) scale(1) rotate(1deg);
}
6% {
transform: translateY(-2px) scale(1.1) rotate(0deg);
}
8% {
transform: translateY(0) scale(1) rotate(0deg);
}
}
.animate-nudge {
animation: nudge 10s ease-in-out infinite;
}

View File

@@ -3,47 +3,50 @@
* Layout Component * Layout Component
* *
* Root layout wrapper that provides the application shell structure. Handles favicon, * Root layout wrapper that provides the application shell structure. Handles favicon,
* sidebar provider initialization, and renders child routes with consistent structure. * toolbar provider initialization, and renders child routes with consistent structure.
* *
* Layout structure: * Layout structure:
* - Header area (currently empty, reserved for future use) * - Header area (currently empty, reserved for future use)
* - Collapsible sidebar with main content area
* - Footer area (currently empty, reserved for future use)
* *
* Uses Sidebar.Provider to enable mobile-responsive collapsible sidebar behavior * - Footer area (currently empty, reserved for future use)
* throughout the application.
*/ */
import favicon from '$shared/assets/favicon.svg'; import favicon from '$shared/assets/favicon.svg';
import * as Sidebar from '$shared/shadcn/ui/sidebar/index'; import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
import { FiltersSidebar } from '$widgets/FiltersSidebar'; import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import TypographyMenu from '$widgets/TypographySettings/ui/TypographyMenu.svelte'; import { TypographyMenu } from '$widgets/TypographySettings';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
}
/** Slot content for route pages to render */ /** Slot content for route pages to render */
let { children } = $props(); let { children }: Props = $props();
</script> </script>
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
<link rel="preconnect" href="https://api.fontshare.com" /> <link rel="preconnect" href="https://api.fontshare.com" />
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" /> <link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous">
<link
href="https://fonts.googleapis.com/css2?family=Karla:ital,wght@0,200..800;1,200..800&display=swap"
rel="stylesheet"
>
</svelte:head> </svelte:head>
<div id="app-root"> <div id="app-root" class="min-h-screen flex flex-col bg-background">
<header></header> <header></header>
<Sidebar.Provider> <!-- <ScrollArea class="h-screen w-screen"> -->
<FiltersSidebar /> <main class="flex-1 h-full w-full max-w-6xl mx-auto px-4 pt-6 pb-10 md:px-8 lg:pt-10 lg:pb-20 relative">
<main class="w-dvw"> <TooltipProvider>
<TypographyMenu /> <TypographyMenu />
{@render children?.()} {@render children?.()}
</TooltipProvider>
</main> </main>
</Sidebar.Provider> <!-- </ScrollArea> -->
<footer></footer> <footer></footer>
</div> </div>
<style>
#app-root {
width: 100%;
height: 100vh;
}
</style>

View File

@@ -0,0 +1,2 @@
export { scrollBreadcrumbsStore } from './model';
export { BreadcrumbHeader } from './ui';

View File

@@ -0,0 +1 @@
export * from './store/scrollBreadcrumbsStore.svelte';

View File

@@ -0,0 +1,29 @@
import type { Snippet } from 'svelte';
export interface BreadcrumbItem {
index: number;
title: Snippet<[{ className?: string }]>;
}
class ScrollBreadcrumbsStore {
#items = $state<BreadcrumbItem[]>([]);
get items() {
// Keep them sorted by index for Swiss orderliness
return this.#items.sort((a, b) => a.index - b.index);
}
add(item: BreadcrumbItem) {
if (!this.#items.find(i => i.index === item.index)) {
this.#items.push(item);
}
}
remove(index: number) {
this.#items = this.#items.filter(i => i.index !== index);
}
}
export function createScrollBreadcrumbsStore() {
return new ScrollBreadcrumbsStore();
}
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();

View File

@@ -0,0 +1,78 @@
<!--
Component: BreadcrumbHeader
Fixed header for breadcrumbs navigation for sections in the page
-->
<script lang="ts">
import Icon from '@lucide/svelte/icons/align-vertical-justify-center';
import { flip } from 'svelte/animate';
import { slide } from 'svelte/transition';
import { scrollBreadcrumbsStore } from '../../model';
</script>
{#if scrollBreadcrumbsStore.items.length > 0}
<div
transition:slide={{ duration: 200 }}
class="
fixed top-0 left-0 right-0 z-100
backdrop-blur-lg bg-white/20
border-b border-gray-300/50
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
h-12
"
>
<div class="max-w-8xl mx-auto px-6 h-full flex items-center gap-4">
<div class="flex items-center gap-2.5 opacity-70">
<Icon class="size-4 stroke-gray-900 stroke-1" />
<div class="w-px h-2.5 bg-gray-400/50"></div>
<span class="font-mono text-[9px] uppercase tracking-[0.25em] text-gray-500 font-medium">
nav_trace
</span>
</div>
<div class="h-4 w-px bg-gray-300/60"></div>
<nav class="flex items-center gap-3 overflow-x-auto scrollbar-hide flex-1">
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
<div
animate:flip={{ duration: 200 }}
class="flex items-center gap-3 whitespace-nowrap shrink-0"
>
<span class="font-mono text-[9px] text-gray-400 tracking-wider">
{String(item.index).padStart(2, '0')}
</span>
{@render item.title({
className: 'font-mono text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-gray-900',
})}
{#if idx < scrollBreadcrumbsStore.items.length - 1}
<div class="flex items-center gap-0.5 opacity-40">
<div class="w-1 h-px bg-gray-400"></div>
<div class="w-1 h-px bg-gray-400"></div>
<div class="w-1 h-px bg-gray-400"></div>
</div>
{/if}
</div>
{/each}
</nav>
<div class="flex items-center gap-2 opacity-50 ml-auto">
<div class="w-px h-2.5 bg-gray-300/60"></div>
<span class="font-mono text-[8px] text-gray-400 tracking-wider">
[{scrollBreadcrumbsStore.items.length}]
</span>
</div>
</div>
</div>
{/if}
<style>
/* Hide scrollbar but keep functionality */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -0,0 +1,3 @@
import BreadcrumbHeader from './BreadcrumbHeader/BreadcrumbHeader.svelte';
export { BreadcrumbHeader };

View File

@@ -4,6 +4,18 @@
* Exports API clients and normalization utilities * Exports API clients and normalization utilities
*/ */
// Proxy API (PRIMARY - NEW)
export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
} from './proxy/proxyFonts';
export type {
ProxyFontsParams,
ProxyFontsResponse,
} from './proxy/proxyFonts';
// Google Fonts API (DEPRECATED - kept for backward compatibility)
export { export {
fetchGoogleFontFamily, fetchGoogleFontFamily,
fetchGoogleFonts, fetchGoogleFonts,
@@ -14,6 +26,7 @@ export type {
GoogleFontsResponse, GoogleFontsResponse,
} from './google/googleFonts'; } from './google/googleFonts';
// Fontshare API (DEPRECATED - kept for backward compatibility)
export { export {
fetchAllFontshareFonts, fetchAllFontshareFonts,
fetchFontshareFontBySlug, fetchFontshareFontBySlug,

View File

@@ -0,0 +1,279 @@
/**
* Proxy API client
*
* Handles API requests to GlyphDiff proxy API for fetching font metadata.
* Provides error handling, pagination support, and type-safe responses.
*
* Proxy API normalizes font data from Google Fonts and Fontshare into a single
* unified format, eliminating the need for client-side normalization.
*
* Fallback: If proxy API fails, falls back to Fontshare API for development.
*
* @see https://api.glyphdiff.com/api/v1/fonts
*/
import { api } from '$shared/api/api';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type { UnifiedFont } from '../../model/types';
import type {
FontCategory,
FontSubset,
} from '../../model/types';
/**
* Proxy API base URL
*/
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
/**
* Whether to use proxy API (true) or fallback (false)
*
* Set to true when your proxy API is ready:
* const USE_PROXY_API = true;
*
* Set to false to use Fontshare API as fallback during development:
* const USE_PROXY_API = false;
*
* The app will automatically fall back to Fontshare API if the proxy fails.
*/
const USE_PROXY_API = true;
/**
* Proxy API parameters
*
* Maps directly to the proxy API query parameters
*/
export interface ProxyFontsParams extends QueryParams {
/**
* Font provider filter ("google" or "fontshare")
* Omit to fetch from both providers
*/
provider?: 'google' | 'fontshare';
/**
* Font category filter
*/
category?: FontCategory;
/**
* Character subset filter
*/
subset?: FontSubset;
/**
* Search query (e.g., "roboto", "satoshi")
*/
q?: string;
/**
* Sort order for results
* "name" - Alphabetical by font name
* "popularity" - Most popular first
* "lastModified" - Recently updated first
*/
sort?: 'name' | 'popularity' | 'lastModified';
/**
* Number of items to return (pagination)
*/
limit?: number;
/**
* Number of items to skip (pagination)
* Use for pagination: offset = (page - 1) * limit
*/
offset?: number;
}
/**
* Proxy API response
*
* Includes pagination metadata alongside font data
*/
export interface ProxyFontsResponse {
/** Array of unified font objects */
fonts: UnifiedFont[];
/** Total number of fonts matching the query */
total: number;
/** Limit used for this request */
limit: number;
/** Offset used for this request */
offset: number;
}
/**
* Fetch fonts from proxy API
*
* If proxy API fails or is unavailable, falls back to Fontshare API for development.
*
* @param params - Query parameters for filtering and pagination
* @returns Promise resolving to proxy API response
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all sans-serif fonts from Google
* const response = await fetchProxyFonts({
* provider: 'google',
* category: 'sans-serif',
* limit: 50,
* offset: 0
* });
*
* // Search fonts across all providers
* const searchResponse = await fetchProxyFonts({
* q: 'roboto',
* limit: 20
* });
*
* // Fetch fonts with pagination
* const page1 = await fetchProxyFonts({ limit: 50, offset: 0 });
* const page2 = await fetchProxyFonts({ limit: 50, offset: 50 });
* ```
*/
export async function fetchProxyFonts(
params: ProxyFontsParams = {},
): Promise<ProxyFontsResponse> {
// Try proxy API first if enabled
if (USE_PROXY_API) {
try {
const queryString = buildQueryString(params);
const url = `${PROXY_API_URL}${queryString}`;
console.log('[fetchProxyFonts] Fetching from proxy API', { params, url });
const response = await api.get<ProxyFontsResponse>(url);
// Validate response has fonts array
if (!response.data || !Array.isArray(response.data.fonts)) {
console.error('[fetchProxyFonts] Invalid response from proxy API', response.data);
throw new Error('Proxy API returned invalid response');
}
console.log('[fetchProxyFonts] Proxy API success', {
count: response.data.fonts.length,
});
return response.data;
} catch (error) {
console.warn('[fetchProxyFonts] Proxy API failed, using fallback', error);
// Check if it's a network error or proxy not available
const isNetworkError = error instanceof Error
&& (error.message.includes('Failed to fetch')
|| error.message.includes('Network')
|| error.message.includes('404')
|| error.message.includes('500'));
if (isNetworkError) {
// Fall back to Fontshare API
console.log('[fetchProxyFonts] Using Fontshare API as fallback');
return await fetchFontshareFallback(params);
}
// Re-throw other errors
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to fetch fonts from proxy API: ${String(error)}`);
}
}
// Use Fontshare API directly
console.log('[fetchProxyFonts] Using Fontshare API (proxy disabled)');
return await fetchFontshareFallback(params);
}
/**
* Fallback to Fontshare API when proxy is unavailable
*
* Maps proxy API params to Fontshare API params and normalizes response
*/
async function fetchFontshareFallback(
params: ProxyFontsParams,
): Promise<ProxyFontsResponse> {
// Import dynamically to avoid circular dependency
const { fetchFontshareFonts } = await import('$entities/Font/api/fontshare/fontshare');
const { normalizeFontshareFonts } = await import('$entities/Font/lib/normalize/normalize');
// Map proxy params to Fontshare params
const fontshareParams = {
q: params.q,
categories: params.category ? [params.category] : undefined,
page: params.offset ? Math.floor(params.offset / (params.limit || 50)) + 1 : undefined,
limit: params.limit,
};
const response = await fetchFontshareFonts(fontshareParams);
const normalizedFonts = normalizeFontshareFonts(response.fonts);
return {
fonts: normalizedFonts,
total: response.count_total,
limit: params.limit || response.count,
offset: params.offset || 0,
};
}
/**
* Fetch font by ID
*
* Convenience function for fetching a single font by ID
* Note: This fetches a page and filters client-side, which is not ideal
* For production, consider adding a dedicated endpoint to the proxy API
*
* @param id - Font ID (family name for Google, slug for Fontshare)
* @returns Promise resolving to font or undefined
*
* @example
* ```ts
* const roboto = await fetchProxyFontById('Roboto');
* const satoshi = await fetchProxyFontById('satoshi');
* ```
*/
export async function fetchProxyFontById(
id: string,
): Promise<UnifiedFont | undefined> {
const response = await fetchProxyFonts({ limit: 1000, q: id });
if (!response || !response.fonts) {
console.error('[fetchProxyFontById] No fonts in response', { response });
return undefined;
}
return response.fonts.find(font => font.id === id);
}
/**
* Fetch multiple fonts by their IDs
*
* @param ids - Array of font IDs to fetch
* @returns Promise resolving to an array of fonts
*/
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
if (ids.length === 0) return [];
// Use proxy API if enabled
if (USE_PROXY_API) {
const queryString = ids.join(',');
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
try {
const response = await api.get<UnifiedFont[]>(url);
return response.data ?? [];
} catch (error) {
console.warn('[fetchFontsByIds] Proxy API batch fetch failed, falling back', error);
// Fallthrough to fallback
}
}
// Fallback: Fetch individually (not efficient but functional for fallback)
const results = await Promise.all(
ids.map(id => fetchProxyFontById(id)),
);
return results.filter((f): f is UnifiedFont => !!f);
}

View File

@@ -1,3 +1,15 @@
// Proxy API (PRIMARY)
export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
} from './api/proxy/proxyFonts';
export type {
ProxyFontsParams,
ProxyFontsResponse,
} from './api/proxy/proxyFonts';
// Fontshare API (DEPRECATED)
export { export {
fetchAllFontshareFonts, fetchAllFontshareFonts,
fetchFontshareFontBySlug, fetchFontshareFontBySlug,
@@ -7,6 +19,8 @@ export type {
FontshareParams, FontshareParams,
FontshareResponse, FontshareResponse,
} from './api/fontshare/fontshare'; } from './api/fontshare/fontshare';
// Google Fonts API (DEPRECATED)
export { export {
fetchGoogleFontFamily, fetchGoogleFontFamily,
fetchGoogleFonts, fetchGoogleFonts,
@@ -42,7 +56,6 @@ export type {
FontshareFont, FontshareFont,
FontshareLink, FontshareLink,
FontsharePublisher, FontsharePublisher,
FontshareStore,
FontshareStyle, FontshareStyle,
FontshareStyleProperties, FontshareStyleProperties,
FontshareTag, FontshareTag,
@@ -61,18 +74,11 @@ export type {
export { export {
appliedFontsManager, appliedFontsManager,
createFontshareStore, createUnifiedFontStore,
fetchFontshareFontsQuery,
fontshareStore,
selectedFontsStore, selectedFontsStore,
unifiedFontStore,
} from './model'; } from './model';
// Stores
export {
createGoogleFontsStore,
GoogleFontsStore,
} from './model/services/fetchGoogleFonts.svelte';
// UI elements // UI elements
export { export {
FontApplicator, FontApplicator,

View File

@@ -24,11 +24,9 @@ describe('Font Normalization', () => {
subsets: ['latin', 'latin-ext'], subsets: ['latin', 'latin-ext'],
files: { files: {
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2', regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
'700': '700': 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2', italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
'700italic': '700italic': 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
}, },
version: 'v30', version: 'v30',
lastModified: '2022-01-01', lastModified: '2022-01-01',

View File

@@ -34,12 +34,10 @@ export type {
UnifiedFontVariant, UnifiedFontVariant,
} from './types'; } from './types';
export { fetchFontshareFontsQuery } from './services';
export { export {
appliedFontsManager, appliedFontsManager,
createFontshareStore, createUnifiedFontStore,
type FontshareStore,
fontshareStore,
selectedFontsStore, selectedFontsStore,
type UnifiedFontStore,
unifiedFontStore,
} from './store'; } from './store';

View File

@@ -1,31 +0,0 @@
import {
type FontshareParams,
fetchFontshareFonts,
} from '../../api';
import { normalizeFontshareFonts } from '../../lib';
import type { UnifiedFont } from '../types';
/**
* Query function for fetching fonts from Fontshare.
*
* @param params - The parameters for fetching fonts from Fontshare (E.g. search query, page number, etc.).
* @returns A promise that resolves with an array of UnifiedFont objects representing the fonts found in Fontshare.
*/
export async function fetchFontshareFontsQuery(params: FontshareParams): Promise<UnifiedFont[]> {
try {
const response = await fetchFontshareFonts(params);
return normalizeFontshareFonts(response.fonts);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('Failed to fetch')) {
throw new Error(
'Unable to connect to Fontshare. Please check your internet connection.',
);
}
if (error.message.includes('404')) {
throw new Error('Font not found in Fontshare catalog.');
}
}
throw new Error('Failed to load fonts from Fontshare.');
}
}

View File

@@ -1,274 +0,0 @@
/**
* Service for fetching Google Fonts with Svelte 5 runes + TanStack Query
*/
import {
type CreateQueryResult,
createQuery,
useQueryClient,
} from '@tanstack/svelte-query';
import {
type GoogleFontsParams,
fetchGoogleFonts,
} from '../../api';
import { normalizeGoogleFonts } from '../../lib';
import type {
FontCategory,
FontSubset,
} from '../types';
import type { UnifiedFont } from '../types/normalize';
/**
* Query key factory
*/
function getGoogleFontsQueryKey(params: GoogleFontsParams) {
return ['googleFonts', params] as const;
}
/**
* Query function
*/
export async function fetchGoogleFontsQuery(params: GoogleFontsParams): Promise<UnifiedFont[]> {
try {
const response = await fetchGoogleFonts({
category: params.category,
subset: params.subset,
sort: params.sort,
});
return normalizeGoogleFonts(response.items);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('Failed to fetch')) {
throw new Error(
'Unable to connect to Google Fonts. Please check your internet connection.',
);
}
if (error.message.includes('404')) {
throw new Error('Font not found in Google Fonts catalog.');
}
}
throw new Error('Failed to load fonts from Google Fonts.');
}
}
/**
* Google Fonts store wrapping TanStack Query with runes
*/
export class GoogleFontsStore {
params = $state<GoogleFontsParams>({});
private query: CreateQueryResult<UnifiedFont[], Error>;
private queryClient = useQueryClient();
constructor(initialParams: GoogleFontsParams = {}) {
this.params = initialParams;
// Create the query - automatically reactive
this.query = createQuery(() => ({
queryKey: getGoogleFontsQueryKey(this.params),
queryFn: () => fetchGoogleFontsQuery(this.params),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
}));
}
// Proxy TanStack Query's reactive state
get fonts() {
return this.query.data ?? [];
}
get isLoading() {
return this.query.isLoading;
}
get isFetching() {
return this.query.isFetching;
}
get isRefetching() {
return this.query.isRefetching;
}
get error() {
return this.query.error;
}
get isError() {
return this.query.isError;
}
get isSuccess() {
return this.query.isSuccess;
}
get status() {
return this.query.status;
}
// Derived helpers
get hasData() {
return this.fonts.length > 0;
}
get isEmpty() {
return !this.isLoading && this.fonts.length === 0;
}
get fontCount() {
return this.fonts.length;
}
// Filtered fonts by category (if you need additional client-side filtering)
get sansSerifFonts() {
return this.fonts.filter(f => f.category === 'sans-serif');
}
get serifFonts() {
return this.fonts.filter(f => f.category === 'serif');
}
get displayFonts() {
return this.fonts.filter(f => f.category === 'display');
}
get handwritingFonts() {
return this.fonts.filter(f => f.category === 'handwriting');
}
get monospaceFonts() {
return this.fonts.filter(f => f.category === 'monospace');
}
/**
* Update parameters - TanStack Query will automatically refetch
*/
setParams(newParams: Partial<GoogleFontsParams>) {
this.params = { ...this.params, ...newParams };
}
setCategory(category: FontCategory | undefined) {
this.setParams({ category });
}
setSubset(subset: FontSubset | undefined) {
this.setParams({ subset });
}
setSort(sort: 'popularity' | 'alpha' | 'date' | undefined) {
this.setParams({ sort });
}
setSearch(search: string) {
this.setParams({ search });
}
clearSearch() {
this.setParams({ search: undefined });
}
clearFilters() {
this.params = {};
}
/**
* Manually refetch
*/
async refetch() {
await this.query.refetch();
}
/**
* Invalidate cache and refetch
*/
invalidate() {
this.queryClient.invalidateQueries({
queryKey: getGoogleFontsQueryKey(this.params),
});
}
/**
* Invalidate all Google Fonts queries
*/
invalidateAll() {
this.queryClient.invalidateQueries({
queryKey: ['googleFonts'],
});
}
/**
* Prefetch with different params (for hover states, pagination, etc.)
*/
async prefetch(params: GoogleFontsParams) {
await this.queryClient.prefetchQuery({
queryKey: getGoogleFontsQueryKey(params),
queryFn: () => fetchGoogleFontsQuery(params),
staleTime: 5 * 60 * 1000,
});
}
/**
* Prefetch next category (useful for tab switching)
*/
async prefetchCategory(category: FontCategory) {
await this.prefetch({ ...this.params, category });
}
/**
* Cancel ongoing queries
*/
cancel() {
this.queryClient.cancelQueries({
queryKey: getGoogleFontsQueryKey(this.params),
});
}
/**
* Clear cache for current params
*/
clearCache() {
this.queryClient.removeQueries({
queryKey: getGoogleFontsQueryKey(this.params),
});
}
/**
* Get cached data without triggering fetch
*/
getCachedData() {
return this.queryClient.getQueryData<UnifiedFont[]>(
getGoogleFontsQueryKey(this.params),
);
}
/**
* Check if data exists in cache
*/
hasCache(params?: GoogleFontsParams) {
const key = params ? getGoogleFontsQueryKey(params) : getGoogleFontsQueryKey(this.params);
return this.queryClient.getQueryData(key) !== undefined;
}
/**
* Set data manually (optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
this.queryClient.setQueryData(
getGoogleFontsQueryKey(this.params),
updater,
);
}
/**
* Get query state for debugging
*/
getQueryState() {
return this.queryClient.getQueryState(
getGoogleFontsQueryKey(this.params),
);
}
}
/**
* Factory function to create Google Fonts store
*/
export function createGoogleFontsStore(params: GoogleFontsParams = {}) {
return new GoogleFontsStore(params);
}

View File

@@ -1,2 +0,0 @@
export { fetchFontshareFontsQuery } from './fetchFontshareFonts.svelte';
export { fetchGoogleFontsQuery } from './fetchGoogleFonts.svelte';

View File

@@ -2,147 +2,167 @@ import { SvelteMap } from 'svelte/reactivity';
export type FontStatus = 'loading' | 'loaded' | 'error'; export type FontStatus = 'loading' | 'loaded' | 'error';
export interface FontConfigRequest {
/** /**
* Manager that handles loading of the fonts * Font id
* Adds <link /> tags to <head /> */
* - Uses batch loading to reduce the number of requests id: string;
* - Uses a queue to prevent too many requests at once /**
* - Purges unused fonts after a certain time * Real font name (e.g. "Lato")
*/
name: string;
/**
* The .ttf URL
*/
url: string;
/**
* Font weight
*/
weight: number;
/**
* Flag of the variable weight
*/
isVariable?: boolean;
}
/**
* Manager that handles loading of fonts.
* Logic:
* - Variable fonts: Loaded once per id (covers all weights).
* - Static fonts: Loaded per id + weight combination.
*/ */
class AppliedFontsManager { class AppliedFontsManager {
// Stores: slug -> timestamp of last visibility
#usageTracker = new Map<string, number>(); #usageTracker = new Map<string, number>();
// Stores: slug -> batchId #idToBatch = new Map<string, string>();
#slugToBatch = new Map<string, string>(); // Changed to HTMLStyleElement
// Stores: batchId -> HTMLLinkElement (for physical cleanup) #batchElements = new Map<string, HTMLStyleElement>();
#batchElements = new Map<string, HTMLLinkElement>();
#queue = new Set<string>(); #queue = new Map<string, FontConfigRequest>(); // Track config in queue
#timeoutId: ReturnType<typeof setTimeout> | null = null; #timeoutId: ReturnType<typeof setTimeout> | null = null;
#PURGE_INTERVAL = 60000; // Check every minute
#TTL = 5 * 60 * 1000; // 5 minutes #PURGE_INTERVAL = 60000;
#CHUNK_SIZE = 3; #TTL = 5 * 60 * 1000;
#CHUNK_SIZE = 5; // Can be larger since we're just injecting strings
statuses = new SvelteMap<string, FontStatus>(); statuses = new SvelteMap<string, FontStatus>();
constructor() { constructor() {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// Start the "Janitor" loop
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL); setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
} }
} }
/** #getFontKey(id: string, weight: number): string {
* Updates the 'last seen' timestamp for fonts. return `${id.toLowerCase()}@${weight}`;
* Prevents them from being purged while they are on screen. }
*/
touch(slugs: string[]) { touch(configs: FontConfigRequest[]) {
const now = Date.now(); const now = Date.now();
const toRegister: string[] = []; configs.forEach(config => {
const key = this.#getFontKey(config.id, config.weight);
this.#usageTracker.set(key, now);
slugs.forEach(slug => { if (!this.#idToBatch.has(key) && !this.#queue.has(key)) {
this.#usageTracker.set(slug, now); this.#queue.set(key, config);
if (!this.#slugToBatch.has(slug)) {
toRegister.push(slug);
}
});
if (toRegister.length > 0) this.registerFonts(toRegister);
}
registerFonts(slugs: string[]) {
const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s));
if (newSlugs.length === 0) return;
newSlugs.forEach(s => this.#queue.add(s));
if (this.#timeoutId) clearTimeout(this.#timeoutId); if (this.#timeoutId) clearTimeout(this.#timeoutId);
this.#timeoutId = setTimeout(() => this.#processQueue(), 50); this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
} }
});
}
getFontStatus(slug: string) { getFontStatus(id: string, weight: number) {
return this.statuses.get(slug); return this.statuses.get(this.#getFontKey(id, weight));
} }
#processQueue() { #processQueue() {
const fullQueue = Array.from(this.#queue); const entries = Array.from(this.#queue.entries());
if (fullQueue.length === 0) return; if (entries.length === 0) return;
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) { for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) {
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE)); this.#createBatch(entries.slice(i, i + this.#CHUNK_SIZE));
} }
this.#queue.clear(); this.#queue.clear();
this.#timeoutId = null; this.#timeoutId = null;
} }
#createBatch(slugs: string[]) { #createBatch(batchEntries: [string, FontConfigRequest][]) {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
const batchId = crypto.randomUUID(); const batchId = crypto.randomUUID();
// font-display=swap included for better UX let cssRules = '';
const query = slugs.map(s => `f[]=${s.toLowerCase()}@400`).join('&');
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
// Mark all as loading immediately batchEntries.forEach(([key, config]) => {
slugs.forEach(slug => this.statuses.set(slug, 'loading')); this.statuses.set(key, 'loading');
this.#idToBatch.set(key, batchId);
const link = document.createElement('link'); // Construct the @font-face rule
link.rel = 'stylesheet'; // Using format('truetype') for .ttf
link.href = url; cssRules += `
link.dataset.batchId = batchId; @font-face {
document.head.appendChild(link); font-family: '${config.name}';
src: url('${config.url}') format('truetype');
this.#batchElements.set(batchId, link); font-weight: ${config.weight};
slugs.forEach(slug => { font-style: normal;
this.#slugToBatch.set(slug, batchId); font-display: swap;
// Use the Native Font Loading API
// format: "font-size font-family"
document.fonts.load(`1em "${slug}"`)
.then(loadedFonts => {
if (loadedFonts.length > 0) {
this.statuses.set(slug, 'loaded');
} else {
this.statuses.set(slug, 'error');
} }
}) `;
.catch(() => {
this.statuses.set(slug, 'error');
}); });
// Create and inject the style tag
const style = document.createElement('style');
style.dataset.batchId = batchId;
style.innerHTML = cssRules;
document.head.appendChild(style);
this.#batchElements.set(batchId, style);
// Verify loading via Font Loading API
batchEntries.forEach(([key, config]) => {
document.fonts.load(`${config.weight} 1em "${config.name}"`)
.then(loaded => {
this.statuses.set(key, loaded.length > 0 ? 'loaded' : 'error');
})
.catch(() => this.statuses.set(key, 'error'));
}); });
} }
#purgeUnused() { #purgeUnused() {
const now = Date.now(); const now = Date.now();
const batchesToPotentialDelete = new Set<string>(); const batchesToRemove = new Set<string>();
const slugsToDelete: string[] = []; const keysToRemove: string[] = [];
// Identify expired slugs for (const [key, lastUsed] of this.#usageTracker.entries()) {
for (const [slug, lastUsed] of this.#usageTracker.entries()) {
if (now - lastUsed > this.#TTL) { if (now - lastUsed > this.#TTL) {
const batchId = this.#slugToBatch.get(slug); const batchId = this.#idToBatch.get(key);
if (batchId) batchesToPotentialDelete.add(batchId); if (batchId) {
slugsToDelete.push(slug); // Check if EVERY font in this batch is expired
} const batchKeys = Array.from(this.#idToBatch.entries())
}
// Only remove a batch if ALL fonts in that batch are expired
batchesToPotentialDelete.forEach(batchId => {
const batchSlugs = Array.from(this.#slugToBatch.entries())
.filter(([_, bId]) => bId === batchId) .filter(([_, bId]) => bId === batchId)
.map(([slug]) => slug); .map(([k]) => k);
const allExpired = batchSlugs.every(s => slugsToDelete.includes(s)); const canDeleteBatch = batchKeys.every(k => {
const lastK = this.#usageTracker.get(k);
if (allExpired) { return lastK && (now - lastK > this.#TTL);
this.#batchElements.get(batchId)?.remove();
this.#batchElements.delete(batchId);
batchSlugs.forEach(s => {
this.#slugToBatch.delete(s);
this.#usageTracker.delete(s);
}); });
if (canDeleteBatch) {
batchesToRemove.add(batchId);
keysToRemove.push(...batchKeys);
} }
}
}
}
batchesToRemove.forEach(id => {
this.#batchElements.get(id)?.remove();
this.#batchElements.delete(id);
});
keysToRemove.forEach(k => {
this.#idToBatch.delete(k);
this.#usageTracker.delete(k);
this.statuses.delete(k);
}); });
} }
} }

View File

@@ -59,6 +59,7 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
queryKey: this.getQueryKey(params), queryKey: this.getQueryKey(params),
queryFn: () => this.fetchFn(params), queryFn: () => this.fetchFn(params),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
}; };
} }

View File

@@ -1,32 +0,0 @@
import type { FontshareParams } from '../../api';
import { fetchFontshareFontsQuery } from '../services';
import type { UnifiedFont } from '../types';
import { BaseFontStore } from './baseFontStore.svelte';
/**
* Fontshare store wrapping TanStack Query with runes
*/
export class FontshareStore extends BaseFontStore<FontshareParams> {
constructor(initialParams: FontshareParams = {}) {
super(initialParams);
}
protected getQueryKey(params: FontshareParams) {
return ['fontshare', params] as const;
}
protected async fetchFn(params: FontshareParams): Promise<UnifiedFont[]> {
return fetchFontshareFontsQuery(params);
}
// Provider-specific methods (shortcuts)
setSearch(search: string) {
this.setParams({ q: search } as any);
}
}
export function createFontshareStore(params: FontshareParams = {}) {
return new FontshareStore(params);
}
export const fontshareStore = new FontshareStore();

View File

@@ -1,27 +0,0 @@
import type { GoogleFontsParams } from '../../api';
import { fetchGoogleFontsQuery } from '../services';
import type { UnifiedFont } from '../types';
import { BaseFontStore } from './baseFontStore.svelte';
/**
* Google Fonts store wrapping TanStack Query with runes
*/
export class GoogleFontsStore extends BaseFontStore<GoogleFontsParams> {
constructor(initialParams: GoogleFontsParams = {}) {
super(initialParams);
}
protected getQueryKey(params: GoogleFontsParams) {
return ['googleFonts', params] as const;
}
protected async fetchFn(params: GoogleFontsParams): Promise<UnifiedFont[]> {
return fetchGoogleFontsQuery(params);
}
}
export function createFontshareStore(params: GoogleFontsParams = {}) {
return new GoogleFontsStore(params);
}
export const googleFontsStore = new GoogleFontsStore();

View File

@@ -6,18 +6,15 @@
* Single export point for the unified font store infrastructure. * Single export point for the unified font store infrastructure.
*/ */
// export { // Primary store (unified)
// createUnifiedFontStore,
// UNIFIED_FONT_STORE_KEY,
// type UnifiedFontStore,
// } from './unifiedFontStore.svelte';
export { export {
createFontshareStore, createUnifiedFontStore,
type FontshareStore, type UnifiedFontStore,
fontshareStore, unifiedFontStore,
} from './fontshareStore.svelte'; } from './unifiedFontStore.svelte';
// Applied fonts manager (CSS loading - unchanged)
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
// Selected fonts store (user selection - unchanged)
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte'; export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';

View File

@@ -1,25 +1,354 @@
import { type Filter } from '$shared/lib'; /**
import { SvelteMap } from 'svelte/reactivity'; * Unified font store
import type { FontProvider } from '../types'; *
import type { CheckboxFilter } from '../types/common'; * Single source of truth for font data, powered by the proxy API.
import type { BaseFontStore } from './baseFontStore.svelte'; * Extends BaseFontStore for TanStack Query integration and reactivity.
import { createFontshareStore } from './fontshareStore.svelte'; *
import type { ProviderParams } from './types'; * Key features:
* - Provider-agnostic (proxy API handles provider logic)
* - Reactive to filter changes
* - Optimistic updates via TanStack Query
* - Pagination support
* - Provider-specific shortcuts for common operations
*/
export class UnitedFontStore { import type { ProxyFontsParams } from '../../api';
private sources: Partial<Record<FontProvider, BaseFontStore<ProviderParams>>>; import { fetchProxyFonts } from '../../api';
import type { UnifiedFont } from '../types';
import { BaseFontStore } from './baseFontStore.svelte';
filters: SvelteMap<CheckboxFilter, Filter>; /**
queryValue = $state(''); * Unified font store wrapping TanStack Query with Svelte 5 runes
*
* Extends BaseFontStore to provide:
* - Reactive state management
* - TanStack Query integration for caching
* - Dynamic parameter binding for filters
* - Pagination support
*
* @example
* ```ts
* const store = new UnifiedFontStore({
* provider: 'google',
* category: 'sans-serif',
* limit: 50
* });
*
* // Access reactive state
* $effect(() => {
* console.log(store.fonts);
* console.log(store.isLoading);
* console.log(store.pagination);
* });
*
* // Update parameters
* store.setCategory('serif');
* store.nextPage();
* ```
*/
export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
/**
* Store pagination metadata separately from fonts
* This is a workaround for TanStack Query's type system
*/
#paginationMetadata = $state<
{
total: number;
limit: number;
offset: number;
} | null
>(null);
constructor(initialConfig: Partial<Record<FontProvider, ProviderParams>> = {}) { /**
this.sources = { * Accumulated fonts from all pages (for infinite scroll)
fontshare: createFontshareStore(initialConfig?.fontshare), */
#accumulatedFonts = $state<UnifiedFont[]>([]);
/**
* Pagination metadata (derived from proxy API response)
*/
readonly pagination = $derived.by(() => {
if (this.#paginationMetadata) {
const { total, limit, offset } = this.#paginationMetadata;
return {
total,
limit,
offset,
hasMore: offset + limit < total,
page: Math.floor(offset / limit) + 1,
totalPages: Math.ceil(total / limit),
}; };
this.filters = new SvelteMap(); }
return {
total: 0,
limit: this.params.limit || 50,
offset: this.params.offset || 0,
hasMore: false,
page: 1,
totalPages: 0,
};
});
/**
* Track previous filter params to detect changes and reset pagination
*/
#previousFilterParams = $state<string>('');
/**
* Cleanup function for the filter tracking effect
*/
#filterCleanup: (() => void) | null = null;
constructor(initialParams: ProxyFontsParams = {}) {
super(initialParams);
// Track filter params (excluding pagination params)
// Wrapped in $effect.root() to prevent effect_orphan error
this.#filterCleanup = $effect.root(() => {
$effect(() => {
const filterParams = JSON.stringify({
provider: this.params.provider,
category: this.params.category,
subset: this.params.subset,
q: this.params.q,
});
// If filters changed, reset offset to 0
if (filterParams !== this.#previousFilterParams) {
if (this.#previousFilterParams && this.params.offset !== 0) {
this.setParams({ offset: 0 });
}
this.#previousFilterParams = filterParams;
}
});
});
} }
get fonts() { /**
return Object.values(this.sources).map(store => store.fonts).flat(); * Clean up both parent and child effects
*/
destroy() {
// Call parent cleanup (TanStack observer effect)
super.destroy();
// Call filter tracking effect cleanup
if (this.#filterCleanup) {
this.#filterCleanup();
this.#filterCleanup = null;
} }
} }
/**
* Query key for TanStack Query caching
* Normalizes params to treat empty arrays/strings as undefined
*/
protected getQueryKey(params: ProxyFontsParams) {
// Normalize params to treat empty arrays/strings as undefined
const normalized = Object.entries(params).reduce((acc, [key, value]) => {
if (value === '' || (Array.isArray(value) && value.length === 0)) {
return acc;
}
return { ...acc, [key]: value };
}, {});
return ['unifiedFonts', normalized] as const;
}
/**
* Fetch function that calls the proxy API
* Returns the full response including pagination metadata
*/
protected async fetchFn(params: ProxyFontsParams): Promise<UnifiedFont[]> {
const response = await fetchProxyFonts(params);
// Validate response structure
if (!response) {
console.error('[UnifiedFontStore] fetchProxyFonts returned undefined', { params });
throw new Error('Proxy API returned undefined response');
}
if (!response.fonts) {
console.error('[UnifiedFontStore] response.fonts is undefined', { response });
throw new Error('Proxy API response missing fonts array');
}
if (!Array.isArray(response.fonts)) {
console.error('[UnifiedFontStore] response.fonts is not an array', {
fonts: response.fonts,
});
throw new Error('Proxy API fonts is not an array');
}
// Store pagination metadata separately for derived values
this.#paginationMetadata = {
total: response.total ?? 0,
limit: response.limit ?? this.params.limit ?? 50,
offset: response.offset ?? this.params.offset ?? 0,
};
// Accumulate fonts for infinite scroll
if (params.offset === 0) {
// Reset when starting from beginning (new search/filter)
this.#accumulatedFonts = response.fonts;
} else {
// Append new fonts to existing ones
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
}
return response.fonts;
}
// --- Getters (proxied from BaseFontStore) ---
/**
* Get all accumulated fonts (for infinite scroll)
*/
get fonts(): UnifiedFont[] {
return this.#accumulatedFonts;
}
/**
* Check if loading initial data
*/
get isLoading(): boolean {
return this.result.isLoading;
}
/**
* Check if fetching (including background refetches)
*/
get isFetching(): boolean {
return this.result.isFetching;
}
/**
* Check if error occurred
*/
get isError(): boolean {
return this.result.isError;
}
/**
* Check if result is empty (not loading and no fonts)
*/
get isEmpty(): boolean {
return !this.isLoading && this.fonts.length === 0;
}
// --- Provider-specific shortcuts ---
/**
* Set provider filter
*/
setProvider(provider: 'google' | 'fontshare' | undefined) {
this.setParams({ provider });
}
/**
* Set category filter
*/
setCategory(category: ProxyFontsParams['category']) {
this.setParams({ category });
}
/**
* Set subset filter
*/
setSubset(subset: ProxyFontsParams['subset']) {
this.setParams({ subset });
}
/**
* Set search query
*/
setSearch(search: string) {
this.setParams({ q: search || undefined });
}
/**
* Set sort order
*/
setSort(sort: ProxyFontsParams['sort']) {
this.setParams({ sort });
}
// --- Pagination methods ---
/**
* Go to next page
*/
nextPage() {
if (this.pagination.hasMore) {
this.setParams({
offset: this.pagination.offset + this.pagination.limit,
});
}
}
/**
* Go to previous page
*/
prevPage() {
if (this.pagination.page > 1) {
this.setParams({
offset: this.pagination.offset - this.pagination.limit,
});
}
}
/**
* Go to specific page
*/
goToPage(page: number) {
if (page >= 1 && page <= this.pagination.totalPages) {
this.setParams({
offset: (page - 1) * this.pagination.limit,
});
}
}
/**
* Set limit (items per page)
*/
setLimit(limit: number) {
this.setParams({ limit });
}
// --- Category shortcuts (for convenience) ---
get sansSerifFonts() {
return this.fonts.filter(f => f.category === 'sans-serif');
}
get serifFonts() {
return this.fonts.filter(f => f.category === 'serif');
}
get displayFonts() {
return this.fonts.filter(f => f.category === 'display');
}
get handwritingFonts() {
return this.fonts.filter(f => f.category === 'handwriting');
}
get monospaceFonts() {
return this.fonts.filter(f => f.category === 'monospace');
}
}
/**
* Factory function to create unified font store
*/
export function createUnifiedFontStore(params: ProxyFontsParams = {}) {
return new UnifiedFontStore(params);
}
/**
* Singleton instance for global use
* Initialized with a default limit to prevent fetching all fonts at once
*/
export const unifiedFontStore = new UnifiedFontStore({
limit: 50,
offset: 0,
});

View File

@@ -6,9 +6,9 @@
- Adds smooth transition when font appears - Adds smooth transition when font appears
--> -->
<script lang="ts"> <script lang="ts">
import { motion } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion';
import { appliedFontsManager } from '../../model'; import { appliedFontsManager } from '../../model';
interface Props { interface Props {
@@ -20,6 +20,12 @@ interface Props {
* Font id to load * Font id to load
*/ */
id: string; id: string;
url: string;
/**
* Font weight
*/
weight?: number;
/** /**
* Additional classes * Additional classes
*/ */
@@ -30,7 +36,7 @@ interface Props {
children?: Snippet; children?: Snippet;
} }
let { name, id, className, children }: Props = $props(); let { name, id, url, weight = 400, className, children }: Props = $props();
let element: Element; let element: Element;
// Track if the user has actually scrolled this into view // Track if the user has actually scrolled this into view
@@ -40,7 +46,7 @@ $effect(() => {
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) { if (entries[0].isIntersecting) {
hasEnteredViewport = true; hasEnteredViewport = true;
appliedFontsManager.touch([id]); appliedFontsManager.touch([{ id, weight, name, url }]);
// Once it has entered, we can stop observing to save CPU // Once it has entered, we can stop observing to save CPU
observer.unobserve(element); observer.unobserve(element);
@@ -50,12 +56,12 @@ $effect(() => {
return () => observer.disconnect(); return () => observer.disconnect();
}); });
const status = $derived(appliedFontsManager.getFontStatus(id)); const status = $derived(appliedFontsManager.getFontStatus(id, weight));
// The "Show" condition: Element is in view AND (Font is ready OR it errored out) // The "Show" condition: Element is in view AND (Font is ready OR it errored out)
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error')); const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
const transitionClasses = $derived( const transitionClasses = $derived(
motion.reduced prefersReducedMotion.current
? 'transition-none' // Disable CSS transitions if motion is reduced ? 'transition-none' // Disable CSS transitions if motion is reduced
: 'transition-all duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]', : 'transition-all duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]',
); );
@@ -67,8 +73,9 @@ const transitionClasses = $derived(
class={cn( class={cn(
transitionClasses, transitionClasses,
// If reduced motion is on, we skip the transform/blur entirely // If reduced motion is on, we skip the transform/blur entirely
!shouldReveal && !motion.reduced && 'opacity-0 translate-y-8 scale-[0.98] blur-sm', !shouldReveal && !prefersReducedMotion.current
!shouldReveal && motion.reduced && 'opacity-0', // Still hide until font is ready, but no movement && 'opacity-0 translate-y-8 scale-[0.98] blur-sm',
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
shouldReveal && 'opacity-100 translate-y-0 scale-100 blur-0', shouldReveal && 'opacity-100 translate-y-0 scale-100 blur-0',
className, className,
)} )}

View File

@@ -1,84 +1,85 @@
<!-- <!--
Component: FontListItem Component: FontListItem
Displays a font item with a checkbox and its characteristics in badges. Displays a font item and manages its animations
--> -->
<script lang="ts"> <script lang="ts">
import { Badge } from '$shared/shadcn/ui/badge'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Checkbox } from '$shared/shadcn/ui/checkbox'; import type { Snippet } from 'svelte';
import { Label } from '$shared/shadcn/ui/label'; import { Spring } from 'svelte/motion';
import { import {
type UnifiedFont, type UnifiedFont,
selectedFontsStore, selectedFontsStore,
} from '../../model'; } from '../../model';
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
interface Props { interface Props {
/** /**
* Object with information about font * Object with information about font
*/ */
font: UnifiedFont; font: UnifiedFont;
/**
* Is element fully visible
*/
isFullyVisible: boolean;
/**
* Is element partially visible
*/
isPartiallyVisible: boolean;
/**
* From 0 to 1
*/
proximity: number;
/**
* Children snippet
*/
children: Snippet<[font: UnifiedFont]>;
} }
const { font }: Props = $props(); const { font, isFullyVisible, isPartiallyVisible, proximity, children }: Props = $props();
const handleChange = (checked: boolean) => {
if (checked) {
selectedFontsStore.addOne(font);
} else {
selectedFontsStore.removeOne(font.id);
}
};
const selected = $derived(selectedFontsStore.has(font.id)); const selected = $derived(selectedFontsStore.has(font.id));
let timeoutId = $state<NodeJS.Timeout | null>(null);
// Create a spring for smooth scale animation
const scale = new Spring(1, {
stiffness: 0.3,
damping: 0.7,
});
// Springs react to the virtualizer's computed state
const bloom = new Spring(0, {
stiffness: 0.15,
damping: 0.6,
});
// Sync spring to proximity for a "Lens" effect
$effect(() => {
bloom.target = isPartiallyVisible ? 1 : 0;
});
$effect(() => {
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
function animateSelection() {
scale.target = 0.98;
timeoutId = setTimeout(() => {
scale.target = 1;
}, 150);
}
</script> </script>
<div class="pb-1"> <div
<Label class={cn('pb-1 will-change-transform')}
for={font.id} style:opacity={bloom.current}
class=" style:transform="
w-full hover:bg-accent/50 flex items-start gap-3 rounded-lg border border-transparent p-3 scale({0.92 + (bloom.current * 0.08)})
active:scale-[0.98] active:transition-transform active:duration-75 translateY({(1 - bloom.current) * 10}px)
has-aria-checked:border-blue-600
has-aria-checked:bg-blue-50
dark:has-aria-checked:border-blue-900
dark:has-aria-checked:bg-blue-950
" "
> >
<div class="w-full"> {@render children?.(font)}
<div class="flex flex-row gap-1 w-full items-center justify-between">
<div class="flex flex-col gap-1 transition-all duration-150 ease-out">
<div class="flex flex-row gap-1">
<Badge variant="outline" class="text-[0.5rem]">
{font.provider}
</Badge>
<Badge variant="outline" class="text-[0.5rem]">
{font.category}
</Badge>
</div>
<FontApplicator
id={font.id}
className="text-2xl"
name={font.name}
>
{font.name}
</FontApplicator>
</div>
<Checkbox
id={font.id}
checked={selected}
onCheckedChange={handleChange}
class="
transition-all duration-150 ease-out
data-[state=checked]:scale-100
data-[state=checked]:border-blue-600
data-[state=checked]:bg-blue-600
data-[state=checked]:text-white
dark:data-[state=checked]:border-blue-700
dark:data-[state=checked]:bg-blue-700
"
/>
</div>
</div>
</Label>
</div> </div>

View File

@@ -3,24 +3,40 @@
- Renders a virtualized list of fonts - Renders a virtualized list of fonts
- Handles font registration with the manager - Handles font registration with the manager
--> -->
<script lang="ts" generics="T extends { id: string }"> <script lang="ts" generics="T extends UnifiedFont">
import type { FontConfigRequest } from '$entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte';
import { VirtualList } from '$shared/ui'; import { VirtualList } from '$shared/ui';
import type { ComponentProps } from 'svelte'; import type { ComponentProps } from 'svelte';
import { appliedFontsManager } from '../../model'; import {
type UnifiedFont,
appliedFontsManager,
} from '../../model';
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> { interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> {
onVisibleItemsChange?: (items: T[]) => void; onVisibleItemsChange?: (items: T[]) => void;
onNearBottom?: (lastVisibleIndex: number) => void;
weight: number;
} }
let { items, children, onVisibleItemsChange, ...rest }: Props = $props(); let { items, children, onVisibleItemsChange, onNearBottom, weight, ...rest }: Props = $props();
function handleInternalVisibleChange(visibleItems: T[]) { function handleInternalVisibleChange(visibleItems: T[]) {
// Auto-register fonts with the manager // Auto-register fonts with the manager
const slugs = visibleItems.map(item => item.id); const configs = visibleItems.map<FontConfigRequest>(item => ({
appliedFontsManager.registerFonts(slugs); id: item.id,
name: item.name,
weight,
url: item.styles.regular!,
}));
appliedFontsManager.touch(configs);
// // Forward the call to any external listener
// onVisibleItemsChange?.(visibleItems);
}
function handleNearBottom(lastVisibleIndex: number) {
// Forward the call to any external listener // Forward the call to any external listener
onVisibleItemsChange?.(visibleItems); onNearBottom?.(lastVisibleIndex);
} }
</script> </script>
@@ -28,6 +44,7 @@ function handleInternalVisibleChange(visibleItems: T[]) {
{items} {items}
{...rest} {...rest}
onVisibleItemsChange={handleInternalVisibleChange} onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom}
> >
{#snippet children(scope)} {#snippet children(scope)}
{@render children(scope)} {@render children(scope)}

View File

@@ -1 +1 @@
export { FontDisplay } from './ui'; export { FontSampler } from './ui';

View File

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

View File

@@ -1,28 +0,0 @@
import { selectedFontsStore } from '$entities/Font';
/**
* Store for displayed font samples
* - Handles shown text
* - Stores selected fonts for display
*/
export class DisplayedFontsStore {
#sampleText = $state('The quick brown fox jumps over the lazy dog');
#displayedFonts = $derived.by(() => {
return selectedFontsStore.all;
});
get fonts() {
return this.#displayedFonts;
}
get text() {
return this.#sampleText;
}
set text(text: string) {
this.#sampleText = text;
}
}
export const displayedFontsStore = new DisplayedFontsStore();

View File

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

View File

@@ -1,14 +0,0 @@
<!--
Component: FontDisplay
Displays a grid of FontSampler components for each displayed font.
-->
<script>
import { displayedFontsStore } from '../../model';
import FontSampler from '../FontSampler/FontSampler.svelte';
</script>
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))]">
{#each displayedFontsStore.fonts as font (font.id)}
<FontSampler font={font} bind:text={displayedFontsStore.text} />
{/each}
</div>

View File

@@ -6,8 +6,14 @@
import { import {
FontApplicator, FontApplicator,
type UnifiedFont, type UnifiedFont,
selectedFontsStore,
} from '$entities/Font'; } from '$entities/Font';
import { ContentEditable } from '$shared/ui'; import { controlManager } from '$features/SetupFont';
import {
ContentEditable,
IconButton,
} from '$shared/ui';
import XIcon from '@lucide/svelte/icons/x';
interface Props { interface Props {
/** /**
@@ -18,6 +24,10 @@ interface Props {
* Text to display * Text to display
*/ */
text: string; text: string;
/**
* Index of the font sampler
*/
index?: number;
/** /**
* Font settings * Font settings
*/ */
@@ -29,18 +39,80 @@ interface Props {
let { let {
font, font,
text = $bindable(), text = $bindable(),
index = 0,
...restProps ...restProps
}: Props = $props(); }: Props = $props();
const fontWeight = $derived(controlManager.weight);
const fontSize = $derived(controlManager.size);
const lineHeight = $derived(controlManager.height);
const letterSpacing = $derived(controlManager.spacing);
function removeSample() {
selectedFontsStore.removeOne(font.id);
}
</script> </script>
<div <div
class=" class="
w-full rounded-xl w-full h-full rounded-2xl
bg-white p-6 border border-slate-200 flex flex-col
shadow-sm dark:border-slate-800 dark:bg-slate-950 backdrop-blur-md bg-white/80
border border-gray-300/50
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
relative overflow-hidden
" "
style:font-weight={fontWeight}
> >
<FontApplicator id={font.id} name={font.name}> <div class="px-6 py-3 border-b border-gray-200/60 flex items-center justify-between">
<ContentEditable bind:text={text} {...restProps} /> <div class="flex items-center gap-2.5">
<span class="font-mono text-[9px] uppercase tracking-[0.25em] text-gray-500 font-medium">
typeface_{String(index).padStart(3, '0')}
</span>
<div class="w-px h-2.5 bg-gray-300/60"></div>
<span class="font-mono text-[10px] tracking-[0.15em] font-bold uppercase text-gray-900">
{font.name}
</span>
</div>
<IconButton
onclick={removeSample}
class="w-5 h-5 rounded-full hover:bg-transparent flex items-center justify-center transition-colors group translate-x-1/2 cursor-pointer"
>
{#snippet icon({ className })}
<XIcon class={className} />
{/snippet}
</IconButton>
</div>
<div class="p-8 relative z-10">
<!-- TODO: Fix this ! -->
<FontApplicator id={font.id} name={font.name} url={font.styles.regular!}>
<ContentEditable
bind:text={text}
{...restProps}
fontSize={fontSize}
lineHeight={lineHeight}
letterSpacing={letterSpacing}
/>
</FontApplicator> </FontApplicator>
</div> </div>
<div class="px-6 py-2 border-t border-gray-200/40 w-full flex gap-4 bg-gray-50/30 mt-auto">
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider ml-auto">
SZ:{fontSize}PX
</span>
<div class="w-px h-2.5 self-center bg-gray-300/40"></div>
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider">
WGT:{fontWeight}
</span>
<div class="w-px h-2.5 self-center bg-gray-300/40"></div>
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider">
LH:{lineHeight?.toFixed(2)}
</span>
<div class="w-px h-2.5 self-center bg-gray-300/40"></div>
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider">
LTR:{letterSpacing}
</span>
</div>
</div>

View File

@@ -1,3 +1,3 @@
import FontDisplay from './FontDisplay/FontDisplay.svelte'; import FontSampler from './FontSampler/FontSampler.svelte';
export { FontDisplay }; export { FontSampler };

View File

@@ -15,5 +15,4 @@ export { filterManager } from './model/state/manager.svelte';
export { export {
FilterControls, FilterControls,
Filters, Filters,
FontSearch,
} from './ui'; } from './ui';

View File

@@ -1,18 +1,54 @@
import type { FontshareParams } from '$entities/Font'; import type { ProxyFontsParams } from '$entities/Font/api';
import type { FilterManager } from '../filterManager/filterManager.svelte'; import type { FilterManager } from '../filterManager/filterManager.svelte';
/** /**
* Maps filter manager to fontshare params. * Maps filter manager to proxy API parameters.
* *
* @param manager - Filter manager instance. * Transforms UI filter state into proxy API query parameters.
* @returns - Partial fontshare params. * Handles conversion from filter groups to API-specific parameters.
*
* @param manager - Filter manager instance with reactive state
* @returns - Partial proxy API parameters ready for API call
*
* @example
* ```ts
* // Example filter manager state:
* // {
* // queryValue: 'roboto',
* // providers: ['google'],
* // categories: ['sans-serif'],
* // subsets: ['latin']
* // }
*
* const params = mapManagerToParams(manager);
* // Returns: { provider: 'google', category: 'sans-serif', subset: 'latin', q: 'roboto' }
* ```
*/ */
export function mapManagerToParams(manager: FilterManager): Partial<FontshareParams> { export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsParams> {
const providers = manager.getGroup('providers')?.instance.selectedProperties.map(p => p.value);
const categories = manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value);
const subsets = manager.getGroup('subsets')?.instance.selectedProperties.map(p => p.value);
return { return {
q: manager.debouncedQueryValue, // Search query (debounced)
// Map groups to specific API keys q: manager.debouncedQueryValue || undefined,
categories: manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value)
?? [], // Provider filter (single value - proxy API doesn't support array)
tags: manager.getGroup('tags')?.instance.selectedProperties.map(p => p.value) ?? [], // Use first provider if multiple selected, or undefined if none/all selected
provider: providers && providers.length === 1
? (providers[0] as 'google' | 'fontshare')
: undefined,
// Category filter (single value - proxy API doesn't support array)
// Use first category if multiple selected, or undefined if none/all selected
category: categories && categories.length === 1
? (categories[0] as ProxyFontsParams['category'])
: undefined,
// Subset filter (single value - proxy API doesn't support array)
// Use first subset if multiple selected, or undefined if none/all selected
subset: subsets && subsets.length === 1
? (subsets[0] as ProxyFontsParams['subset'])
: undefined,
}; };
} }

View File

@@ -5,15 +5,42 @@
--> -->
<script lang="ts"> <script lang="ts">
import { Button } from '$shared/shadcn/ui/button'; import { Button } from '$shared/shadcn/ui/button';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import Rotate from '@lucide/svelte/icons/rotate-ccw';
import { cubicOut } from 'svelte/easing';
import { Tween } from 'svelte/motion';
import { filterManager } from '../../model'; import { filterManager } from '../../model';
interface Props {
class?: string;
}
const { class: className }: Props = $props();
const transform = new Tween(
{ scale: 1, rotate: 0 },
{ duration: 150, easing: cubicOut },
);
function handleClick() {
filterManager.deselectAllGlobal();
transform.set({ scale: 0.98, rotate: 1 }).then(() => {
transform.set({ scale: 1, rotate: 0 });
});
}
</script> </script>
<div class="flex flex-row gap-2"> <div
<Button class={cn('flex flex-row gap-2', className)}
variant="outline" style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)"
class="flex-1 cursor-pointer"
onclick={filterManager.deselectAllGlobal}
> >
<Button
variant="ghost"
class="group flex flex-1 cursor-pointer gap-1"
onclick={handleClick}
>
<Rotate class="size-4 group-hover:-rotate-180 transition-transform duration-300" />
Reset Reset
</Button> </Button>
</div> </div>

View File

@@ -1,33 +0,0 @@
<!--
Component: FontSearch
Combines search input with font list display
-->
<script lang="ts">
import { fontshareStore } from '$entities/Font';
import { SearchBar } from '$shared/ui';
import { onMount } from 'svelte';
import { mapManagerToParams } from '../../lib';
import { filterManager } from '../../model';
import SuggestedFonts from '../SuggestedFonts/SuggestedFonts.svelte';
onMount(() => {
/**
* The Pairing:
* We "plug" this manager into the global store.
* addBinding returns a function that removes this binding when the component unmounts.
*/
const unbind = fontshareStore.addBinding(() => mapManagerToParams(filterManager));
return unbind;
});
</script>
<SearchBar
id="font-search"
class="w-full"
placeholder="Search fonts by name..."
bind:value={filterManager.queryValue}
>
<SuggestedFonts />
</SearchBar>

View File

@@ -1,17 +0,0 @@
<!--
Component: SuggestedFonts
Renders a list of suggested fonts in a virtualized list to improve performance.
-->
<script lang="ts">
import {
FontListItem,
FontVirtualList,
fontshareStore,
} from '$entities/Font';
</script>
<FontVirtualList items={fontshareStore.fonts}>
{#snippet children({ item: font })}
<FontListItem {font} />
{/snippet}
</FontVirtualList>

View File

@@ -1,9 +1,7 @@
import Filters from './Filters/Filters.svelte'; import Filters from './Filters/Filters.svelte';
import FilterControls from './FiltersControl/FilterControls.svelte'; import FilterControls from './FiltersControl/FilterControls.svelte';
import FontSearch from './FontSearch/FontSearch.svelte';
export { export {
FilterControls, FilterControls,
Filters, Filters,
FontSearch,
}; };

View File

@@ -4,6 +4,7 @@ export {
controlManager, controlManager,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP, FONT_SIZE_STEP,
FONT_WEIGHT_STEP, FONT_WEIGHT_STEP,

View File

@@ -1,7 +1,53 @@
import { import {
type ControlModel, type ControlModel,
type TypographyControl,
createTypographyControl, createTypographyControl,
} from '$shared/lib'; } from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity';
export interface Control {
id: string;
increaseLabel?: string;
decreaseLabel?: string;
controlLabel?: string;
instance: TypographyControl;
}
export class TypographyControlManager {
#controls = new SvelteMap<string, Control>();
constructor(configs: ControlModel[]) {
configs.forEach(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => {
this.#controls.set(id, {
id,
increaseLabel,
decreaseLabel,
controlLabel,
instance: createTypographyControl(config),
});
});
}
get controls() {
return this.#controls.values();
}
get weight() {
return this.#controls.get('font_weight')?.instance.value ?? 400;
}
get size() {
return this.#controls.get('font_size')?.instance.value;
}
get height() {
return this.#controls.get('line_height')?.instance.value;
}
get spacing() {
return this.#controls.get('letter_spacing')?.instance.value;
}
}
/** /**
* Creates a typography control manager that handles a collection of typography controls. * Creates a typography control manager that handles a collection of typography controls.
@@ -10,19 +56,5 @@ import {
* @returns - Typography control manager instance. * @returns - Typography control manager instance.
*/ */
export function createTypographyControlManager(configs: ControlModel[]) { export function createTypographyControlManager(configs: ControlModel[]) {
const controls = $state( return new TypographyControlManager(configs);
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({
id,
increaseLabel,
decreaseLabel,
controlLabel,
instance: createTypographyControl(config),
})),
);
return {
get controls() {
return controls;
},
};
} }

View File

@@ -1,7 +1,7 @@
/** /**
* Font size constants * Font size constants
*/ */
export const DEFAULT_FONT_SIZE = 16; export const DEFAULT_FONT_SIZE = 48;
export const MIN_FONT_SIZE = 8; export const MIN_FONT_SIZE = 8;
export const MAX_FONT_SIZE = 100; export const MAX_FONT_SIZE = 100;
export const FONT_SIZE_STEP = 1; export const FONT_SIZE_STEP = 1;
@@ -21,3 +21,11 @@ export const DEFAULT_LINE_HEIGHT = 1.5;
export const MIN_LINE_HEIGHT = 1; export const MIN_LINE_HEIGHT = 1;
export const MAX_LINE_HEIGHT = 2; export const MAX_LINE_HEIGHT = 2;
export const LINE_HEIGHT_STEP = 0.05; export const LINE_HEIGHT_STEP = 0.05;
/**
* Letter spacing constants
*/
export const DEFAULT_LETTER_SPACING = 0;
export const MIN_LETTER_SPACING = -0.1;
export const MAX_LETTER_SPACING = 0.5;
export const LETTER_SPACING_STEP = 0.01;

View File

@@ -1,6 +1,7 @@
export { export {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP, FONT_SIZE_STEP,
FONT_WEIGHT_STEP, FONT_WEIGHT_STEP,

View File

@@ -3,15 +3,19 @@ import { createTypographyControlManager } from '../../lib';
import { import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP, FONT_SIZE_STEP,
FONT_WEIGHT_STEP, FONT_WEIGHT_STEP,
LETTER_SPACING_STEP,
LINE_HEIGHT_STEP, LINE_HEIGHT_STEP,
MAX_FONT_SIZE, MAX_FONT_SIZE,
MAX_FONT_WEIGHT, MAX_FONT_WEIGHT,
MAX_LETTER_SPACING,
MAX_LINE_HEIGHT, MAX_LINE_HEIGHT,
MIN_FONT_SIZE, MIN_FONT_SIZE,
MIN_FONT_WEIGHT, MIN_FONT_WEIGHT,
MIN_LETTER_SPACING,
MIN_LINE_HEIGHT, MIN_LINE_HEIGHT,
} from '../const/const'; } from '../const/const';
@@ -49,6 +53,17 @@ const controlData: ControlModel[] = [
decreaseLabel: 'Decrease Line Height', decreaseLabel: 'Decrease Line Height',
controlLabel: 'Line Height', controlLabel: 'Line Height',
}, },
{
id: 'letter_spacing',
value: DEFAULT_LETTER_SPACING,
max: MAX_LETTER_SPACING,
min: MIN_LETTER_SPACING,
step: LETTER_SPACING_STEP,
increaseLabel: 'Increase Letter Spacing',
decreaseLabel: 'Decrease Letter Spacing',
controlLabel: 'Letter Spacing',
},
]; ];
export const controlManager = createTypographyControlManager(controlData); export const controlManager = createTypographyControlManager(controlData);

View File

@@ -3,18 +3,19 @@
Contains controls for setting up font properties. Contains controls for setting up font properties.
--> -->
<script lang="ts"> <script lang="ts">
import { Separator } from '$shared/shadcn/ui/separator/index';
import { Trigger as SidebarTrigger } from '$shared/shadcn/ui/sidebar';
import { ComboControl } from '$shared/ui'; import { ComboControl } from '$shared/ui';
import { controlManager } from '../model'; import { controlManager } from '../model';
</script> </script>
<div class="p-2 flex flex-row items-center gap-2"> <div class="py-2 px-10 flex flex-row items-center gap-2">
<SidebarTrigger /> <div class="flex flex-row gap-3">
<Separator orientation="vertical" class="h-full" />
<div class="flex flex-row gap-2">
{#each controlManager.controls as control (control.id)} {#each controlManager.controls as control (control.id)}
<ComboControl control={control.instance} /> <ComboControl
control={control.instance}
increaseLabel={control.increaseLabel}
decreaseLabel={control.decreaseLabel}
controlLabel={control.controlLabel}
/>
{/each} {/each}
</div> </div>
</div> </div>

View File

@@ -1,12 +1,94 @@
<!--
Component: Page
Description: The main page component of the application.
-->
<script lang="ts"> <script lang="ts">
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte'; import { BreadcrumbHeader } from '$entities/Breadcrumb';
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
import { Section } from '$shared/ui';
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
import { FontSearch } from '$widgets/FontSearch';
import { SampleList } from '$widgets/SampleList';
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
import ScanEyeIcon from '@lucide/svelte/icons/scan-eye';
import ScanSearchIcon from '@lucide/svelte/icons/search';
import type { Snippet } from 'svelte';
/** let searchContainer: HTMLElement;
* Page Component
*/ let isExpanded = $state(false);
function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippet<[{ className?: string }]>) {
if (isPast && title) {
scrollBreadcrumbsStore.add({ index, title });
} else {
scrollBreadcrumbsStore.remove(index);
}
return () => {
scrollBreadcrumbsStore.remove(index);
};
}
// $effect(() => {
// appliedFontsManager.touch(
// selectedFontsStore.all.map(font => ({
// slug: font.id,
// weight: controlManager.weight,
// })),
// );
// });
</script> </script>
<BreadcrumbHeader />
<!-- Font List --> <!-- Font List -->
<div class="p-2"> <div class="p-2 h-full flex flex-col gap-3">
<FontDisplay /> <Section class="my-12 gap-8" index={0} onTitleStatusChange={handleTitleStatusChanged}>
{#snippet icon({ className })}
<ScanEyeIcon class={className} />
{/snippet}
{#snippet title({ className })}
<h1 class={className}>
Optical<br />Comparator
</h1>
{/snippet}
<ComparisonSlider />
</Section>
<Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}>
{#snippet icon({ className })}
<ScanSearchIcon class={className} />
{/snippet}
{#snippet title({ className })}
<h2 class={className}>
Query<br />Module
</h2>
{/snippet}
<FontSearch bind:showFilters={isExpanded} />
</Section>
<Section class="my-12 gap-8" index={2} onTitleStatusChange={handleTitleStatusChanged}>
{#snippet icon({ className })}
<LineSquiggleIcon class={className} />
{/snippet}
{#snippet title({ className })}
<h2 class={className}>
Sample<br />Set
</h2>
{/snippet}
<SampleList />
</Section>
</div> </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>

View File

@@ -56,6 +56,5 @@ export const api = {
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
delete: <T>(url: string, options?: RequestInit) => delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
request<T>(url, { ...options, method: 'DELETE' }),
}; };

View File

@@ -1,32 +0,0 @@
// Check if we are in a browser environment
const isBrowser = typeof window !== 'undefined';
// A class to manage motion preference and provide a single instance for use everywhere
class MotionPreference {
// Reactive state
#reduced = $state(false);
constructor() {
if (isBrowser) {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// Set initial value immediately
this.#reduced = mediaQuery.matches;
// Simple listener that updates the reactive state
const handleChange = (e: MediaQueryListEvent) => {
this.#reduced = e.matches;
};
mediaQuery.addEventListener('change', handleChange);
}
}
// Getter allows us to use 'motion.reduced' reactively in components
get reduced() {
return this.#reduced;
}
}
// Export a single instance to be used everywhere
export const motion = new MotionPreference();

View File

@@ -0,0 +1,257 @@
/**
* Interface representing a line of text with its measured width.
*/
export interface LineData {
text: string;
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.
*
* @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)
*/
export function createCharacterComparison<
T extends { name: string; id: string } | undefined = undefined,
>(
text: () => string,
fontA: () => T,
fontB: () => T,
weight: () => number,
size: () => number,
) {
let lines = $state<LineData[]>([]);
let containerWidth = $state(0);
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
return font !== undefined;
}
/**
* Measures text width using a canvas 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
*/
function measureText(
ctx: CanvasRenderingContext2D,
text: string,
fontSize: number,
fontWeight: number,
fontFamily?: string,
): number {
if (!fontFamily) return 0;
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
return ctx.measureText(text).width;
}
/**
* Determines the appropriate font size based on window width.
* Matches the Tailwind breakpoints used in the component.
*/
function getFontSize() {
if (typeof window === 'undefined') {
return 64;
}
return window.innerWidth >= 1024
? 112
: window.innerWidth >= 768
? 96
: window.innerWidth >= 640
? 80
: 64;
}
/**
* Breaks the text into lines based on the container width and measure canvas.
* Populates the `lines` state.
*
* @param container - The container element to measure width from
* @param measureCanvas - The canvas element used for text measurement
*/
function breakIntoLines(
container: HTMLElement | undefined,
measureCanvas: HTMLCanvasElement | undefined,
) {
if (!container || !measureCanvas || !fontA() || !fontB()) return;
const rect = container.getBoundingClientRect();
containerWidth = rect.width;
// Padding considerations - matches the container padding
const padding = window.innerWidth < 640 ? 48 : 96;
const availableWidth = rect.width - padding;
const ctx = measureCanvas.getContext('2d');
if (!ctx) return;
const controlledFontSize = size();
const fontSize = getFontSize();
const currentWeight = weight(); // Get current weight
const words = text().split(' ');
const newLines: LineData[] = [];
let currentLineWords: string[] = [];
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,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontA()?.name,
);
const widthB = measureText(
ctx!,
lineText,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontB()?.name,
);
const maxWidth = Math.max(widthA, widthB);
newLines.push({ text: lineText, width: maxWidth });
}
for (const word of words) {
const testLine = currentLineWords.length > 0
? currentLineWords.join(' ') + ' ' + word
: word;
// Measure with both fonts and use the wider one to prevent layout shifts
const widthA = measureText(
ctx,
testLine,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontA()?.name,
);
const widthB = measureText(
ctx,
testLine,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontB()?.name,
);
const maxWidth = Math.max(widthA, widthB);
const isContainerOverflown = maxWidth > availableWidth;
if (isContainerOverflown) {
if (currentLineWords.length > 0) {
pushLine(currentLineWords);
currentLineWords = [];
}
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
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const testFragment = remainingWord.slice(0, mid);
const wA = measureText(
ctx,
testFragment,
fontSize,
currentWeight,
fontA()?.name,
);
const wB = measureText(
ctx,
testFragment,
fontSize,
currentWeight,
fontB()?.name,
);
if (Math.max(wA, wB) <= availableWidth) {
bestBreak = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
pushLine([remainingWord.slice(0, bestBreak)]);
remainingWord = remainingWord.slice(bestBreak);
}
} else if (maxWidth > availableWidth && currentLineWords.length > 0) {
pushLine(currentLineWords);
currentLineWords = [word];
} else {
currentLineWords.push(word);
}
}
if (currentLineWords.length > 0) {
pushLine(currentLineWords);
}
lines = newLines;
}
/**
* precise calculation of character state based on global 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)
*/
function getCharState(
charIndex: number,
sliderPos: number,
lineElement?: HTMLElement,
container?: HTMLElement,
) {
if (!containerWidth || !container) {
return {
proximity: 0,
isPast: false,
};
}
const charElement = lineElement?.children[charIndex] as HTMLElement;
if (!charElement) {
return { proximity: 0, isPast: false };
}
// Get the actual bounding box of the character
const charRect = charElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
// Calculate character center relative to container
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
const charGlobalPercent = (charCenter / containerWidth) * 100;
const distance = Math.abs(sliderPos - charGlobalPercent);
const range = 5;
const proximity = Math.max(0, 1 - distance / range);
const isPast = sliderPos > charGlobalPercent;
return { proximity, isPast };
}
return {
get lines() {
return lines;
},
get containerWidth() {
return containerWidth;
},
breakIntoLines,
getCharState,
};
}

View File

@@ -46,9 +46,6 @@ export class EntityStore<T extends Entity> {
updateOne(id: string, changes: Partial<T>) { updateOne(id: string, changes: Partial<T>) {
const entity = this.#entities.get(id); const entity = this.#entities.get(id);
if (entity) { if (entity) {
// In Svelte 5, updating the object property directly is reactive
// if the object itself was made reactive, but here we replace
// the reference to ensure top-level map triggers.
this.#entities.set(id, { ...entity, ...changes }); this.#entities.set(id, { ...entity, ...changes });
} }
} }

View File

@@ -0,0 +1,51 @@
/**
* Reusable persistent storage utility using Svelte 5 runes
*
* Automatically syncs state with localStorage.
*/
export function createPersistentStore<T>(key: string, defaultValue: T) {
// Initialize from storage or default
const loadFromStorage = (): T => {
if (typeof window === 'undefined') {
return defaultValue;
}
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (error) {
console.warn(`[createPersistentStore] Error loading ${key}:`, error);
return defaultValue;
}
};
let value = $state<T>(loadFromStorage());
// Sync to storage whenever value changes
$effect.root(() => {
$effect(() => {
if (typeof window === 'undefined') {
return;
}
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.warn(`[createPersistentStore] Error saving ${key}:`, error);
}
});
});
return {
get value() {
return value;
},
set value(v: T) {
value = v;
},
clear() {
if (typeof window !== 'undefined') {
localStorage.removeItem(key);
}
value = defaultValue;
},
};
}

View File

@@ -30,15 +30,15 @@ export interface ControlModel extends ControlDataModel {
/** /**
* Area label for increase button * Area label for increase button
*/ */
increaseLabel: string; increaseLabel?: string;
/** /**
* Area label for decrease button * Area label for decrease button
*/ */
decreaseLabel: string; decreaseLabel?: string;
/** /**
* Control area label * Control area label
*/ */
controlLabel: string; controlLabel?: string;
} }
export function createTypographyControl<T extends ControlDataModel>( export function createTypographyControl<T extends ControlDataModel>(

View File

@@ -4,16 +4,38 @@
* Used to render visible items with absolute positioning based on computed offsets. * Used to render visible items with absolute positioning based on computed offsets.
*/ */
export interface VirtualItem { export interface VirtualItem {
/** Index of the item in the data array */ /**
* Index of the item in the data array
*/
index: number; index: number;
/** Offset from the top of the list in pixels */ /**
* Offset from the top of the list in pixels
*/
start: number; start: number;
/** Height/size of the item in pixels */ /**
* Height/size of the item in pixels
*/
size: number; size: number;
/** End position in pixels (start + size) */ /**
* End position in pixels (start + size)
*/
end: number; end: number;
/** Unique key for the item (for Svelte's {#each} keying) */ /**
* Unique key for the item (for Svelte's {#each} keying)
*/
key: string | number; key: string | number;
/**
* Whether the item is currently fully visible in the viewport
*/
isFullyVisible: boolean;
/**
* Whether the item is currently partially visible in the viewport
*/
isPartiallyVisible: boolean;
/**
* Proximity of the item to the center of the viewport
*/
proximity: number;
} }
/** /**
@@ -41,6 +63,11 @@ export interface VirtualizerOptions {
* Can be useful for handling sticky headers or other UI elements. * Can be useful for handling sticky headers or other UI elements.
*/ */
scrollMargin?: number; scrollMargin?: number;
/**
* Whether to use the window as the scroll container.
* @default false
*/
useWindowScroll?: boolean;
} }
/** /**
@@ -88,6 +115,7 @@ export function createVirtualizer<T>(
let containerHeight = $state(0); let containerHeight = $state(0);
let measuredSizes = $state<Record<number, number>>({}); let measuredSizes = $state<Record<number, number>>({});
let elementRef: HTMLElement | null = null; let elementRef: HTMLElement | null = null;
let elementOffsetTop = 0;
// By wrapping the getter in $derived, we track everything inside it // By wrapping the getter in $derived, we track everything inside it
const options = $derived(optionsGetter()); const options = $derived(optionsGetter());
@@ -136,6 +164,8 @@ export function createVirtualizer<T>(
let endIdx = startIdx; let endIdx = startIdx;
const viewportEnd = scrollOffset + containerHeight; const viewportEnd = scrollOffset + containerHeight;
const viewportCenter = scrollOffset + (containerHeight / 2);
while (endIdx < count && offsets[endIdx] < viewportEnd) { while (endIdx < count && offsets[endIdx] < viewportEnd) {
endIdx++; endIdx++;
} }
@@ -144,13 +174,31 @@ export function createVirtualizer<T>(
const end = Math.min(count, endIdx + overscan); const end = Math.min(count, endIdx + overscan);
const result: VirtualItem[] = []; const result: VirtualItem[] = [];
for (let i = start; i < end; i++) { for (let i = start; i < end; i++) {
const itemStart = offsets[i];
const itemSize = measuredSizes[i] ?? options.estimateSize(i);
const itemEnd = itemStart + itemSize;
// Visibility check: Does the item overlap the viewport?
const isPartiallyVisible = itemStart < viewportEnd && itemEnd > scrollOffset;
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
// Proximity calculation: 1.0 at center, 0.0 at edges
const itemCenter = itemStart + (itemSize / 2);
const distanceToCenter = Math.abs(viewportCenter - itemCenter);
const maxDistance = containerHeight / 2;
const proximity = Math.max(0, 1 - (distanceToCenter / maxDistance));
result.push({ result.push({
index: i, index: i,
start: offsets[i], start: itemStart,
size: measuredSizes[i] ?? options.estimateSize(i), size: itemSize,
end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)), end: itemEnd,
key: options.getItemKey?.(i) ?? i, key: options.getItemKey?.(i) ?? i,
isPartiallyVisible,
isFullyVisible,
proximity,
}); });
} }
@@ -168,6 +216,53 @@ export function createVirtualizer<T>(
*/ */
function container(node: HTMLElement) { function container(node: HTMLElement) {
elementRef = node; elementRef = node;
const { useWindowScroll } = optionsGetter();
if (useWindowScroll) {
// Calculate initial offset ONCE
const getElementOffset = () => {
const rect = node.getBoundingClientRect();
return rect.top + window.scrollY;
};
let cachedOffsetTop = getElementOffset();
containerHeight = window.innerHeight;
const handleScroll = () => {
// Use cached offset for scroll calculations
scrollOffset = Math.max(0, window.scrollY - cachedOffsetTop);
};
const handleResize = () => {
const oldHeight = containerHeight;
containerHeight = window.innerHeight;
// Recalculate offset on resize (layout may have shifted)
const newOffsetTop = getElementOffset();
if (Math.abs(newOffsetTop - cachedOffsetTop) > 0.5) {
cachedOffsetTop = newOffsetTop;
handleScroll(); // Recalculate scroll position
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleResize);
// Initial calculation
handleScroll();
return {
destroy() {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
if (frameId !== null) {
cancelAnimationFrame(frameId);
frameId = null;
}
elementRef = null;
},
};
} else {
containerHeight = node.offsetHeight; containerHeight = node.offsetHeight;
const handleScroll = () => { const handleScroll = () => {
@@ -189,6 +284,7 @@ export function createVirtualizer<T>(
}, },
}; };
} }
}
let measurementBuffer: Record<number, number> = {}; let measurementBuffer: Record<number, number> = {};
let frameId: number | null = null; let frameId: number | null = null;
@@ -207,23 +303,25 @@ export function createVirtualizer<T>(
const index = parseInt(node.dataset.index || '', 10); const index = parseInt(node.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
if (!isNaN(index) && measuredSizes[index] !== height) { if (!isNaN(index)) {
// 1. Stuff the measurement into a temporary buffer const oldHeight = measuredSizes[index];
// Only update if the height difference is significant (> 0.5px)
// This prevents "jitter" from focus rings or sub-pixel border changes
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
// Stuff the measurement into a temporary buffer
measurementBuffer[index] = height; measurementBuffer[index] = height;
// 2. Schedule a single update for the next animation frame // Schedule a single update for the next animation frame
if (frameId === null) { if (frameId === null) {
frameId = requestAnimationFrame(() => { frameId = requestAnimationFrame(() => {
// 3. Update the state once for all collected measurements
// We use spread to trigger a single fine-grained update
measuredSizes = { ...measuredSizes, ...measurementBuffer }; measuredSizes = { ...measuredSizes, ...measurementBuffer };
// Reset the buffer
// 4. Reset the buffer
measurementBuffer = {}; measurementBuffer = {};
frameId = null; frameId = null;
}); });
} }
} }
}
}); });
resizeObserver.observe(node); resizeObserver.observe(node);
@@ -249,12 +347,23 @@ export function createVirtualizer<T>(
const itemStart = offsets[index]; const itemStart = offsets[index];
const itemSize = measuredSizes[index] ?? options.estimateSize(index); const itemSize = measuredSizes[index] ?? options.estimateSize(index);
let target = itemStart; let target = itemStart;
const { useWindowScroll } = optionsGetter();
if (useWindowScroll) {
if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2;
if (align === 'end') target = itemStart - window.innerHeight + itemSize;
// Add container offset to target to get absolute document position
const absoluteTarget = target + elementOffsetTop;
window.scrollTo({ top: absoluteTarget, behavior: 'smooth' });
} else {
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2; if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
if (align === 'end') target = itemStart - containerHeight + itemSize; if (align === 'end') target = itemStart - containerHeight + itemSize;
elementRef.scrollTo({ top: target, behavior: 'smooth' }); elementRef.scrollTo({ top: target, behavior: 'smooth' });
} }
}
return { return {
/** Computed array of visible items to render (reactive) */ /** Computed array of visible items to render (reactive) */

View File

@@ -26,3 +26,10 @@ export {
type Entity, type Entity,
type EntityStore, type EntityStore,
} from './createEntityStore/createEntityStore.svelte'; } from './createEntityStore/createEntityStore.svelte';
export {
createCharacterComparison,
type LineData,
} from './createCharacterComparison/createCharacterComparison.svelte';
export { createPersistentStore } from './createPersistentStore/createPersistentStore.svelte';

View File

@@ -1,15 +1,18 @@
export { export {
type ControlDataModel, type ControlDataModel,
type ControlModel, type ControlModel,
createCharacterComparison,
createDebouncedState, createDebouncedState,
createEntityStore, createEntityStore,
createFilter, createFilter,
createPersistentStore,
createTypographyControl, createTypographyControl,
createVirtualizer, createVirtualizer,
type Entity, type Entity,
type EntityStore, type EntityStore,
type Filter, type Filter,
type FilterModel, type FilterModel,
type LineData,
type Property, type Property,
type TypographyControl, type TypographyControl,
type VirtualItem, type VirtualItem,
@@ -17,5 +20,6 @@ export {
type VirtualizerOptions, type VirtualizerOptions,
} from './helpers'; } from './helpers';
export { motion } from './accessibility/motion.svelte';
export { splitArray } from './utils'; export { splitArray } from './utils';
export { springySlideFade } from './transitions';

View File

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

View File

@@ -0,0 +1,60 @@
import type {
SlideParams,
TransitionConfig,
} from 'svelte/transition';
function elasticOut(t: number) {
return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1;
}
function gentleSpring(t: number) {
return 1 - Math.pow(1 - t, 3) * Math.cos(t * Math.PI * 2);
}
/**
* Svelte slide transition function for custom slide+fade
* @param node - The element to apply the transition to
* @param params - Transition parameters
* @returns Transition configuration
*/
export function springySlideFade(
node: HTMLElement,
params: SlideParams = {},
): TransitionConfig {
const { duration = 400 } = params;
const height = node.scrollHeight;
// Check if the browser is Firefox to work around specific rendering issues
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');
return {
duration,
// We use 'tick' for the most precise control over the
// coordination with the elements below.
css: t => {
// Use elastic easing
const eased = gentleSpring(t);
return `
height: ${eased * height}px;
opacity: ${t};
transform: translateY(${(1 - t) * -10}px);
transform-origin: top;
overflow: hidden;
contain: size layout style;
will-change: max-height, opacity, transform;
backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
${
isFirefox
? `
perspective: 1000px;
isolation: isolate;
`
: ''
}
`;
},
};
}

View File

@@ -9,10 +9,8 @@ export const badgeVariants = tv({
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3', 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
variants: { variants: {
variant: { variant: {
default: default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent', secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
secondary:
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
destructive: destructive:
'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white', 'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',

View File

@@ -0,0 +1,10 @@
import Scrollbar from './scroll-area-scrollbar.svelte';
import Root from './scroll-area.svelte';
export {
Root,
// ,
Root as ScrollArea,
Scrollbar,
Scrollbar as ScrollAreaScrollbar,
};

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import {
type WithoutChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
orientation = 'vertical',
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
</script>
<ScrollAreaPrimitive.Scrollbar
bind:ref
data-slot="scroll-area-scrollbar"
{orientation}
class={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-s border-s-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...restProps}
>
{@render children?.()}
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
class="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.Scrollbar>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import {
type WithoutChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
import { Scrollbar } from './index.js';
let {
ref = $bindable(null),
viewportRef = $bindable(null),
class: className,
orientation = 'vertical',
scrollbarXClasses = '',
scrollbarYClasses = '',
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
orientation?: 'vertical' | 'horizontal' | 'both' | undefined;
scrollbarXClasses?: string | undefined;
scrollbarYClasses?: string | undefined;
viewportRef?: HTMLElement | null;
} = $props();
</script>
<ScrollAreaPrimitive.Root
bind:ref
data-slot="scroll-area"
class={cn('relative', className)}
{...restProps}
>
<ScrollAreaPrimitive.Viewport
bind:ref={viewportRef}
data-slot="scroll-area-viewport"
class="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
>
{@render children?.()}
</ScrollAreaPrimitive.Viewport>
{#if orientation === 'vertical' || orientation === 'both'}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
{/if}
{#if orientation === 'horizontal' || orientation === 'both'}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
{/if}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>

View File

@@ -0,0 +1,37 @@
import Content from './select-content.svelte';
import GroupHeading from './select-group-heading.svelte';
import Group from './select-group.svelte';
import Item from './select-item.svelte';
import Label from './select-label.svelte';
import Portal from './select-portal.svelte';
import ScrollDownButton from './select-scroll-down-button.svelte';
import ScrollUpButton from './select-scroll-up-button.svelte';
import Separator from './select-separator.svelte';
import Trigger from './select-trigger.svelte';
import Root from './select.svelte';
export {
Content,
Content as SelectContent,
Group,
Group as SelectGroup,
GroupHeading,
GroupHeading as SelectGroupHeading,
Item,
Item as SelectItem,
Label,
Label as SelectLabel,
Portal,
Portal as SelectPortal,
Root,
//
Root as Select,
ScrollDownButton,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton,
ScrollUpButton as SelectScrollUpButton,
Separator,
Separator as SelectSeparator,
Trigger,
Trigger as SelectTrigger,
};

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import {
type WithoutChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import type { WithoutChildrenOrChild } from '$shared/shadcn/utils/shadcn-utils.js';
import { Select as SelectPrimitive } from 'bits-ui';
import type { ComponentProps } from 'svelte';
import SelectPortal from './select-portal.svelte';
import SelectScrollDownButton from './select-scroll-down-button.svelte';
import SelectScrollUpButton from './select-scroll-up-button.svelte';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
'bg-popover text-popover-foreground 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=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1',
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
import { Select as SelectPrimitive } from 'bits-ui';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import {
type WithoutChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import CheckIcon from '@lucide/svelte/icons/check';
import { Select as SelectPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import {
type WithElementRef,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import {
type WithoutChildrenOrChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { Select as SelectPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn('flex cursor-default items-center justify-center py-1', className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import {
type WithoutChildrenOrChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import { Select as SelectPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn('flex cursor-default items-center justify-center py-1', className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { Separator } from '$shared/shadcn/ui/separator/index.js';
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
import type { Separator as SeparatorPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...restProps}
/>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import {
type WithoutChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { Select as SelectPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
children,
size = 'default',
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: 'sm' | 'default';
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />

View File

@@ -33,8 +33,7 @@ const sidebar = setSidebar({
onOpenChange(value); onOpenChange(value);
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
`${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, },
}); });
</script> </script>

View File

@@ -8,8 +8,10 @@
- Local transition prevents animation when component first renders - Local transition prevents animation when component first renders
--> -->
<script lang="ts"> <script lang="ts">
import type { Filter } from '$shared/lib'; import {
import { motion } from '$shared/lib'; type Filter,
springySlideFade,
} from '$shared/lib';
import { Badge } from '$shared/shadcn/ui/badge'; import { Badge } from '$shared/shadcn/ui/badge';
import { buttonVariants } from '$shared/shadcn/ui/button'; import { buttonVariants } from '$shared/shadcn/ui/button';
import { Checkbox } from '$shared/shadcn/ui/checkbox'; import { Checkbox } from '$shared/shadcn/ui/checkbox';
@@ -20,7 +22,7 @@ import {
import { Label } from '$shared/shadcn/ui/label'; import { Label } from '$shared/shadcn/ui/label';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition'; import { prefersReducedMotion } from 'svelte/motion';
interface PropertyFilterProps { interface PropertyFilterProps {
/** Label for this filter group (e.g., "Properties", "Tags") */ /** Label for this filter group (e.g., "Properties", "Tags") */
@@ -37,7 +39,7 @@ let isOpen = $state(true);
// Animation config respects user preferences - zero duration if reduced motion enabled // Animation config respects user preferences - zero duration if reduced motion enabled
// Local modifier prevents animation on initial render, only animates user interactions // Local modifier prevents animation on initial render, only animates user interactions
const slideConfig = $derived({ const slideConfig = $derived({
duration: motion.reduced ? 0 : 250, duration: prefersReducedMotion.current ? 0 : 150,
easing: cubicOut, easing: cubicOut,
}); });
@@ -49,7 +51,7 @@ const hasSelection = $derived(selectedCount > 0);
<!-- Collapsible card wrapper with subtle hover state for affordance --> <!-- Collapsible card wrapper with subtle hover state for affordance -->
<CollapsibleRoot <CollapsibleRoot
bind:open={isOpen} bind:open={isOpen}
class="w-full rounded-lg border bg-card transition-colors hover:bg-accent/5" class="w-full bg-card transition-colors hover:bg-accent/5"
> >
<!-- Trigger row: title, expand indicator, and optional count badge --> <!-- Trigger row: title, expand indicator, and optional count badge -->
<div class="flex items-center justify-between px-4 py-2"> <div class="flex items-center justify-between px-4 py-2">
@@ -57,8 +59,7 @@ const hasSelection = $derived(selectedCount > 0);
class={buttonVariants({ class={buttonVariants({
variant: 'ghost', variant: 'ghost',
size: 'sm', size: 'sm',
class: class: 'flex-1 justify-between gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring',
'flex-1 justify-between gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring',
})} })}
> >
<h4 class="text-sm font-semibold">{displayedLabel}</h4> <h4 class="text-sm font-semibold">{displayedLabel}</h4>
@@ -88,8 +89,8 @@ const hasSelection = $derived(selectedCount > 0);
<!-- Expandable content with slide animation --> <!-- Expandable content with slide animation -->
{#if isOpen} {#if isOpen}
<div <div
transition:slide|local={slideConfig} transition:springySlideFade|local={slideConfig}
class="border-t" class="will-change-[height,opacity]"
> >
<div class="px-4 py-3"> <div class="px-4 py-3">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
@@ -105,9 +106,7 @@ const hasSelection = $derived(selectedCount > 0);
active:scale-[0.98] active:transition-transform active:duration-75 active:scale-[0.98] active:transition-transform active:duration-75
" "
> >
<!-- <!-- Checkbox handles toggle, styled for accessibility with focus rings -->
Checkbox handles toggle, styled for accessibility with focus rings
-->
<Checkbox <Checkbox
id={property.id} id={property.id}
bind:checked={property.selected} bind:checked={property.selected}

View File

@@ -8,13 +8,23 @@
<script lang="ts"> <script lang="ts">
import type { TypographyControl } from '$shared/lib'; import type { TypographyControl } from '$shared/lib';
import { Button } from '$shared/shadcn/ui/button'; import { Button } from '$shared/shadcn/ui/button';
import * as ButtonGroup from '$shared/shadcn/ui/button-group'; import { Root as ButtonGroupRoot } from '$shared/shadcn/ui/button-group';
import { Input } from '$shared/shadcn/ui/input'; import { Input } from '$shared/shadcn/ui/input';
import * as Popover from '$shared/shadcn/ui/popover'; import {
Content as PopoverContent,
Root as PopoverRoot,
Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover';
import { Slider } from '$shared/shadcn/ui/slider'; import { Slider } from '$shared/shadcn/ui/slider';
import {
Content as TooltipContent,
Root as TooltipRoot,
Trigger as TooltipTrigger,
} from '$shared/shadcn/ui/tooltip';
import MinusIcon from '@lucide/svelte/icons/minus'; import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import type { ChangeEventHandler } from 'svelte/elements'; import type { ChangeEventHandler } from 'svelte/elements';
import IconButton from '../IconButton/IconButton.svelte';
interface ComboControlProps { interface ComboControlProps {
/** /**
@@ -67,30 +77,34 @@ const handleSliderChange = (newValue: number) => {
}; };
</script> </script>
<ButtonGroup.Root> <TooltipRoot>
<Button <ButtonGroupRoot class="bg-transparent border-none shadow-none">
variant="outline" <TooltipTrigger class="flex items-center">
size="icon" <IconButton
aria-label={decreaseLabel}
onclick={control.decrease} onclick={control.decrease}
disabled={control.isAtMin} disabled={control.isAtMin}
aria-label={decreaseLabel}
rotation="counterclockwise"
> >
<MinusIcon /> {#snippet icon({ className })}
</Button> <MinusIcon class={className} />
<Popover.Root> {/snippet}
<Popover.Trigger> </IconButton>
<PopoverRoot>
<PopoverTrigger>
{#snippet child({ props })} {#snippet child({ props })}
<Button <Button
{...props} {...props}
variant="outline" variant="ghost"
class="hover:bg-white/50 hover:font-bold bg-white/20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
size="icon" size="icon"
aria-label={controlLabel} aria-label={controlLabel}
> >
{control.value} {control.value}
</Button> </Button>
{/snippet} {/snippet}
</Popover.Trigger> </PopoverTrigger>
<Popover.Content class="w-auto p-4"> <PopoverContent class="w-auto p-4">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
<Slider <Slider
min={control.min} min={control.min}
@@ -110,15 +124,24 @@ const handleSliderChange = (newValue: number) => {
class="w-16 text-center" class="w-16 text-center"
/> />
</div> </div>
</Popover.Content> </PopoverContent>
</Popover.Root> </PopoverRoot>
<Button
variant="outline" <IconButton
size="icon"
aria-label={increaseLabel} aria-label={increaseLabel}
onclick={control.increase} onclick={control.increase}
disabled={control.isAtMax} disabled={control.isAtMax}
rotation="clockwise"
> >
<PlusIcon /> {#snippet icon({ className })}
</Button> <PlusIcon class={className} />
</ButtonGroup.Root> {/snippet}
</IconButton>
</TooltipTrigger>
</ButtonGroupRoot>
{#if controlLabel}
<TooltipContent>
{controlLabel}
</TooltipContent>
{/if}
</TooltipRoot>

View File

@@ -0,0 +1,72 @@
<!--
Component: ComboControl
Provides the same functionality as the original ComboControl but lacks increase/decrease buttons.
-->
<script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { Input } from '$shared/shadcn/ui/input';
import { Slider } from '$shared/shadcn/ui/slider';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import type { ChangeEventHandler } from 'svelte/elements';
interface Props {
control: TypographyControl;
ref?: Snippet;
}
let {
control,
ref = $bindable(),
}: Props = $props();
let sliderValue = $state(Number(control.value));
$effect(() => {
sliderValue = Number(control.value);
});
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
const parsedValue = parseFloat(event.currentTarget.value);
if (!isNaN(parsedValue)) {
control.value = parsedValue;
}
};
const handleSliderChange = (newValue: number) => {
control.value = newValue;
};
// Shared glass button class for consistency
// const glassBtnClass = cn(
// 'border-none transition-all duration-200',
// 'bg-white/10 hover:bg-white/40 active:scale-90',
// 'text-slate-900 font-medium',
// );
// const ghostStyle = cn(
// 'flex items-center justify-center transition-all duration-300 ease-out',
// 'text-slate-900/40 hover:text-slate-950 hover:bg-white/20 active:scale-90',
// 'disabled:opacity-10 disabled:pointer-events-none',
// );
</script>
<div class="flex flex-col items-center gap-4">
<Input
value={control.value}
onchange={handleInputChange}
min={control.min}
max={control.max}
class="w-14 h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50"
/>
<Slider
min={control.min}
max={control.max}
step={control.step}
value={sliderValue}
onValueChange={handleSliderChange}
type="single"
orientation="vertical"
class="h-30"
/>
</div>

View File

@@ -5,14 +5,20 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
/** /**
* Visible text * Visible text (bindable)
*/ */
text: string; text: string;
/** /**
* Font settings * Font size in pixels
*/ */
fontSize?: number; fontSize?: number;
/**
* Line height
*/
lineHeight?: number; lineHeight?: number;
/**
* Letter spacing in pixels
*/
letterSpacing?: number; letterSpacing?: number;
} }
@@ -53,7 +59,7 @@ function handleInput(e: Event) {
w-full min-h-[1.2em] outline-none transition-all duration-200 w-full min-h-[1.2em] outline-none transition-all duration-200
empty:before:content-[attr(data-placeholder)] empty:before:text-slate-400 empty:before:content-[attr(data-placeholder)] empty:before:text-slate-400
selection:bg-indigo-100 selection:text-indigo-900 selection:bg-indigo-100 selection:text-indigo-900
caret-indigo-500 caret-indigo-500 focus:outline-none
" "
style:font-size="{fontSize}px" style:font-size="{fontSize}px"
style:line-height={lineHeight} style:line-height={lineHeight}

View File

@@ -0,0 +1,95 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import { createRawSnippet } from 'svelte';
import ExpandableWrapper from './ExpandableWrapper.svelte';
const visibleSnippet = createRawSnippet(() => ({
render: () =>
`<div class="w-48 p-2 font-bold text-indigo-600">
Always visible
</div>`,
}));
const hiddenSnippet = createRawSnippet(() => ({
render: () =>
`<div class="p-4 space-y-2 border-t border-indigo-100 mt-2">
<div class="h-4 w-full bg-indigo-100 rounded animate-pulse"></div>
<div class="h-4 w-2/3 bg-indigo-50 rounded animate-pulse"></div>
</div>`,
}));
const badgeSnippet = createRawSnippet(() => ({
render: () =>
`<div class="">
<span class="badge badge-primary">*</span>
</div>`,
}));
const { Story } = defineMeta({
title: 'Shared/ExpandableWrapper',
component: ExpandableWrapper,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Animated styled wrapper for content that can be expanded and collapsed.',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
args: {
expanded: false,
disabled: false,
rotation: 'clockwise',
visibleContent: visibleSnippet,
hiddenContent: hiddenSnippet,
},
argTypes: {
expanded: { control: 'boolean' },
disabled: { control: 'boolean' },
rotation: {
control: 'select',
options: ['clockwise', 'counterclockwise'],
},
},
});
</script>
<script lang="ts">
</script>
{/* @ts-ignore */ null}
<Story name="With hidden content">
{#snippet children(args)}
<div class="p-12 bg-slate-100 min-h-[300px] flex justify-center items-start">
<ExpandableWrapper
{...args}
bind:expanded={args.expanded}
/>
</div>
{/snippet}
</Story>
{/* @ts-ignore */ null}
<Story name="Disabled" args={{ disabled: true }}>
{#snippet children(args)}
<div class="p-12 bg-slate-100 min-h-[300px] flex justify-center items-start">
<ExpandableWrapper
{...args}
bind:expanded={args.expanded}
disabled={args.disabled}
/>
</div>
{/snippet}
</Story>
{/* @ts-ignore */ null}
<Story name="With badge" args={{ badge: badgeSnippet }}>
{#snippet children(args)}
<div class="p-12 bg-slate-100 min-h-[300px] flex justify-center items-start">
<ExpandableWrapper
{...args}
bind:expanded={args.expanded}
/>
</div>
{/snippet}
</Story>

View File

@@ -0,0 +1,195 @@
<!--
Component: ExpandableWrapper
Animated wrapper for content that can be expanded and collapsed.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing';
import type { HTMLAttributes } from 'svelte/elements';
import { Spring } from 'svelte/motion';
import { slide } from 'svelte/transition';
interface Props extends HTMLAttributes<HTMLDivElement> {
/**
* Bindable property to control the expanded state of the wrapper.
* @default false
*/
expanded?: boolean;
/**
* Disabled flag
* @default false
*/
disabled?: boolean;
/**
* Bindable property to bind:this
* @default null
*/
element?: HTMLElement | null;
/**
* Content that's always visible
*/
visibleContent?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
/**
* Content that's hidden when the wrapper is collapsed
*/
hiddenContent?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
/**
* Optional badge to render
*/
badge?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
/**
* Rotation animation direction
* @default 'clockwise'
*/
rotation?: 'clockwise' | 'counterclockwise';
/**
* Classes for intermnal container
*/
containerClassName?: string;
}
let {
expanded = $bindable(false),
disabled = false,
element = $bindable(null),
visibleContent,
hiddenContent,
badge,
rotation = 'clockwise',
class: className = '',
containerClassName = '',
...props
}: Props = $props();
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
export const xSpring = new Spring(0, {
stiffness: 0.14, // Lower is slower
damping: 0.5, // Settle
});
const ySpring = new Spring(0, {
stiffness: 0.32,
damping: 0.65,
});
const scaleSpring = new Spring(1, {
stiffness: 0.32,
damping: 0.65,
});
export const rotateSpring = new Spring(0, {
stiffness: 0.12,
damping: 0.55,
});
function handleClickOutside(e: MouseEvent) {
if (element && !element.contains(e.target as Node)) {
expanded = false;
}
}
function handleWrapperClick() {
if (!disabled) {
expanded = true;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || (e.key === ' ' && !expanded)) {
e.preventDefault();
handleWrapperClick();
}
if (expanded && e.key === 'Escape') {
expanded = false;
}
}
// Elevation and scale on activation
$effect(() => {
if (expanded && !disabled) {
// Lift up
ySpring.target = 8;
// Slightly bigger
scaleSpring.target = 1.1;
rotateSpring.target = rotation === 'clockwise' ? -0.5 : 0.5;
timeoutId = setTimeout(() => {
rotateSpring.target = 0;
scaleSpring.target = 1.05;
}, 300);
} else {
ySpring.target = 0;
scaleSpring.target = 1;
rotateSpring.target = 0;
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
// Click outside handling
$effect(() => {
if (typeof window === 'undefined') {
return;
}
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
$effect(() => {
if (disabled) {
expanded = false;
}
});
</script>
<div
bind:this={element}
onclick={handleWrapperClick}
onkeydown={handleKeyDown}
role="button"
tabindex={0}
class={cn(
'will-change-transform duration-300',
disabled ? 'pointer-events-none' : 'pointer-events-auto',
className,
)}
style:transform="
translate({xSpring.current}px, {ySpring.current}px)
scale({scaleSpring.current})
rotateZ({rotateSpring.current}deg)
"
{...props}
>
{@render badge?.({ expanded, disabled })}
<div
class={cn(
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
expanded
? 'bg-white/5 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
: ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
disabled && 'opacity-80 grayscale-[0.2]',
containerClassName,
)}
>
{@render visibleContent?.({ expanded, disabled })}
{#if expanded}
<div
in:slide={{ duration: 250, easing: cubicOut }}
out:slide={{ duration: 250, easing: cubicOut }}
>
{@render hiddenContent?.({ expanded, disabled })}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,50 @@
<!--
Component: IconButton
Shadcn button size="icon" variant="ghost" with custom styling and icon snippet
-->
<script lang="ts">
import { Button } from '$shared/shadcn/ui/button';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type {
ComponentProps,
Snippet,
} from 'svelte';
interface Props extends ComponentProps<typeof Button> {
/**
* Direction of the rotation effect on click
* @default clockwise
*/
rotation?: 'clockwise' | 'counterclockwise';
/**
* Icon
*/
icon: Snippet<[{ className: string }]>;
}
let { rotation = 'clockwise', icon, ...rest }: Props = $props();
</script>
<Button
variant="ghost"
class="
group relative border-none size-9
bg-white/20 hover:bg-white/60
backdrop-blur-3xl
transition-all duration-200 ease-out
will-change-transform
hover:-translate-y-0.5
active:translate-y-0 active:scale-95 active:shadow-none
cursor-pointer
disabled:opacity-50 disabled:pointer-events-none
"
size="icon"
{...rest}
>
{@render icon({
className: cn(
'size-4 transition-all duration-200 stroke-[1.5] stroke-gray-500 group-hover:stroke-gray-900 group-hover:scale-110 group-hover:stroke-3 group-active:scale-90 group-disabled:stroke-transparent',
rotation === 'clockwise' ? 'group-active:rotate-6' : 'group-active:-rotate-6',
),
})}
</Button>

View File

@@ -45,11 +45,7 @@ let noChildrenValue = $state('');
placeholder: 'Type here...', placeholder: 'Type here...',
}} }}
> >
<SearchBar bind:value={defaultSearchValue} placeholder="Type here..."> <SearchBar bind:value={defaultSearchValue} placeholder="Type here..."> </SearchBar>
Here will be the search result
<br />
Popover closes only when the user clicks outside the search bar or presses the Escape key.
</SearchBar>
</Story> </Story>
<Story <Story
@@ -60,11 +56,7 @@ let noChildrenValue = $state('');
label: 'Search', label: 'Search',
}} }}
> >
<SearchBar bind:value={withLabelValue} placeholder="Search products..." label="Search"> <SearchBar bind:value={withLabelValue} placeholder="Search products..." label="Search"> </SearchBar>
<div class="p-4">
<p class="text-sm text-muted-foreground">No results found</p>
</div>
</SearchBar>
</Story> </Story>
<Story <Story
@@ -74,9 +66,5 @@ let noChildrenValue = $state('');
placeholder: 'Quick search...', placeholder: 'Quick search...',
}} }}
> >
<SearchBar bind:value={noChildrenValue} placeholder="Quick search..."> <SearchBar bind:value={noChildrenValue} placeholder="Quick search..."> </SearchBar>
<div class="p-4 text-center text-sm text-muted-foreground">
Start typing to see results
</div>
</SearchBar>
</Story> </Story>

View File

@@ -1,90 +1,75 @@
<!-- <!-- Component: SearchBar -->
Component: SearchBar
Search input with popover dropdown for results/suggestions
- Features keyboard navigation (ArrowDown/Up/Enter) and auto-focus prevention on popover open.
- The input field serves as the popover trigger.
-->
<script lang="ts"> <script lang="ts">
import { Input } from '$shared/shadcn/ui/input'; import { Input } from '$shared/shadcn/ui/input';
import { Label } from '$shared/shadcn/ui/label'; import AsteriskIcon from '@lucide/svelte/icons/asterisk';
import {
Content as PopoverContent,
Root as PopoverRoot,
Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover';
import { useId } from 'bits-ui';
import type { Snippet } from 'svelte';
interface Props { interface Props {
/** Unique identifier for the input element */ /**
* Unique identifier for the input element
*/
id?: string; id?: string;
/** Current search value (bindable) */ /**
* Current search value (bindable)
*/
value: string; value: string;
/** Additional CSS classes for the container */ /**
* Additional CSS classes for the container
*/
class?: string; class?: string;
/** Placeholder text for the input */ /**
* Placeholder text for the input
*/
placeholder?: string; placeholder?: string;
/** Optional label displayed above the input */ /**
* Optional label displayed above the input
*/
label?: string; label?: string;
/** Content to render inside the popover (receives unique content ID) */
children: Snippet<[{ id: string }]> | undefined;
} }
let { let {
id = 'search-bar', id = 'search-bar',
value = $bindable(), value = $bindable(''),
class: className, class: className,
placeholder, placeholder,
label,
children,
}: Props = $props(); }: Props = $props();
let open = $state(false);
let triggerRef = $state<HTMLInputElement>(null!);
// svelte-ignore state_referenced_locally
const contentId = useId(id);
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') { if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
} }
} }
function handleInputClick() {
open = true;
}
</script> </script>
<PopoverRoot bind:open> <div class="relative w-full">
<PopoverTrigger bind:ref={triggerRef}> <div class="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
{#snippet child({ props })} <AsteriskIcon class="size-4 stroke-gray-400 stroke-[1.5]" />
{@const { onclick, ...rest } = props} </div>
<div {...rest} class="flex flex-row flex-1 w-full">
{#if label}
<Label for={id}>{label}</Label>
{/if}
<Input <Input
id={id} id={id}
placeholder={placeholder} placeholder={placeholder}
bind:value={value} bind:value={value}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
onclick={handleInputClick} class="
class="flex flex-row flex-1" h-16 w-full text-base
backdrop-blur-md bg-white/80
border border-gray-300/50
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
focus-visible:border-gray-400/60
focus-visible:outline-none
focus-visible:ring-1
focus-visible:ring-gray-400/30
focus-visible:bg-white/90
hover:bg-white/90
hover:border-gray-400/60
text-gray-900
placeholder:text-gray-400
placeholder:font-mono
placeholder:text-sm
placeholder:tracking-wide
pl-14 pr-6
rounded-xl
transition-all duration-200
font-medium
"
/> />
</div> </div>
{/snippet}
</PopoverTrigger>
<PopoverContent
onOpenAutoFocus={e => e.preventDefault()}
onInteractOutside={(e => {
if (e.target === triggerRef) {
e.preventDefault();
}
})}
class="w-(--bits-popover-anchor-width) min-w-(--bits-popover-anchor-width)"
>
{@render children?.({ id: contentId })}
</PopoverContent>
</PopoverRoot>

View File

@@ -0,0 +1,108 @@
<!--
Component: Section
Provides a container for a page widget with snippets for a title
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing';
import type { HTMLAttributes } from 'svelte/elements';
import {
type FlyParams,
fly,
} from 'svelte/transition';
interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
/**
* Additional CSS classes to apply to the section container.
*/
class?: string;
/**
* Snippet for a title itself
*/
title?: Snippet<[{ className?: string }]>;
/**
* Snippet for a title icon
*/
icon?: Snippet<[{ className?: string }]>;
/**
* Index of the section
*/
index?: number;
/**
* Callback function to notify 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
* @returns Cleanup callback
*/
onTitleStatusChange?: (index: number, isPast: boolean, title?: Snippet<[{ className?: string }]>) => () => void;
/**
* Snippet for the section content
*/
children?: Snippet;
}
const { class: className, title, icon, index = 0, onTitleStatusChange, children }: Props = $props();
let titleContainer = $state<HTMLElement>();
const flyParams: FlyParams = { y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 };
// Track if the user has actually scrolled away from view
let isScrolledPast = $state(false);
$effect(() => {
if (!titleContainer) {
return;
}
let cleanup: ((index: number) => void) | undefined;
const observer = new IntersectionObserver(entries => {
const entry = entries[0];
const isPast = !entry.isIntersecting && entry.boundingClientRect.top < 0;
if (isPast !== isScrolledPast) {
isScrolledPast = isPast;
cleanup = onTitleStatusChange?.(index, isPast, title);
}
}, {
// Set threshold to 0 to trigger exactly when the last pixel leaves
threshold: 0,
});
observer.observe(titleContainer);
return () => {
observer.disconnect();
cleanup?.(index);
};
});
</script>
<section
class={cn(
'flex flex-col',
className,
)}
in:fly={flyParams}
out:fly={flyParams}
>
<div class="flex flex-col gap-2" bind:this={titleContainer}>
<div class="flex items-center gap-3 opacity-60">
{#if icon}
{@render icon({ className: 'size-4 stroke-gray-900 stroke-1' })}
<div class="w-px h-3 bg-gray-400/50"></div>
{/if}
{#if typeof index === 'number'}
<span class="font-mono text-[10px] uppercase tracking-[0.2em] text-gray-600">
Component_{String(index).padStart(3, '0')}
</span>
{/if}
</div>
{#if title}
{@render title({ className: 'text-5xl md:text-6xl font-semibold tracking-tighter text-gray-900 leading-[0.9]' })}
{/if}
</div>
{@render children?.()}
</section>

View File

@@ -6,11 +6,15 @@
- Keyboard navigation (ArrowUp/Down, Home, End) - Keyboard navigation (ArrowUp/Down, Home, End)
- Fixed or dynamic item heights - Fixed or dynamic item heights
- ARIA listbox/option pattern with single tab stop - ARIA listbox/option pattern with single tab stop
- Custom shadcn ScrollArea scrollbar
--> -->
<script lang="ts" generics="T"> <script lang="ts" generics="T">
import { createVirtualizer } from '$shared/lib'; import { createVirtualizer } from '$shared/lib';
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { quintOut } from 'svelte/easing';
interface Props { interface Props {
/** /**
@@ -19,6 +23,20 @@ interface Props {
* @template T - The type of items in the list * @template T - The type of items in the list
*/ */
items: T[]; items: T[];
/**
* Total number of items (including not-yet-loaded items for pagination).
* If not provided, defaults to items.length.
*
* Use this when implementing pagination to ensure the scrollbar
* reflects the total count of items, not just the loaded ones.
*
* @example
* ```ts
* // Pagination scenario: 1920 total fonts, but only 50 loaded
* <VirtualList items={loadedFonts} total={1920}>
* ```
*/
total?: number;
/** /**
* Height for each item, either as a fixed number * Height for each item, either as a fixed number
* or a function that returns height per index. * or a function that returns height per index.
@@ -40,6 +58,24 @@ interface Props {
* @param items - Loaded items * @param items - Loaded items
*/ */
onVisibleItemsChange?: (items: T[]) => void; onVisibleItemsChange?: (items: T[]) => void;
/**
* An optional callback that will be called when user scrolls near the end of the list.
* Useful for triggering auto-pagination.
*
* The callback receives the index of the last visible item. You can use this
* to determine if you should load more data.
*
* @example
* ```ts
* onNearBottom={(lastVisibleIndex) => {
* const itemsRemaining = total - lastVisibleIndex;
* if (itemsRemaining < 5 && hasMore && !isFetching) {
* loadMore();
* }
* }}
* ```
*/
onNearBottom?: (lastVisibleIndex: number) => void;
/** /**
* Snippet for rendering individual list items. * Snippet for rendering individual list items.
* *
@@ -52,39 +88,79 @@ interface Props {
* *
* @template T - The type of items in the list * @template T - The type of items in the list
*/ */
children: Snippet<[{ item: T; index: number }]>; /**
* Snippet for rendering individual list items.
*
* The snippet receives an object containing:
* - `item`: The item from the items array (type T)
* - `index`: The current item's index in the array
*
* This pattern provides type safety and flexibility for
* rendering different item types without prop drilling.
*
* @template T - The type of items in the list
*/
children: Snippet<
[{ item: T; index: number; isFullyVisible: boolean; isPartiallyVisible: boolean; proximity: number }]
>;
/**
* Whether to use the window as the scroll container.
* @default false
*/
useWindowScroll?: boolean;
} }
let { items, itemHeight = 80, overscan = 5, class: className, onVisibleItemsChange, children }: let {
Props = $props(); items,
total = items.length,
itemHeight = 80,
overscan = 5,
class: className,
onVisibleItemsChange,
onNearBottom,
children,
useWindowScroll = false,
}: Props = $props();
// Reference to the ScrollArea viewport element for attaching the virtualizer
let viewportRef = $state<HTMLElement | null>(null);
const virtualizer = createVirtualizer(() => ({ const virtualizer = createVirtualizer(() => ({
count: items.length, count: items.length,
data: items, data: items,
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight, estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
overscan, overscan,
useWindowScroll,
})); }));
// Attach virtualizer.container action to the viewport when it becomes available
$effect(() => {
if (viewportRef) {
const { destroy } = virtualizer.container(viewportRef);
return destroy;
}
});
$effect(() => { $effect(() => {
const visibleItems = virtualizer.items.map(item => items[item.index]); const visibleItems = virtualizer.items.map(item => items[item.index]);
onVisibleItemsChange?.(visibleItems); onVisibleItemsChange?.(visibleItems);
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
if (virtualizer.items.length > 0 && onNearBottom) {
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
// Compare against loaded items length, not total
const itemsRemaining = items.length - lastVisibleItem.index;
if (itemsRemaining <= 5) {
onNearBottom(lastVisibleItem.index);
}
}
}); });
</script> </script>
<div {#if useWindowScroll}
use:virtualizer.container <div class={cn('relative w-full', className)} bind:this={viewportRef}>
class={cn( <div style:height="{virtualizer.totalSize}px" class="relative w-full">
'relative overflow-auto rounded-md bg-background',
'h-150 w-full',
className,
)}
>
<div
style:height="{virtualizer.totalSize}px"
class="w-full pointer-events-none"
>
</div>
{#each virtualizer.items as item (item.key)} {#each virtualizer.items as item (item.key)}
<div <div
use:virtualizer.measureElement use:virtualizer.measureElement
@@ -92,7 +168,51 @@ $effect(() => {
class="absolute top-0 left-0 w-full" class="absolute top-0 left-0 w-full"
style:transform="translateY({item.start}px)" style:transform="translateY({item.start}px)"
> >
{@render children({ item: items[item.index], index: item.index })} {#if item.index < items.length}
{@render children({
// TODO: Fix indenation rule for this case
item: items[item.index],
index: item.index,
isFullyVisible: item.isFullyVisible,
isPartiallyVisible: item.isPartiallyVisible,
proximity: item.proximity,
})}
{/if}
</div> </div>
{/each} {/each}
</div> </div>
</div>
{:else}
<ScrollArea
bind:viewportRef
class={cn(
'relative rounded-md bg-background',
'h-150 w-full',
className,
)}
orientation="vertical"
>
<div style:height="{virtualizer.totalSize}px" class="relative w-full">
{#each virtualizer.items as item (item.key)}
<div
use:virtualizer.measureElement
data-index={item.index}
class="absolute top-0 left-0 w-full"
style:transform="translateY({item.start}px)"
animate:flip={{ delay: 0, duration: 300, easing: quintOut }}
>
{#if item.index < items.length}
{@render children({
// TODO: Fix indenation rule for this case
item: items[item.index],
index: item.index,
isFullyVisible: item.isFullyVisible,
isPartiallyVisible: item.isPartiallyVisible,
proximity: item.proximity,
})}
{/if}
</div>
{/each}
</div>
</ScrollArea>
{/if}

View File

@@ -6,14 +6,22 @@
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte'; import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
import ComboControl from './ComboControl/ComboControl.svelte'; import ComboControl from './ComboControl/ComboControl.svelte';
import ComboControlV2 from './ComboControlV2/ComboControlV2.svelte';
import ContentEditable from './ContentEditable/ContentEditable.svelte'; import ContentEditable from './ContentEditable/ContentEditable.svelte';
import ExpandableWrapper from './ExpandableWrapper/ExpandableWrapper.svelte';
import IconButton from './IconButton/IconButton.svelte';
import SearchBar from './SearchBar/SearchBar.svelte'; import SearchBar from './SearchBar/SearchBar.svelte';
import Section from './Section/Section.svelte';
import VirtualList from './VirtualList/VirtualList.svelte'; import VirtualList from './VirtualList/VirtualList.svelte';
export { export {
CheckboxFilter, CheckboxFilter,
ComboControl, ComboControl,
ComboControlV2,
ContentEditable, ContentEditable,
ExpandableWrapper,
IconButton,
SearchBar, SearchBar,
Section,
VirtualList, VirtualList,
}; };

View File

@@ -0,0 +1,2 @@
export * from './model';
export { ComparisonSlider } from './ui';

View File

@@ -0,0 +1 @@
export { comparisonStore } from './stores/comparisonStore.svelte';

View File

@@ -0,0 +1,150 @@
import {
type UnifiedFont,
fetchFontsByIds,
unifiedFontStore,
} from '$entities/Font';
import { createPersistentStore } from '$shared/lib';
/**
* Storage schema for comparison state
*/
interface ComparisonState {
fontAId: string | null;
fontBId: string | null;
}
// Persistent storage for selected comparison fonts
const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
fontAId: null,
fontBId: null,
});
/**
* Store for managing font comparison state
* - Persists selection to localStorage
* - Handles font fetching on initialization
* - Manages sample text
*/
class ComparisonStore {
#fontA = $state<UnifiedFont | undefined>();
#fontB = $state<UnifiedFont | undefined>();
#sampleText = $state('The quick brown fox jumps over the lazy dog');
#isRestoring = $state(true);
constructor() {
this.restoreFromStorage();
// Reactively set defaults if we aren't restoring and have no selection
$effect.root(() => {
$effect(() => {
// Wait until we are done checking storage
if (this.#isRestoring) {
return;
}
// If we already have a selection, do nothing
if (this.#fontA && this.#fontB) {
return;
}
// Check if fonts are available to set as defaults
const fonts = unifiedFontStore.fonts;
if (fonts.length >= 2) {
// Only set if we really have nothing (fallback)
if (!this.#fontA) this.#fontA = fonts[0];
if (!this.#fontB) this.#fontB = fonts[fonts.length - 1];
// Sync defaults to storage so they persist if the user leaves
this.updateStorage();
}
});
});
}
/**
* Restore state from persistent storage
*/
async restoreFromStorage() {
this.#isRestoring = true;
const { fontAId, fontBId } = storage.value;
if (fontAId && fontBId) {
try {
// Batch fetch the saved fonts
const fonts = await fetchFontsByIds([fontAId, fontBId]);
const loadedFontA = fonts.find((f: UnifiedFont) => f.id === fontAId);
const loadedFontB = fonts.find((f: UnifiedFont) => f.id === fontBId);
if (loadedFontA && loadedFontB) {
this.#fontA = loadedFontA;
this.#fontB = loadedFontB;
}
} catch (error) {
console.warn('[ComparisonStore] Failed to restore fonts:', error);
}
}
// Mark restoration as complete (whether success or fail)
this.#isRestoring = false;
}
/**
* Update storage with current state
*/
private updateStorage() {
// Don't save if we are currently restoring (avoid race)
if (this.#isRestoring) return;
storage.value = {
fontAId: this.#fontA?.id ?? null,
fontBId: this.#fontB?.id ?? null,
};
}
// --- Getters & Setters ---
get fontA() {
return this.#fontA;
}
set fontA(font: UnifiedFont | undefined) {
this.#fontA = font;
this.updateStorage();
}
get fontB() {
return this.#fontB;
}
set fontB(font: UnifiedFont | undefined) {
this.#fontB = font;
this.updateStorage();
}
get text() {
return this.#sampleText;
}
set text(value: string) {
this.#sampleText = value;
}
/**
* Check if both fonts are selected
*/
get isReady() {
return !!this.#fontA && !!this.#fontB;
}
/**
* Public initializer (optional, as constructor starts it)
* Kept for compatibility if manual re-init is needed
*/
initialize() {
if (!this.#isRestoring && !this.#fontA && !this.#fontB) {
this.restoreFromStorage();
}
}
}
export const comparisonStore = new ComparisonStore();

View File

@@ -0,0 +1,230 @@
<!--
Component: ComparisonSlider (Ultimate Comparison Slider)
A multiline text comparison slider that morphs between two fonts.
Features:
- Multiline support with precise line breaking matching container width.
- Character-level morphing: Font changes exactly when the slider passes the character's global position.
- Responsive layout with Tailwind breakpoints for font sizing.
- Performance optimized using offscreen canvas for measurements and transform-based animations.
-->
<script lang="ts">
import {
createCharacterComparison,
createTypographyControl,
} from '$shared/lib';
import type { LineData } from '$shared/lib';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { Spring } from 'svelte/motion';
import CharacterSlot from './components/CharacterSlot.svelte';
import ControlsWrapper from './components/ControlsWrapper.svelte';
import Labels from './components/Labels.svelte';
import SliderLine from './components/SliderLine.svelte';
// Pair of fonts to compare
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
let container: HTMLElement | undefined = $state();
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
let measureCanvas: HTMLCanvasElement | undefined = $state();
let isDragging = $state(false);
const weightControl = createTypographyControl({
min: 100,
max: 700,
step: 100,
value: 400,
});
const heightControl = createTypographyControl({
min: 1,
max: 2,
step: 0.05,
value: 1.2,
});
const sizeControl = createTypographyControl({
min: 1,
max: 112,
step: 1,
value: 64,
});
/**
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
* Manages line breaking and character state based on fonts and container dimensions.
*/
const charComparison = createCharacterComparison(
() => comparisonStore.text,
() => fontA,
() => fontB,
() => weightControl.value,
() => sizeControl.value,
);
let lineElements = $state<(HTMLElement | undefined)[]>([]);
/** Physics-based spring for smooth handle movement */
const sliderSpring = new Spring(50, {
stiffness: 0.2, // Balanced for responsiveness
damping: 0.7, // No bounce, just smooth stop
});
const sliderPos = $derived(sliderSpring.current);
/** Updates spring target based on pointer position */
function handleMove(e: PointerEvent) {
if (!isDragging || !container) return;
const rect = container.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const percentage = (x / rect.width) * 100;
sliderSpring.target = percentage;
}
function startDragging(e: PointerEvent) {
if (
e.target === controlsWrapperElement
|| controlsWrapperElement?.contains(e.target as Node)
) {
e.stopPropagation();
return;
}
e.preventDefault();
isDragging = true;
handleMove(e);
}
$effect(() => {
if (isDragging) {
window.addEventListener('pointermove', handleMove);
const stop = () => (isDragging = false);
window.addEventListener('pointerup', stop);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', stop);
};
}
});
// Re-run line breaking when container resizes or dependencies change
$effect(() => {
// React on text and typography settings changes
const _text = comparisonStore.text;
const _weight = weightControl.value;
const _size = sizeControl.value;
const _height = heightControl.value;
if (container && measureCanvas && fontA && fontB) {
// Using rAF to ensure DOM is ready/stabilized
requestAnimationFrame(() => {
charComparison.breakIntoLines(container, measureCanvas);
});
}
});
$effect(() => {
if (typeof window === 'undefined') return;
const handleResize = () => {
if (container && measureCanvas) {
charComparison.breakIntoLines(container, measureCanvas);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
</script>
{#snippet renderLine(line: LineData, index: number)}
<div
bind:this={lineElements[index]}
class="relative flex w-full justify-center items-center whitespace-nowrap"
style:height={`${heightControl.value}em`}
style:line-height={`${heightControl.value}em`}
>
{#each line.text.split('') as char, charIndex}
{@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)}
<!--
Single Character Span
- Font Family switches based on `isPast`
- Transitions/Transforms provide the "morph" feel
-->
{#if fontA && fontB}
<CharacterSlot
{char}
{proximity}
{isPast}
weight={weightControl.value}
size={sizeControl.value}
fontAName={fontA.name}
fontBName={fontB.name}
/>
{/if}
{/each}
</div>
{/snippet}
{#if fontA && fontB}
<!-- Hidden canvas used for text measurement by the helper -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<div class="relative">
<div
bind:this={container}
role="slider"
tabindex="0"
aria-valuenow={Math.round(sliderPos)}
aria-label="Font comparison slider"
onpointerdown={startDragging}
class="
group relative w-full py-16 px-24 sm:py-24 sm:px-24 overflow-hidden
rounded-[2.5rem]
select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center
backdrop-blur-lg bg-gradient-to-br from-gray-100/70 via-white/50 to-gray-100/60
border border-gray-300/40
shadow-[inset_0_4px_12px_0_rgba(0,0,0,0.12),inset_0_2px_4px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(255,255,255,0.8)]
before:absolute before:inset-0 before:rounded-[2.5rem] before:p-[1px]
before:bg-gradient-to-br before:from-black/5 before:via-black/2 before:to-transparent
before:-z-10 before:blur-sm
"
>
<!-- Text Rendering Container -->
<div
class="
relative flex flex-col items-center gap-4
text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.15]
z-10 pointer-events-none text-center
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
"
style:perspective="1000px"
>
{#each charComparison.lines as line, lineIndex}
<div
class="relative w-full whitespace-nowrap"
style:height={`${heightControl.value}em`}
style:display="flex"
style:align-items="center"
style:justify-content="center"
>
{@render renderLine(line, lineIndex)}
</div>
{/each}
</div>
<SliderLine {sliderPos} {isDragging} />
</div>
<Labels {fontA} {fontB} {sliderPos} weight={weightControl.value} />
<!-- Since there're slider controls inside we put them outside the main one -->
<ControlsWrapper
bind:wrapper={controlsWrapperElement}
{sliderPos}
{isDragging}
bind:text={comparisonStore.text}
containerWidth={container?.clientWidth}
{weightControl}
{sizeControl}
{heightControl}
/>
</div>
{/if}

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props {
text: string;
fontName: string;
isAnimating: boolean;
onAnimationComplete?: () => void;
}
let { text, fontName, isAnimating, onAnimationComplete }: Props = $props();
// Split text into characters, preserving spaces
const chars = $derived(text.split('').map(c => c === ' ' ? '\u00A0' : c));
let completedCount = 0;
function handleTransitionEnd() {
completedCount++;
if (completedCount === chars.length) {
onAnimationComplete?.();
completedCount = 0;
}
}
</script>
<div class="relative inline-flex flex-wrap leading-tight">
{#each chars as char, i}
<span
class={cn(
'inline-block transition-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
isAnimating ? 'opacity-0 -translate-y-4 rotate-x-90' : 'opacity-100 translate-y-0 rotate-x-0',
)}
style:font-family={fontName}
style:transition-delay="{i * 25}ms"
ontransitionend={i === chars.length - 1 ? handleTransitionEnd : null}
>
{char}
</span>
{/each}
</div>
<style>
/* Necessary for the "Flip" feel */
div {
perspective: 1000px;
}
span {
transform-style: preserve-3d;
backface-visibility: hidden;
}
</style>

View File

@@ -0,0 +1,77 @@
<!--
Component: CharacterSlot
Renders a character with particular styling based on proximity, isPast, weight, fontAName, and fontBName.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props {
/**
* Displayed character
*/
char: string;
/**
* Proximity of the character to the center of the slider
*/
proximity: number;
/**
* Flag indicating whether character needed to be changed
*/
isPast: boolean;
/**
* Font weight of the character
*/
weight: number;
/**
* Font size of the character
*/
size: number;
/**
* Name of the font for the character after the change
*/
fontAName: string;
/**
* Name of the font for the character before the change
*/
fontBName: string;
}
let { char, proximity, isPast, weight, size, fontAName, fontBName }: Props = $props();
</script>
<span
class={cn(
'inline-block transition-all duration-300 ease-out will-change-transform',
isPast ? 'text-indigo-500' : 'text-neutral-950',
)}
style:font-family={isPast ? fontBName : fontAName}
style:font-weight={weight}
style:font-size={`${size}px`}
style:transform="
scale({1 + proximity * 0.3})
translateY({-proximity * 12}px)
rotateY({proximity * 25 * (isPast ? -1 : 1)}deg)
"
style:filter="brightness({1 + proximity * 0.2}) contrast({1 + proximity * 0.1})"
style:text-shadow={proximity > 0.5 ? '0 0 15px rgba(99,102,241,0.3)' : 'none'}
style:will-change={proximity > 0 ? 'transform, font-family, color' : 'auto'}
>
{char === ' ' ? '\u00A0' : char}
</span>
<style>
span {
/*
Optimize for performance and smooth transitions.
step-end logic is effectively handled by binary font switching in JS.
*/
transition:
font-family 0.15s ease-out,
color 0.2s ease-out,
transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-style: preserve-3d;
backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

View File

@@ -0,0 +1,174 @@
<!--
Component: ControlsWrapper
Wrapper for the controls of the slider.
- Input to change the text
- Three combo controls with inputs and sliders for font-weight, font-size, and line-height
-->
<script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { Input } from '$shared/shadcn/ui/input';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { ComboControlV2 } from '$shared/ui';
import { ExpandableWrapper } from '$shared/ui';
import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
import { Spring } from 'svelte/motion';
interface Props {
/**
* Ref
*/
wrapper?: HTMLDivElement | null;
/**
* Slider position
*/
sliderPos: number;
/**
* Whether slider is being dragged
*/
isDragging: boolean;
/**
* Text to display
*/
text: string;
/**
* Container width
*/
containerWidth: number;
/**
* Weight control
*/
weightControl: TypographyControl;
/**
* Size control
*/
sizeControl: TypographyControl;
/**
* Height control
*/
heightControl: TypographyControl;
}
let {
sliderPos,
isDragging,
wrapper = $bindable(null),
text = $bindable(),
containerWidth = 0,
weightControl,
sizeControl,
heightControl,
}: Props = $props();
let panelWidth = $derived(wrapper?.clientWidth ?? 0);
const margin = 24;
let side = $state<'left' | 'right'>('left');
// Unified active state for the entire wrapper
let isActive = $state(false);
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
const xSpring = new Spring(0, {
stiffness: 0.14, // Lower is slower
damping: 0.5, // Settle
});
const rotateSpring = new Spring(0, {
stiffness: 0.12,
damping: 0.55,
});
function handleInputFocus() {
isActive = true;
}
// Movement Logic
$effect(() => {
if (containerWidth === 0 || panelWidth === 0) return;
const sliderX = (sliderPos / 100) * containerWidth;
const buffer = 40;
const leftTrigger = margin + panelWidth + buffer;
const rightTrigger = containerWidth - (margin + panelWidth + buffer);
if (side === 'left' && sliderX < leftTrigger) {
side = 'right';
} else if (side === 'right' && sliderX > rightTrigger) {
side = 'left';
}
});
$effect(() => {
const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0;
if (containerWidth > 0 && panelWidth > 0) {
// On side change set the position and the rotation
xSpring.target = targetX;
rotateSpring.target = side === 'right' ? 3.5 : -3.5;
timeoutId = setTimeout(() => {
rotateSpring.target = 0;
}, 600);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
</script>
<div
class="absolute top-6 left-6 z-50 will-change-transform"
style:transform="
translateX({xSpring.current}px)
rotateZ({rotateSpring.current}deg)
"
>
<ExpandableWrapper
bind:element={wrapper}
bind:expanded={isActive}
disabled={isDragging}
aria-label="Font controls"
rotation={side === 'right' ? 'counterclockwise' : 'clockwise'}
class={cn(
'transition-opacity flex items-top gap-1.5',
panelWidth === 0 ? 'opacity-0' : 'opacity-100',
)}
>
{#snippet badge()}
<div
class={cn(
'animate-nudge relative transition-all',
side === 'left' ? 'order-2' : 'order-0',
isActive ? 'opacity-0' : 'opacity-100',
isDragging && 'opacity-80 grayscale-[0.2]',
)}
>
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
</div>
{/snippet}
{#snippet visibleContent()}
<div class="relative px-2 py-1">
<Input
bind:value={text}
disabled={isDragging}
onfocusin={handleInputFocus}
class={cn(
isActive
? 'h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50 text-slate-900'
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm font-medium focus-visible:ring-0 text-slate-900/50',
' placeholder:text-slate-400 selection:bg-indigo-100 flex-1 transition-all duration-350 w-56',
)}
placeholder="The quick brown fox..."
/>
</div>
{/snippet}
{#snippet hiddenContent()}
<div class="flex justify-between items-center-safe">
<ComboControlV2 control={weightControl} />
<ComboControlV2 control={sizeControl} />
<ComboControlV2 control={heightControl} />
</div>
{/snippet}
</ExpandableWrapper>
</div>

View File

@@ -0,0 +1,154 @@
<!--
Component: Labels
Displays labels for font selection in the comparison slider.
-->
<script lang="ts" generics="T extends UnifiedFont">
import {
FontVirtualList,
type UnifiedFont,
unifiedFontStore,
} from '$entities/Font';
import FontApplicator from '$entities/Font/ui/FontApplicator/FontApplicator.svelte';
import {
Content as SelectContent,
Item as SelectItem,
Root as SelectRoot,
Trigger as SelectTrigger,
} from '$shared/shadcn/ui/select';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
interface Props<T> {
/**
* First font to compare
*/
fontA: T;
/**
* Second font to compare
*/
fontB: T;
/**
* Position of the slider
*/
sliderPos: number;
weight: number;
}
let { fontA, fontB, sliderPos, weight }: Props<T> = $props();
const fontList = $derived(unifiedFontStore.fonts);
function selectFontA(font: UnifiedFont) {
if (!font) return;
comparisonStore.fontA = font;
}
function selectFontB(font: UnifiedFont) {
if (!font) return;
comparisonStore.fontB = font;
}
</script>
{#snippet fontSelector(
name: string,
id: string,
url: string,
fonts: UnifiedFont[],
selectFont: (font: UnifiedFont) => void,
align: 'start' | 'end',
)}
<div
class="z-50 pointer-events-auto"
onpointerdown={(e => e.stopPropagation())}
>
<SelectRoot type="single" disabled={!fontList.length}>
<SelectTrigger
class={cn(
'w-44 sm:w-52 h-9 border border-gray-300/40 bg-white/60 backdrop-blur-sm',
'px-3 rounded-lg transition-all flex items-center justify-between gap-2',
'font-mono text-[11px] tracking-tight font-medium text-gray-900',
'hover:bg-white/80 hover:border-gray-400/60 hover:shadow-sm',
)}
>
<div class="text-left flex-1 min-w-0">
<FontApplicator {name} {id} {url}>
{name}
</FontApplicator>
</div>
</SelectTrigger>
<SelectContent
class={cn(
'bg-white/95 backdrop-blur-xl border border-gray-300/50 shadow-xl',
'w-52 max-h-[280px] overflow-hidden rounded-lg',
)}
side="top"
{align}
sideOffset={8}
size="small"
>
<div class="p-1.5">
<FontVirtualList items={fonts} {weight}>
{#snippet children({ item: font })}
{@const handleClick = () => selectFont(font)}
<SelectItem
value={font.id}
class="data-[highlighted]:bg-gray-100 font-mono text-[11px] px-3 py-2.5 rounded-md cursor-pointer transition-colors"
onclick={handleClick}
>
<FontApplicator name={font.name} id={font.id} url={font.styles.regular!}>
{font.name}
</FontApplicator>
</SelectItem>
{/snippet}
</FontVirtualList>
</div>
</SelectContent>
</SelectRoot>
</div>
{/snippet}
<div class="absolute bottom-8 inset-x-6 sm:inset-x-12 flex justify-between items-end pointer-events-none z-20">
<div
class="flex flex-col gap-2 transition-all duration-500 items-start"
style:opacity={sliderPos < 20 ? 0 : 1}
style:transform="translateY({sliderPos < 20 ? '8px' : '0px'})"
>
<div class="flex items-center gap-2.5 px-1">
<div class="w-1.5 h-1.5 rounded-full bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.6)]"></div>
<div class="w-px h-2.5 bg-gray-300/60"></div>
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
ch_01
</span>
</div>
{@render fontSelector(
fontB.name,
fontB.id,
fontB.styles.regular!,
fontList,
selectFontB,
'start',
)}
</div>
<div
class="flex flex-col items-end text-right gap-2 transition-all duration-500"
style:opacity={sliderPos > 80 ? 0 : 1}
style:transform="translateY({sliderPos > 80 ? '8px' : '0px'})"
>
<div class="flex items-center gap-2.5 px-1">
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
ch_02
</span>
<div class="w-px h-2.5 bg-gray-300/60"></div>
<div class="w-1.5 h-1.5 rounded-full bg-gray-900 shadow-[0_0_6px_rgba(0,0,0,0.4)]"></div>
</div>
{@render fontSelector(
fontA.name,
fontA.id,
fontA.styles.regular!,
fontList,
selectFontA,
'end',
)}
</div>
</div>

View File

@@ -0,0 +1,70 @@
<!--
Component: SliderLine
Visual representation of the slider line.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props {
/**
* Position of the slider
*/
sliderPos: number;
/**
* Whether the slider is being dragged
*/
isDragging: boolean;
}
let { sliderPos, isDragging }: Props = $props();
</script>
<div
class="absolute inset-y-4 pointer-events-none -translate-x-1/2 z-50 transition-all duration-300 ease-out flex flex-col justify-center items-center"
style:left="{sliderPos}%"
>
<!-- We use part of lucide cursor svg icon as a handle -->
<svg
class={cn(
'transition-all relative duration-300 text-black/80 drop-shadow-sm',
isDragging ? 'w-12 h-12' : 'w-8 h-8',
)}
viewBox="0 0 24 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17 10h-1a4 4 0 0 1-4-4V0" />
<path d="M7 10h1a4 4 0 0 0 4-4V0" />
</svg>
<div
class={cn(
'relative h-full rounded-sm transition-all duration-500',
'bg-white/[0.03] backdrop-blur-md',
// These are the visible "edges" of the glass
'shadow-[0_0_40px_rgba(0,0,0,0.1)_inset_0_0_20px_rgba(255,255,255,0.1)]',
'shadow-[0_10px_30px_-10px_rgba(0,0,0,0.2),inset_0_1px_1px_rgba(255,255,255,0.4)]',
'rounded-full',
isDragging ? 'w-32' : 'w-16',
)}
>
</div>
<!-- We use part of lucide cursor svg icon as a handle -->
<svg
class={cn(
'transition-all relative duration-500 text-black/80 drop-shadow-sm',
isDragging ? 'w-12 h-12' : 'w-8 h-8',
)}
viewBox="0 0 24 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17 2h-1a4 4 0 0 0-4 4v6" />
<path d="M7 2h1a4 4 0 0 1 4 4v6" />
</svg>
</div>

View File

@@ -0,0 +1,3 @@
import ComparisonSlider from './ComparisonSlider/ComparisonSlider.svelte';
export { ComparisonSlider };

View File

@@ -1,3 +0,0 @@
import FiltersSidebar from './ui/FiltersSidebar.svelte';
export { FiltersSidebar };

View File

@@ -1,38 +0,0 @@
<script lang="ts">
import {
FilterControls,
Filters,
} from '$features/GetFonts';
import {
Content as SidebarContent,
Root as SidebarRoot,
} from '$shared/shadcn/ui/sidebar/index';
/**
* FiltersSidebar Component
*
* Main application sidebar widget. Contains filter controls and action buttons
* for font filtering operations. Organized into two sections:
*
* - Filters: Category-based filter groups (providers, subsets, categories)
* - Controls: Reset button for filter actions
*
* Features:
* - Loading indicator during font fetch operations
* - Empty state message when no fonts match filters
* - Error display for failed font operations
* - Responsive sidebar behavior via shadcn Sidebar component
*
* Uses Sidebar.Root from shadcn for responsive sidebar behavior including
* mobile drawer and desktop persistent sidebar modes.
*/
</script>
<SidebarRoot>
<SidebarContent class="p-2">
<!-- Filter groups -->
<Filters />
<!-- Action buttons -->
<FilterControls />
</SidebarContent>
</SidebarRoot>

View File

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

View File

@@ -0,0 +1,128 @@
<!--
Component: FontSearch
Provides a search input and filtration for fonts
-->
<script lang="ts">
import { unifiedFontStore } from '$entities/Font';
import {
FilterControls,
Filters,
filterManager,
mapManagerToParams,
} from '$features/GetFonts';
import { springySlideFade } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
IconButton,
SearchBar,
} from '$shared/ui';
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import {
Tween,
prefersReducedMotion,
} from 'svelte/motion';
import { type SlideParams } from 'svelte/transition';
interface Props {
/**
* Controllable flag to show/hide filters (bindable)
*/
showFilters?: boolean;
}
let { showFilters = $bindable(false) }: Props = $props();
onMount(() => {
/**
* The Pairing:
* We "plug" this manager into the global store.
* addBinding returns a function that removes this binding when the component unmounts.
*/
const unbind = unifiedFontStore.addBinding(() => mapManagerToParams(filterManager));
return unbind;
});
const transform = new Tween(
{ scale: 1, rotate: 0 },
{ duration: 250, easing: cubicOut },
);
const slideConfig = $derived<SlideParams>({
duration: prefersReducedMotion.current ? 0 : 200,
easing: cubicOut,
axis: 'y',
});
function toggleFilters() {
showFilters = !showFilters;
transform.set({ scale: 0.9, rotate: 2 }).then(() => {
transform.set({ scale: 1, rotate: 0 });
});
}
</script>
<div class="flex flex-col gap-3 relative">
<div class="relative">
<SearchBar
id="font-search"
class="w-full"
placeholder="search_typefaces..."
label="query_input"
bind:value={filterManager.queryValue}
/>
<div class="absolute right-4 top-1/2 translate-y-[-50%] z-10">
<div class="flex items-center gap-2">
<div class="w-px h-5 bg-gray-300/60"></div>
<div style:transform="scale({transform.current.scale})">
<IconButton onclick={toggleFilters}>
{#snippet icon({ className })}
<SlidersHorizontalIcon
class={cn(
className,
showFilters ? 'stroke-gray-900 stroke-3' : 'stroke-gray-500',
)}
/>
{/snippet}
</IconButton>
</div>
</div>
</div>
</div>
{#if showFilters}
<div
transition:springySlideFade|local={slideConfig}
class="will-change-[height,opacity] contain-layout overflow-hidden"
>
<div
class="
p-4 rounded-xl
backdrop-blur-md bg-white/80
border border-gray-300/50
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
"
>
<div class="flex items-center gap-2.5 mb-4 opacity-70">
<div class="w-1 h-1 rounded-full bg-gray-900"></div>
<div class="w-px h-2.5 bg-gray-400/50"></div>
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
filter_params
</span>
</div>
<div class="grid gap-3 grid-cols-[repeat(auto-fit,minmax(8em,14em))]">
<Filters />
</div>
<div class="mt-4 pt-4 border-t border-gray-300/40">
<FilterControls class="ml-auto" />
</div>
</div>
</div>
{/if}
</div>

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