Compare commits

..

11 Commits

16 changed files with 222 additions and 131 deletions

View File

@@ -117,6 +117,8 @@
}
body {
@apply bg-background text-foreground;
font-family: 'Karla', system-ui, sans-serif;
font-optical-sizing: auto;
}
}

View File

@@ -26,6 +26,13 @@ let { children } = $props();
<link rel="icon" href={favicon} />
<link rel="preconnect" href="https://api.fontshare.com" />
<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>
<div id="app-root">

View File

@@ -6,9 +6,9 @@
- Adds smooth transition when font appears
-->
<script lang="ts">
import { motion } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion';
import { appliedFontsManager } from '../../model';
interface Props {
@@ -59,7 +59,7 @@ const status = $derived(appliedFontsManager.getFontStatus(id, weight, false));
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
const transitionClasses = $derived(
motion.reduced
prefersReducedMotion.current
? 'transition-none' // Disable CSS transitions if motion is reduced
: 'transition-all duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]',
);
@@ -71,8 +71,9 @@ const transitionClasses = $derived(
class={cn(
transitionClasses,
// 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 && motion.reduced && 'opacity-0', // Still hide until font is ready, but no movement
!shouldReveal && !prefersReducedMotion.current
&& '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',
className,
)}

View File

@@ -4,8 +4,8 @@
-->
<script lang="ts">
import { Badge } from '$shared/shadcn/ui/badge';
import { Checkbox } from '$shared/shadcn/ui/checkbox';
import { Label } from '$shared/shadcn/ui/label';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Spring } from 'svelte/motion';
import {
type UnifiedFont,
selectedFontsStore,
@@ -17,68 +17,118 @@ interface Props {
* Object with information about font
*/
font: UnifiedFont;
/**
* Is element fully visible
*/
isVisible: boolean;
/**
* From 0 to 1
*/
proximity: number;
}
const { font }: Props = $props();
const { font, isVisible, proximity }: Props = $props();
const handleChange = (checked: boolean) => {
if (checked) {
selectedFontsStore.addOne(font);
} else {
selectedFontsStore.removeOne(font.id);
}
};
let selected = $state(selectedFontsStore.has(font.id));
let timeoutId = $state<NodeJS.Timeout | null>(null);
const selected = $derived(selectedFontsStore.has(font.id));
// 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 = isVisible ? 1 : 0;
});
$effect(() => {
selected = selectedFontsStore.has(font.id);
});
$effect(() => {
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
function handleClick() {
animateSelection();
selected ? selectedFontsStore.removeOne(font.id) : selectedFontsStore.addOne(font);
}
function animateSelection() {
scale.target = 0.98;
timeoutId = setTimeout(() => {
scale.target = 1;
}, 150);
}
</script>
<div class="pb-1">
<Label
for={font.id}
class="
w-full hover:bg-accent/50 flex items-start gap-3 rounded-lg border border-transparent p-3
active:scale-[0.98] active:transition-transform active:duration-75
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">
<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
class={cn('pb-1 will-change-transform')}
style:opacity={bloom.current}
style:transform="
scale({0.92 + (bloom.current * 0.08)})
translateY({(1 - bloom.current) * 10}px)
"
>
<div style:transform={`scale(${scale.current})`}>
<div
class={cn(
'w-full hover:bg-accent/50 flex items-start gap-3 rounded-lg border border-transparent p-3',
'active:transition-transform active:duration-150',
'border dark:border-slate-800',
'bg-white/10 border-white/20',
isVisible && 'bg-white/40 border-white/40',
selected && 'ring-2 ring-indigo-600 ring-inset bg-indigo-50/50 hover:bg-indigo-50',
)}
role="button"
tabindex="0"
onmousedown={(e => {
// Prevent browser focus-jump
if (e.currentTarget === document.activeElement) return;
e.preventDefault();
handleClick();
})}
onkeydown={(e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
})}
>
<div class="w-full">
<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>
<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

@@ -1,8 +1,9 @@
<script lang="ts">
import { appliedFontsManager } from '$entities/Font';
import { controlManager } from '$features/SetupFont';
import { Input } from '$shared/shadcn/ui/input';
import { ComparisonSlider } from '$widgets/ComparisonSlider/ui';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { displayedFontsStore } from '../../model';
import PairSelector from '../PairSelector/PairSelector.svelte';
@@ -20,16 +21,17 @@ $effect(() => {
{#if hasAnyPairs}
<div class="flex flex-col gap-4">
<div class="flex flex-row gap-4">
<Input bind:value={displayedText} />
<PairSelector />
</div>
{#if fontA && fontB}
<ComparisonSlider
fontA={fontA}
fontB={fontB}
bind:text={displayedText}
/>
<div in:fly={{ y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 }}>
<ComparisonSlider
fontA={fontA}
fontB={fontB}
bind:text={displayedText}
/>
</div>
{/if}
</div>
{/if}

View File

@@ -11,7 +11,7 @@ import {
</script>
<FontVirtualList items={fontshareStore.fonts}>
{#snippet children({ item: font })}
<FontListItem {font} />
{#snippet children({ item: font, isVisible, proximity })}
<FontListItem {font} {isVisible} {proximity} />
{/snippet}
</FontVirtualList>

View File

@@ -21,3 +21,11 @@ export const DEFAULT_LINE_HEIGHT = 1.5;
export const MIN_LINE_HEIGHT = 1;
export const MAX_LINE_HEIGHT = 2;
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

@@ -3,15 +3,19 @@ import { createTypographyControlManager } from '../../lib';
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LETTER_SPACING_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LETTER_SPACING,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LETTER_SPACING,
MIN_LINE_HEIGHT,
} from '../const/const';
@@ -49,6 +53,17 @@ const controlData: ControlModel[] = [
decreaseLabel: 'Decrease 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);

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

@@ -49,10 +49,6 @@ export function createCharacterComparison(
* Matches the Tailwind breakpoints used in the component.
*/
function getFontSize() {
// const fontSize = size();
// if (fontSize) {
// return fontSize;
// }
if (typeof window === 'undefined') {
return 64;
}

View File

@@ -14,6 +14,10 @@ export interface VirtualItem {
end: number;
/** Unique key for the item (for Svelte's {#each} keying) */
key: string | number;
/** Whether the item is currently visible in the viewport */
isVisible: boolean;
/** Proximity of the item to the center of the viewport */
proximity: number;
}
/**
@@ -136,6 +140,8 @@ export function createVirtualizer<T>(
let endIdx = startIdx;
const viewportEnd = scrollOffset + containerHeight;
const viewportCenter = scrollOffset + (containerHeight / 2);
while (endIdx < count && offsets[endIdx] < viewportEnd) {
endIdx++;
}
@@ -144,13 +150,31 @@ export function createVirtualizer<T>(
const end = Math.min(count, endIdx + overscan);
const result: VirtualItem[] = [];
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 isVisible = itemStart < viewportEnd && itemEnd > scrollOffset;
// Fully visible
const isVisible = 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({
index: i,
start: offsets[i],
size: measuredSizes[i] ?? options.estimateSize(i),
end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)),
start: itemStart,
size: itemSize,
end: itemEnd,
key: options.getItemKey?.(i) ?? i,
isVisible,
proximity,
});
}
@@ -207,21 +231,23 @@ export function createVirtualizer<T>(
const index = parseInt(node.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
if (!isNaN(index) && measuredSizes[index] !== height) {
// 1. Stuff the measurement into a temporary buffer
measurementBuffer[index] = height;
if (!isNaN(index)) {
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;
// 2. Schedule a single update for the next animation frame
if (frameId === null) {
frameId = requestAnimationFrame(() => {
// 3. Update the state once for all collected measurements
// We use spread to trigger a single fine-grained update
measuredSizes = { ...measuredSizes, ...measurementBuffer };
// 4. Reset the buffer
measurementBuffer = {};
frameId = null;
});
// Schedule a single update for the next animation frame
if (frameId === null) {
frameId = requestAnimationFrame(() => {
measuredSizes = { ...measuredSizes, ...measurementBuffer };
// Reset the buffer
measurementBuffer = {};
frameId = null;
});
}
}
}
});

View File

@@ -19,5 +19,4 @@ export {
type VirtualizerOptions,
} from './helpers';
export { motion } from './accessibility/motion.svelte';
export { splitArray } from './utils';

View File

@@ -9,7 +9,6 @@
-->
<script lang="ts">
import type { Filter } from '$shared/lib';
import { motion } from '$shared/lib';
import { Badge } from '$shared/shadcn/ui/badge';
import { buttonVariants } from '$shared/shadcn/ui/button';
import { Checkbox } from '$shared/shadcn/ui/checkbox';
@@ -20,6 +19,7 @@ import {
import { Label } from '$shared/shadcn/ui/label';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cubicOut } from 'svelte/easing';
import { prefersReducedMotion } from 'svelte/motion';
import { slide } from 'svelte/transition';
interface PropertyFilterProps {
@@ -37,7 +37,7 @@ let isOpen = $state(true);
// Animation config respects user preferences - zero duration if reduced motion enabled
// Local modifier prevents animation on initial render, only animates user interactions
const slideConfig = $derived({
duration: motion.reduced ? 0 : 250,
duration: prefersReducedMotion.current ? 0 : 250,
easing: cubicOut,
});

View File

@@ -70,7 +70,14 @@ function handleInputClick() {
bind:value={value}
onkeydown={handleKeyDown}
onclick={handleInputClick}
class="flex flex-row flex-1"
class="
h-20 w-full md:text-2xl backdrop-blur-sm bg-white/60 dark:bg-slate-900/40
border-2 border-slate-200/50 dark:border-slate-700/50
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/50
hover:bg-white/70 dark:hover:bg-slate-900/50 text-slate-900 dark:text-slate-100
placeholder:text-slate-400 px-6 py-4 rounded-2xl transition-all duration-300
font-medium
"
/>
</div>
{/snippet}
@@ -83,7 +90,7 @@ function handleInputClick() {
e.preventDefault();
}
})}
class="w-(--bits-popover-anchor-width) min-w-(--bits-popover-anchor-width)"
class="w-(--bits-popover-anchor-width) min-w-(--bits-popover-anchor-width) md:rounded-2xl"
>
{@render children?.({ id: contentId })}
</PopoverContent>

View File

@@ -52,7 +52,7 @@ interface Props {
*
* @template T - The type of items in the list
*/
children: Snippet<[{ item: T; index: number }]>;
children: Snippet<[{ item: T; index: number; isVisible: boolean; proximity: number }]>;
}
let { items, itemHeight = 80, overscan = 5, class: className, onVisibleItemsChange, children }:
@@ -76,8 +76,13 @@ $effect(() => {
class={cn(
'relative overflow-auto rounded-md bg-background',
'h-150 w-full',
'scroll-smooth',
className,
)}
onfocusin={(e => {
// Prevent the browser from jumping the scroll when an inner element gets focus
e.preventDefault();
})}
>
<div
style:height="{virtualizer.totalSize}px"
@@ -92,7 +97,12 @@ $effect(() => {
class="absolute top-0 left-0 w-full"
style:transform="translateY({item.start}px)"
>
{@render children({ item: items[item.index], index: item.index })}
{@render children({
item: items[item.index],
index: item.index,
isVisible: item.isVisible,
proximity: item.proximity,
})}
</div>
{/each}
</div>

View File

@@ -171,7 +171,7 @@ $effect(() => {
'animate-nudge relative transition-all',
side === 'left' ? 'order-2' : 'order-0',
isActive ? 'opacity-0' : 'opacity-100',
isDragging && 'opacity-40 grayscale-[0.2]',
isDragging && 'opacity-80 grayscale-[0.2]',
)}
>
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
@@ -183,7 +183,7 @@ $effect(() => {
isActive
? 'bg-white/95 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)]',
isDragging && 'opacity-40 grayscale-[0.2]',
isDragging && 'opacity-80 grayscale-[0.2]',
)}
style:backdrop-filter="blur(24px)"
>