Compare commits

...

15 Commits

Author SHA1 Message Date
Ilia Mashkov
2f45dc3620 feat(Controls): remove isLoading flag 2026-02-12 12:20:52 +03:00
Ilia Mashkov
d282448c53 feat(CharacterSlot): remove touch from characters 2026-02-12 12:20:06 +03:00
Ilia Mashkov
f2e8de1d1d feat(comparisonStore): add the check before loading 2026-02-12 12:19:11 +03:00
Ilia Mashkov
cee2a80c41 feat(FontListItem): delete springs to imrove performance 2026-02-12 11:24:16 +03:00
Ilia Mashkov
8b02333c01 feat(createVirtualizer): slidthly improve batching with version trigger 2026-02-12 11:23:27 +03:00
Ilia Mashkov
0e85851cfd fix(FontApplicator): remove unused prop 2026-02-12 11:21:04 +03:00
Ilia Mashkov
7dce7911c0 feat(FontSampler): remove backdrop filter since it's not being used and bad for performance 2026-02-12 11:16:01 +03:00
Ilia Mashkov
5e3929575d feat(FontApplicator): remove IntersectionObserver to ease the product, font applying logic is entirely in the VirtualList 2026-02-12 11:14:22 +03:00
Ilia Mashkov
d3297d519f feat(SampleList): add throttling to the checkPosition function 2026-02-12 11:11:22 +03:00
Ilia Mashkov
21d8273967 feat(VirtualList): add throttling 2026-02-12 10:32:25 +03:00
Ilia Mashkov
cdb2c355c0 fix: add types for env variables 2026-02-12 10:31:23 +03:00
Ilia Mashkov
3423eebf77 feat: install lenis 2026-02-12 10:31:02 +03:00
Ilia Mashkov
08d474289b chore: add export/import 2026-02-12 10:30:43 +03:00
Ilia Mashkov
2e6fc0e858 feat(throttle): add tohrottling util 2026-02-12 10:29:52 +03:00
Ilia Mashkov
173816b5c0 feat(lenis): add smooth scroll solution 2026-02-12 10:29:08 +03:00
19 changed files with 322 additions and 166 deletions

View File

@@ -67,6 +67,7 @@
"vitest-browser-svelte": "^2.0.1" "vitest-browser-svelte": "^2.0.1"
}, },
"dependencies": { "dependencies": {
"@tanstack/svelte-query": "^6.0.14" "@tanstack/svelte-query": "^6.0.14",
"lenis": "^1.3.17"
} }
} }

View File

@@ -35,3 +35,16 @@ declare module '*.jpg' {
const content: string; const content: string;
export default content; export default content;
} }
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly DEV: boolean;
readonly PROD: boolean;
readonly MODE: string;
// Add other env variables you use
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -2,11 +2,10 @@
Component: FontApplicator Component: FontApplicator
Loads fonts from fontshare with link tag Loads fonts from fontshare with link tag
- Loads font only if it's not already applied - Loads font only if it's not already applied
- Uses IntersectionObserver to detect when font is visible - Reacts to font load status to show/hide content
- Adds smooth transition when font appears - Adds smooth transition when font appears
--> -->
<script lang="ts"> <script lang="ts">
import { getFontUrl } from '$entities/Font/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 { prefersReducedMotion } from 'svelte/motion';
@@ -34,46 +33,23 @@ interface Props {
children?: Snippet; children?: Snippet;
} }
let { font, weight = 400, className, children }: Props = $props(); let {
let element: Element; font,
weight = 400,
className,
children,
}: Props = $props();
// Track if the user has actually scrolled this into view const status = $derived(
let hasEnteredViewport = $state(false); appliedFontsManager.getFontStatus(
const status = $derived(appliedFontsManager.getFontStatus(font.id, weight, font.features.isVariable)); font.id,
$effect(() => {
if (status === 'loaded' || status === 'error') {
hasEnteredViewport = true;
return;
}
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
hasEnteredViewport = true;
const url = getFontUrl(font, weight);
// Touch ensures it's in the queue.
// It's safe to call this even if VirtualList called it
// (Manager dedupes based on key)
if (url) {
appliedFontsManager.touch([{
id: font.id,
weight, weight,
name: font.name, font.features.isVariable,
url, ),
isVariable: font.features.isVariable, );
}]);
}
observer.unobserve(element); // The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
} const shouldReveal = $derived(status === 'loaded' || status === 'error');
});
if (element) observer.observe(element);
return () => observer.disconnect();
});
// The "Show" condition: Element is in view AND (Font is ready OR it errored out)
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded'));
const transitionClasses = $derived( const transitionClasses = $derived(
prefersReducedMotion.current prefersReducedMotion.current
@@ -83,12 +59,14 @@ const transitionClasses = $derived(
</script> </script>
<div <div
bind:this={element} style:font-family={shouldReveal
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'} ? `'${font.name}'`
: 'system-ui, -apple-system, sans-serif'}
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 && !prefersReducedMotion.current !shouldReveal
&& !prefersReducedMotion.current
&& 'opacity-50 scale-[0.95] blur-sm', && 'opacity-50 scale-[0.95] blur-sm',
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement !shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
shouldReveal && 'opacity-100 scale-100 blur-0', shouldReveal && 'opacity-100 scale-100 blur-0',

View File

@@ -1,11 +1,6 @@
<!--
Component: FontListItem
Displays a font item and manages its animations
-->
<script lang="ts"> <script lang="ts">
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 { Spring } from 'svelte/motion';
import { type UnifiedFont } from '../../model'; import { type UnifiedFont } from '../../model';
interface Props { interface Props {
@@ -31,51 +26,14 @@ interface Props {
children: Snippet<[font: UnifiedFont]>; children: Snippet<[font: UnifiedFont]>;
} }
const { font, isFullyVisible, isPartiallyVisible, proximity, children }: Props = $props(); const { font, children }: Props = $props();
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 <div
class={cn('pb-1 will-change-transform')} class={cn(
style:opacity={bloom.current} 'pb-1 will-change-transform transition-transform duration-200 ease-out',
style:transform=" 'hover:scale-[0.98]', // Simple CSS hover effect
scale({0.92 + (bloom.current * 0.08)}) )}
translateY({(1 - bloom.current) * 10}px)
"
> >
{@render children?.(font)} {@render children?.(font)}
</div> </div>

View File

@@ -53,7 +53,7 @@ const letterSpacing = $derived(controlManager.spacing);
class=" class="
w-full h-full rounded-xl sm:rounded-2xl w-full h-full rounded-xl sm:rounded-2xl
flex flex-col flex flex-col
backdrop-blur-md bg-background-80 bg-background-80
border border-border-muted border border-border-muted
shadow-[0_1px_3px_rgba(0,0,0,0.04)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]
relative overflow-hidden relative overflow-hidden

View File

@@ -0,0 +1,32 @@
import Lenis from 'lenis';
import {
getContext,
setContext,
} from 'svelte';
const LENIS_KEY = Symbol('lenis');
export function createLenisContext() {
let lenis = $state<Lenis | null>(null);
return {
get lenis() {
return lenis;
},
setLenis(instance: Lenis) {
lenis = instance;
},
destroyLenis() {
lenis?.destroy();
lenis = null;
},
};
}
export function setLenisContext(context: ReturnType<typeof createLenisContext>) {
setContext(LENIS_KEY, context);
}
export function getLenisContext() {
return getContext<ReturnType<typeof createLenisContext>>(LENIS_KEY);
}

View File

@@ -120,9 +120,11 @@ export function createVirtualizer<T>(
// 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());
// This derivation now tracks: count, measuredSizes, AND the data array itself // This derivation now tracks: count, _version (for measuredSizes updates), AND the data array itself
const offsets = $derived.by(() => { const offsets = $derived.by(() => {
const count = options.count; const count = options.count;
// Implicit dependency on version signal
const v = _version;
const result = new Float64Array(count); const result = new Float64Array(count);
let accumulated = 0; let accumulated = 0;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@@ -144,6 +146,8 @@ export function createVirtualizer<T>(
// We MUST read options.data here so Svelte knows to re-run // We MUST read options.data here so Svelte knows to re-run
// this derivation when the items array is replaced! // this derivation when the items array is replaced!
const { count, data } = options; const { count, data } = options;
// Implicit dependency
const v = _version;
if (count === 0 || containerHeight === 0 || !data) return []; if (count === 0 || containerHeight === 0 || !data) return [];
const overscan = options.overscan ?? 5; const overscan = options.overscan ?? 5;
@@ -318,6 +322,9 @@ export function createVirtualizer<T>(
let measurementBuffer: Record<number, number> = {}; let measurementBuffer: Record<number, number> = {};
let frameId: number | null = null; let frameId: number | null = null;
// Signal to trigger updates when mutating measuredSizes in place
let _version = $state(0);
/** /**
* Svelte action to measure individual item elements for dynamic height support. * Svelte action to measure individual item elements for dynamic height support.
* *
@@ -334,18 +341,25 @@ export function createVirtualizer<T>(
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
if (!isNaN(index)) { if (!isNaN(index)) {
// Accessing the version ensures we have the latest state if needed,
// though here we just read the raw object.
const oldHeight = measuredSizes[index]; const oldHeight = measuredSizes[index];
// Only update if the height difference is significant (> 0.5px) // 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) { if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
// Stuff the measurement into a temporary buffer // Stuff the measurement into a temporary buffer to batch updates
measurementBuffer[index] = height; measurementBuffer[index] = height;
// 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(() => {
measuredSizes = { ...measuredSizes, ...measurementBuffer }; // Mutation in place for performance
// Reset the buffer Object.assign(measuredSizes, measurementBuffer);
// Trigger reactivity
_version += 1;
// Reset buffer
measurementBuffer = {}; measurementBuffer = {};
frameId = null; frameId = null;
}); });

View File

@@ -42,3 +42,9 @@ export {
type ResponsiveManager, type ResponsiveManager,
responsiveManager, responsiveManager,
} from './createResponsiveManager/createResponsiveManager.svelte'; } from './createResponsiveManager/createResponsiveManager.svelte';
export {
createLenisContext,
getLenisContext,
setLenisContext,
} from './createScrollContext/createScrollContext.svelte';

View File

@@ -5,6 +5,7 @@ export {
createDebouncedState, createDebouncedState,
createEntityStore, createEntityStore,
createFilter, createFilter,
createLenisContext,
createPersistentStore, createPersistentStore,
createResponsiveManager, createResponsiveManager,
createTypographyControl, createTypographyControl,
@@ -13,11 +14,13 @@ export {
type EntityStore, type EntityStore,
type Filter, type Filter,
type FilterModel, type FilterModel,
getLenisContext,
type LineData, type LineData,
type PersistentStore, type PersistentStore,
type Property, type Property,
type ResponsiveManager, type ResponsiveManager,
responsiveManager, responsiveManager,
setLenisContext,
type TypographyControl, type TypographyControl,
type VirtualItem, type VirtualItem,
type Virtualizer, type Virtualizer,
@@ -27,10 +30,12 @@ export {
export { export {
buildQueryString, buildQueryString,
clampNumber, clampNumber,
debounce,
getDecimalPlaces, getDecimalPlaces,
roundToStepPrecision, roundToStepPrecision,
smoothScroll, smoothScroll,
splitArray, splitArray,
throttle,
} from './utils'; } from './utils';
export { springySlideFade } from './transitions'; export { springySlideFade } from './transitions';

View File

@@ -13,3 +13,4 @@ export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision'; export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
export { smoothScroll } from './smoothScroll/smoothScroll'; export { smoothScroll } from './smoothScroll/smoothScroll';
export { splitArray } from './splitArray/splitArray'; export { splitArray } from './splitArray/splitArray';
export { throttle } from './throttle/throttle';

View File

@@ -0,0 +1,32 @@
/**
* Throttle function execution to a maximum frequency.
*
* @param fn Function to throttle.
* @param wait Maximum time between function calls.
* @returns Throttled function.
*/
export function throttle<T extends (...args: any[]) => any>(
fn: T,
wait: number,
): (...args: Parameters<T>) => void {
let lastCall = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
const now = Date.now();
const timeSinceLastCall = now - lastCall;
if (timeSinceLastCall >= wait) {
lastCall = now;
fn(...args);
} else {
// Schedule for end of wait period
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
lastCall = Date.now();
fn(...args);
timeoutId = null;
}, wait - timeSinceLastCall);
}
};
}

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import {
createLenisContext,
setLenisContext,
} from '$shared/lib';
import Lenis from 'lenis';
import type { LenisOptions } from 'lenis';
import { onMount } from 'svelte';
interface Props {
children?: import('svelte').Snippet;
// Lenis options - all optional with sensible defaults
duration?: number;
easing?: (t: number) => number;
smoothWheel?: boolean;
wheelMultiplier?: number;
touchMultiplier?: number;
infinite?: boolean;
orientation?: 'vertical' | 'horizontal';
gestureOrientation?: 'vertical' | 'horizontal' | 'both';
}
let {
children,
duration = 1.2,
easing = t => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel = true,
wheelMultiplier = 1,
touchMultiplier = 2,
infinite = false,
orientation = 'vertical',
gestureOrientation = 'vertical',
}: Props = $props();
const lenisContext = createLenisContext();
setLenisContext(lenisContext);
onMount(() => {
const lenisOptions: LenisOptions = {
duration,
easing,
smoothWheel,
wheelMultiplier,
touchMultiplier,
infinite,
orientation,
gestureOrientation,
// Prevent jitter with virtual scroll
prevent: (node: HTMLElement) => {
// Don't smooth scroll inside elements with data-lenis-prevent
return node.hasAttribute('data-lenis-prevent');
},
};
const lenis = new Lenis(lenisOptions);
lenisContext.setLenis(lenis);
// RAF loop
function raf(time: number) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
// Expose to window for debugging (only in dev)
if (import.meta.env?.DEV) {
(window as any).lenis = lenis;
}
return () => {
lenisContext.destroyLenis();
};
});
</script>
{@render children?.()}

View File

@@ -10,6 +10,7 @@
--> -->
<script lang="ts" generics="T"> <script lang="ts" generics="T">
import { createVirtualizer } from '$shared/lib'; import { createVirtualizer } from '$shared/lib';
import { throttle } from '$shared/lib/utils';
import { ScrollArea } from '$shared/shadcn/ui/scroll-area'; 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';
@@ -154,10 +155,20 @@ $effect(() => {
} }
}); });
const throttledVisibleChange = throttle((visibleItems: T[]) => {
onVisibleItemsChange?.(visibleItems);
}, 150); // 150ms debounce
const throttledNearBottom = throttle((lastVisibleIndex: number) => {
onNearBottom?.(lastVisibleIndex);
}, 200); // 200ms debounce
$effect(() => { $effect(() => {
const visibleItems = virtualizer.items.map(item => items[item.index]); const visibleItems = virtualizer.items.map(item => items[item.index]);
onVisibleItemsChange?.(visibleItems); throttledVisibleChange(visibleItems);
});
$effect(() => {
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items) // Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
if (virtualizer.items.length > 0 && onNearBottom) { if (virtualizer.items.length > 0 && onNearBottom) {
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1]; const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
@@ -165,7 +176,7 @@ $effect(() => {
const itemsRemaining = items.length - lastVisibleItem.index; const itemsRemaining = items.length - lastVisibleItem.index;
if (itemsRemaining <= 5) { if (itemsRemaining <= 5) {
onNearBottom(lastVisibleItem.index); throttledNearBottom(lastVisibleItem.index);
} }
} }
}); });
@@ -180,6 +191,7 @@ $effect(() => {
data-index={item.index} data-index={item.index}
class="absolute top-0 left-0 w-full will-change-transform" class="absolute top-0 left-0 w-full will-change-transform"
style:transform="translateY({item.start}px)" style:transform="translateY({item.start}px)"
data-lenis-prevent
> >
{#if item.index < items.length} {#if item.index < items.length}
{@render children({ {@render children({
@@ -212,7 +224,6 @@ $effect(() => {
data-index={item.index} data-index={item.index}
class="absolute top-0 left-0 w-full will-change-transform" class="absolute top-0 left-0 w-full will-change-transform"
style:transform="translateY({item.start}px)" style:transform="translateY({item.start}px)"
animate:flip={{ delay: 0, duration: 300, easing: quintOut }}
> >
{#if item.index < items.length} {#if item.index < items.length}
{@render children({ {@render children({

View File

@@ -13,4 +13,5 @@ export { default as SearchBar } from './SearchBar/SearchBar.svelte';
export { default as Section } from './Section/Section.svelte'; export { default as Section } from './Section/Section.svelte';
export { default as Skeleton } from './Skeleton/Skeleton.svelte'; export { default as Skeleton } from './Skeleton/Skeleton.svelte';
export { default as Slider } from './Slider/Slider.svelte'; export { default as Slider } from './Slider/Slider.svelte';
export { default as SmoothScroll } from './SmoothScroll/SmoothScroll.svelte';
export { default as VirtualList } from './VirtualList/VirtualList.svelte'; export { default as VirtualList } from './VirtualList/VirtualList.svelte';

View File

@@ -78,8 +78,6 @@ class ComparisonStore {
return; return;
} }
this.#fontsReady = false;
const weight = this.#typography.weight; const weight = this.#typography.weight;
const size = this.#typography.renderedSize; const size = this.#typography.renderedSize;
const fontAName = this.#fontA?.name; const fontAName = this.#fontA?.name;
@@ -87,11 +85,25 @@ class ComparisonStore {
if (!fontAName || !fontBName) return; if (!fontAName || !fontBName) return;
const fontAString = `${weight} ${size}px "${fontAName}"`;
const fontBString = `${weight} ${size}px "${fontBName}"`;
// Check if already loaded to avoid UI flash
const isALoaded = document.fonts.check(fontAString);
const isBLoaded = document.fonts.check(fontBString);
if (isALoaded && isBLoaded) {
this.#fontsReady = true;
return;
}
this.#fontsReady = false;
try { try {
// Step 1: Load fonts into memory // Step 1: Load fonts into memory
await Promise.all([ await Promise.all([
document.fonts.load(`${weight} ${size}px "${fontAName}"`), document.fonts.load(fontAString),
document.fonts.load(`${weight} ${size}px "${fontBName}"`), document.fonts.load(fontBString),
]); ]);
// Step 2: Wait for browser to be ready to render // Step 2: Wait for browser to be ready to render

View File

@@ -3,8 +3,6 @@
Renders a character with particular styling based on proximity, isPast, weight, fontAName, and fontBName. Renders a character with particular styling based on proximity, isPast, weight, fontAName, and fontBName.
--> -->
<script lang="ts"> <script lang="ts">
import { appliedFontsManager } from '$entities/Font';
import { getFontUrl } from '$entities/Font/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '../../../model'; import { comparisonStore } from '../../../model';
@@ -28,33 +26,6 @@ let { char, proximity, isPast }: Props = $props();
const fontA = $derived(comparisonStore.fontA); const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB); const fontB = $derived(comparisonStore.fontB);
const typography = $derived(comparisonStore.typography); const typography = $derived(comparisonStore.typography);
$effect(() => {
if (!fontA || !fontB) {
return;
}
const urlA = getFontUrl(fontA, typography.weight);
const urlB = getFontUrl(fontB, typography.weight);
if (!urlA || !urlB) {
return;
}
appliedFontsManager.touch([{
id: fontA.id,
weight: typography.weight,
name: fontA.name,
url: urlA,
isVariable: fontA.features.isVariable,
}, {
id: fontB.id,
weight: typography.weight,
name: fontB.name,
url: urlB,
isVariable: fontB.features.isVariable,
}]);
});
</script> </script>
{#if fontA && fontB} {#if fontA && fontB}
@@ -67,13 +38,18 @@ $effect(() => {
style:font-weight={typography.weight} style:font-weight={typography.weight}
style:font-size={`${typography.renderedSize}px`} style:font-size={`${typography.renderedSize}px`}
style:transform=" style:transform="
scale({1 + proximity * 0.3}) scale({1 + proximity * 0.3}) translateY({-proximity * 12}px) rotateY({proximity *
translateY({-proximity * 12}px) 25 *
rotateY({proximity * 25 * (isPast ? -1 : 1)}deg) (isPast ? -1 : 1)}deg)
" "
style:filter="brightness({1 + proximity * 0.2}) contrast({1 + proximity * 0.1})" style:filter="brightness({1 + proximity * 0.2}) contrast({1 +
style:text-shadow={proximity > 0.5 ? '0 0 15px rgba(99,102,241,0.3)' : 'none'} proximity * 0.1})"
style:will-change={proximity > 0 ? 'transform, font-family, color' : 'auto'} 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} {char === ' ' ? '\u00A0' : char}
</span> </span>

View File

@@ -20,11 +20,16 @@ interface Props {
container: HTMLElement; container: HTMLElement;
} }
let { sliderPos, isDragging, typographyControls = $bindable<HTMLDivElement | null>(null), container }: Props = $props(); let {
sliderPos,
isDragging,
typographyControls = $bindable<HTMLDivElement | null>(null),
container,
}: Props = $props();
const fontA = $derived(comparisonStore.fontA); const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB); const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
const weight = $derived(comparisonStore.typography.weight); const weight = $derived(comparisonStore.typography.weight);
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
@@ -41,8 +46,18 @@ $effect(() => {
return; return;
} }
const fontAConfig = { id: fontA.id, name: fontA.name, url: fontAUrl, weight: weight }; const fontAConfig = {
const fontBConfig = { id: fontB.id, name: fontB.name, url: fontBUrl, weight: weight }; id: fontA.id,
name: fontA.name,
url: fontAUrl,
weight: weight,
};
const fontBConfig = {
id: fontB.id,
name: fontB.name,
url: fontBUrl,
weight: weight,
};
appliedFontsManager.touch([fontAConfig, fontBConfig]); appliedFontsManager.touch([fontAConfig, fontBConfig]);
}); });
@@ -73,7 +88,6 @@ $effect(() => {
{/snippet} {/snippet}
</Drawer> </Drawer>
{:else} {:else}
{#if !isLoading}
<div class="absolute top-3 sm:top-6 left-3 sm:left-6 z-50"> <div class="absolute top-3 sm:top-6 left-3 sm:left-6 z-50">
<TypographyControls <TypographyControls
{sliderPos} {sliderPos}
@@ -82,11 +96,8 @@ $effect(() => {
containerWidth={container?.clientWidth} containerWidth={container?.clientWidth}
/> />
</div> </div>
{/if}
{#if !isLoading}
<div class="absolute bottom-3 sm:bottom-6 md:bottom-8 inset-x-3 sm:inset-x-6 md:inset-x-12"> <div class="absolute bottom-3 sm:bottom-6 md:bottom-8 inset-x-3 sm:inset-x-6 md:inset-x-12">
<SelectComparedFonts {sliderPos} /> <SelectComparedFonts {sliderPos} />
</div> </div>
{/if} {/if}
{/if}

View File

@@ -15,6 +15,7 @@ import {
TypographyMenu, TypographyMenu,
controlManager, controlManager,
} from '$features/SetupFont'; } from '$features/SetupFont';
import { throttle } from '$shared/lib/utils';
let text = $state('The quick brown fox jumps over the lazy dog...'); let text = $state('The quick brown fox jumps over the lazy dog...');
let wrapper = $state<HTMLDivElement | null>(null); let wrapper = $state<HTMLDivElement | null>(null);
@@ -23,7 +24,9 @@ let innerHeight = $state(0);
// Is the component above the middle of the viewport? // Is the component above the middle of the viewport?
let isAboveMiddle = $state(false); let isAboveMiddle = $state(false);
const isLoading = $derived(unifiedFontStore.isFetching || unifiedFontStore.isLoading); const isLoading = $derived(
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
);
/** /**
* Load more fonts by moving to the next page * Load more fonts by moving to the next page
@@ -62,14 +65,14 @@ const displayRange = $derived.by(() => {
return `Showing ${loadedCount} of ${total} fonts`; return `Showing ${loadedCount} of ${total} fonts`;
}); });
function checkPosition() { const checkPosition = throttle(() => {
if (!wrapper) return; if (!wrapper) return;
const rect = wrapper.getBoundingClientRect(); const rect = wrapper.getBoundingClientRect();
const viewportMiddle = innerHeight / 2; const viewportMiddle = innerHeight / 2;
isAboveMiddle = rect.top < viewportMiddle; isAboveMiddle = rect.top < viewportMiddle;
} }, 100);
</script> </script>
<svelte:window <svelte:window
@@ -95,7 +98,12 @@ function checkPosition() {
proximity, proximity,
index, index,
})} })}
<FontListItem {font} {isFullyVisible} {isPartiallyVisible} {proximity}> <FontListItem
{font}
{isFullyVisible}
{isPartiallyVisible}
{proximity}
>
<FontSampler {font} bind:text {index} /> <FontSampler {font} bind:text {index} />
</FontListItem> </FontListItem>
{/snippet} {/snippet}

View File

@@ -2459,6 +2459,7 @@ __metadata:
dprint: "npm:^0.50.2" dprint: "npm:^0.50.2"
jsdom: "npm:^27.4.0" jsdom: "npm:^27.4.0"
lefthook: "npm:^2.0.13" lefthook: "npm:^2.0.13"
lenis: "npm:^1.3.17"
oxlint: "npm:^1.35.0" oxlint: "npm:^1.35.0"
playwright: "npm:^1.57.0" playwright: "npm:^1.57.0"
storybook: "npm:^10.1.11" storybook: "npm:^10.1.11"
@@ -2849,6 +2850,24 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lenis@npm:^1.3.17":
version: 1.3.17
resolution: "lenis@npm:1.3.17"
peerDependencies:
"@nuxt/kit": ">=3.0.0"
react: ">=17.0.0"
vue: ">=3.0.0"
peerDependenciesMeta:
"@nuxt/kit":
optional: true
react:
optional: true
vue:
optional: true
checksum: 10c0/c268da36d5711677b239c7d173bc52775276df08f86f7f89f305c4e02ba4055d8c50ea69125d16c94bb1e1999ccd95f654237d11c6647dc5fdf63aa90515fbfb
languageName: node
linkType: hard
"lightningcss-android-arm64@npm:1.30.2": "lightningcss-android-arm64@npm:1.30.2":
version: 1.30.2 version: 1.30.2
resolution: "lightningcss-android-arm64@npm:1.30.2" resolution: "lightningcss-android-arm64@npm:1.30.2"