Merge pull request 'fixes/mobile-comparator' (#25) from fixes/mobile-comparator into main
All checks were successful
Workflow / build (push) Successful in 1m5s
Workflow / publish (push) Successful in 33s

Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
2026-02-10 16:21:43 +00:00
13 changed files with 203 additions and 90 deletions

View File

@@ -49,8 +49,6 @@ onMount(async () => {
} }
fontsReady = true; fontsReady = true;
}); });
$inspect(fontsReady);
</script> </script>
<svelte:head> <svelte:head>

View File

@@ -112,7 +112,7 @@ export class AppliedFontsManager {
const internalName = `f_${config.id}`; const internalName = `f_${config.id}`;
const weightRange = config.isVariable ? '100 900' : `${config.weight}`; const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
const font = new FontFace(config.name, `url(${config.url})`, { const font = new FontFace(config.name, `url(${config.url}) format('woff2')`, {
weight: weightRange, weight: weightRange,
style: 'normal', style: 'normal',
display: 'swap', display: 'swap',

View File

@@ -6,30 +6,24 @@
- 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';
import { appliedFontsManager } from '../../model'; import {
type UnifiedFont,
appliedFontsManager,
} from '../../model';
interface Props { interface Props {
/** /**
* Font name to set * Applied font
*/ */
name: string; font: UnifiedFont;
/**
* Font id to load
*/
id: string;
/** */
url: string;
/** /**
* Font weight * Font weight
*/ */
weight?: number; weight?: number;
/**
* Variable font flag
*/
isVariable?: boolean;
/** /**
* Additional classes * Additional classes
*/ */
@@ -40,12 +34,12 @@ interface Props {
children?: Snippet; children?: Snippet;
} }
let { name, id, url, weight = 400, isVariable = false, className, children }: Props = $props(); let { font, 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
let hasEnteredViewport = $state(false); let hasEnteredViewport = $state(false);
const status = $derived(appliedFontsManager.getFontStatus(id, weight, isVariable)); const status = $derived(appliedFontsManager.getFontStatus(font.id, weight, font.features.isVariable));
$effect(() => { $effect(() => {
if (status === 'loaded' || status === 'error') { if (status === 'loaded' || status === 'error') {
@@ -56,17 +50,19 @@ $effect(() => {
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) { if (entries[0].isIntersecting) {
hasEnteredViewport = true; hasEnteredViewport = true;
const url = getFontUrl(font, weight);
// Touch ensures it's in the queue. // Touch ensures it's in the queue.
// It's safe to call this even if VirtualList called it // It's safe to call this even if VirtualList called it
// (Manager dedupes based on key) // (Manager dedupes based on key)
if (url) {
appliedFontsManager.touch([{ appliedFontsManager.touch([{
id, id: font.id,
weight, weight,
name, name: font.name,
url, url,
isVariable, isVariable: font.features.isVariable,
}]); }]);
}
observer.unobserve(element); observer.unobserve(element);
} }
@@ -88,7 +84,7 @@ const transitionClasses = $derived(
<div <div
bind:this={element} bind:this={element}
style:font-family={shouldReveal ? `'${name}'` : 'system-ui, -apple-system, sans-serif'} style:font-family={shouldReveal ? `'${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

View File

@@ -84,8 +84,7 @@ const letterSpacing = $derived(controlManager.spacing);
</div> </div>
<div class="p-4 sm:p-5 md:p-8 relative z-10"> <div class="p-4 sm:p-5 md:p-8 relative z-10">
<!-- TODO: Fix this ! --> <FontApplicator {font} weight={fontWeight}>
<FontApplicator id={font.id} name={font.name} url={font.styles.regular!}>
<ContentEditable <ContentEditable
bind:text={text} bind:text={text}
{...restProps} {...restProps}

View File

@@ -109,7 +109,7 @@ function calculateScale(index: number): number | string {
{#snippet ComboControl()} {#snippet ComboControl()}
<div <div
class={cn( class={cn(
'flex gap-4 sm:p-4 rounded-xl transition-all duration-300', 'flex gap-4 sm:py-4 sm:px-1 rounded-xl transition-all duration-300',
'backdrop-blur-md', 'backdrop-blur-md',
orientation === 'horizontal' ? 'flex-row items-end w-full' : 'flex-col items-center h-full', orientation === 'horizontal' ? 'flex-row items-end w-full' : 'flex-col items-center h-full',
className, className,

View File

@@ -3,6 +3,7 @@
Animated wrapper for content that can be expanded and collapsed. Animated wrapper for content that can be expanded and collapsed.
--> -->
<script lang="ts"> <script lang="ts">
import { debounce } from '$shared/lib/utils';
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 { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
@@ -38,6 +39,10 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
* Optional badge to render * Optional badge to render
*/ */
badge?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>; badge?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
/**
* Callback for when the element's size changes
*/
onResize?: (rect: DOMRectReadOnly) => void;
/** /**
* Rotation animation direction * Rotation animation direction
* @default 'clockwise' * @default 'clockwise'
@@ -56,6 +61,7 @@ let {
visibleContent, visibleContent,
hiddenContent, hiddenContent,
badge, badge,
onResize,
rotation = 'clockwise', rotation = 'clockwise',
class: className = '', class: className = '',
containerClassName = '', containerClassName = '',
@@ -64,7 +70,7 @@ let {
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null); let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
export const xSpring = new Spring(0, { const xSpring = new Spring(0, {
stiffness: 0.14, // Lower is slower stiffness: 0.14, // Lower is slower
damping: 0.5, // Settle damping: 0.5, // Settle
}); });
@@ -79,7 +85,7 @@ const scaleSpring = new Spring(1, {
damping: 0.65, damping: 0.65,
}); });
export const rotateSpring = new Spring(0, { const rotateSpring = new Spring(0, {
stiffness: 0.12, stiffness: 0.12,
damping: 0.55, damping: 0.55,
}); });
@@ -107,6 +113,9 @@ function handleKeyDown(e: KeyboardEvent) {
} }
} }
// Create debounced recize callback
const debouncedResize = debounce((entry: ResizeObserverEntry) => onResize?.(entry.contentRect), 50);
// Elevation and scale on activation // Elevation and scale on activation
$effect(() => { $effect(() => {
if (expanded && !disabled) { if (expanded && !disabled) {
@@ -149,6 +158,21 @@ $effect(() => {
expanded = false; expanded = false;
} }
}); });
// Use an effect to watch the element's actual physical size
$effect(() => {
if (!element) return;
const observer = new ResizeObserver(entries => {
const entry = entries[0];
if (entry) {
debouncedResize(entry);
}
});
observer.observe(element);
return () => observer.disconnect();
});
</script> </script>
<div <div
@@ -158,7 +182,7 @@ $effect(() => {
role="button" role="button"
tabindex={0} tabindex={0}
class={cn( class={cn(
'will-change-transform duration-300', 'will-change-[transform, width, height] duration-300',
disabled ? 'pointer-events-none' : 'pointer-events-auto', disabled ? 'pointer-events-none' : 'pointer-events-auto',
className, className,
)} )}

View File

@@ -34,6 +34,7 @@ class ComparisonStore {
#fontB = $state<UnifiedFont | undefined>(); #fontB = $state<UnifiedFont | undefined>();
#sampleText = $state('The quick brown fox jumps over the lazy dog'); #sampleText = $state('The quick brown fox jumps over the lazy dog');
#isRestoring = $state(true); #isRestoring = $state(true);
#fontsReady = $state(false);
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography'); #typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
constructor() { constructor() {
@@ -49,6 +50,7 @@ class ComparisonStore {
// If we already have a selection, do nothing // If we already have a selection, do nothing
if (this.#fontA && this.#fontB) { if (this.#fontA && this.#fontB) {
this.#checkFontsLoaded();
return; return;
} }
@@ -66,6 +68,48 @@ class ComparisonStore {
}); });
} }
/**
* Checks if fonts are actually loaded in the browser at current weight.
* Uses CSS Font Loading API to prevent FOUT.
*/
async #checkFontsLoaded() {
if (!('fonts' in document)) {
this.#fontsReady = true;
return;
}
this.#fontsReady = false;
const weight = this.#typography.weight;
const size = this.#typography.renderedSize;
const fontAName = this.#fontA?.name;
const fontBName = this.#fontB?.name;
if (!fontAName || !fontBName) return;
try {
// Step 1: Load fonts into memory
await Promise.all([
document.fonts.load(`${weight} ${size}px "${fontAName}"`),
document.fonts.load(`${weight} ${size}px "${fontBName}"`),
]);
// Step 2: Wait for browser to be ready to render
await document.fonts.ready;
// Step 3: Force a layout/paint cycle (critical!)
await new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve); // Double rAF ensures paint completes
});
});
this.#fontsReady = true;
} catch (error) {
console.warn('[ComparisonStore] Font loading failed:', error);
setTimeout(() => this.#fontsReady = true, 1000);
}
}
/** /**
* Restore state from persistent storage * Restore state from persistent storage
*/ */
@@ -141,13 +185,12 @@ class ComparisonStore {
* Check if both fonts are selected * Check if both fonts are selected
*/ */
get isReady() { get isReady() {
return !!this.#fontA && !!this.#fontB; return !!this.#fontA && !!this.#fontB && this.#fontsReady;
} }
get isLoading() { get isLoading() {
return this.#isRestoring; return this.#isRestoring || !this.#fontsReady;
} }
/** /**
* Public initializer (optional, as constructor starts it) * Public initializer (optional, as constructor starts it)
* Kept for compatibility if manual re-init is needed * Kept for compatibility if manual re-init is needed

View File

@@ -167,10 +167,6 @@ $effect(() => {
{char} {char}
{proximity} {proximity}
{isPast} {isPast}
weight={typography.weight}
size={typography.renderedSize}
fontAName={fontA.name}
fontBName={fontB.name}
/> />
{/if} {/if}
{/each} {/each}
@@ -202,7 +198,9 @@ $effect(() => {
> >
<!-- Text Rendering Container --> <!-- Text Rendering Container -->
{#if isLoading} {#if isLoading}
<div out:fade={{ duration: 300 }}>
<Loader size={24} /> <Loader size={24} />
</div>
{:else} {:else}
<div <div
class=" class="

View File

@@ -3,7 +3,10 @@
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';
interface Props { interface Props {
/** /**
@@ -18,35 +21,51 @@ interface Props {
* Flag indicating whether character needed to be changed * Flag indicating whether character needed to be changed
*/ */
isPast: boolean; 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(); let { char, proximity, isPast }: Props = $props();
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
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}
<span <span
class={cn( class={cn(
'inline-block transition-all duration-300 ease-out will-change-transform', 'inline-block transition-all duration-300 ease-out will-change-transform',
isPast ? 'text-indigo-500' : 'text-neutral-950', isPast ? 'text-indigo-500' : 'text-neutral-950',
)} )}
style:font-family={isPast ? fontBName : fontAName} style:font-family={isPast ? fontB.name : fontA.name}
style:font-weight={weight} style:font-weight={typography.weight}
style:font-size={`${size}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) translateY({-proximity * 12}px)
@@ -58,6 +77,7 @@ let { char, proximity, isPast, weight, size, fontAName, fontBName }: Props = $pr
> >
{char === ' ' ? '\u00A0' : char} {char === ' ' ? '\u00A0' : char}
</span> </span>
{/if}
<style> <style>
span { span {

View File

@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { appliedFontsManager } from '$entities/Font';
import { getFontUrl } from '$entities/Font/lib';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { import {
@@ -23,8 +25,27 @@ let { sliderPos, isDragging, typographyControls = $bindable<HTMLDivElement | nul
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 isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
const weight = $derived(comparisonStore.typography.weight);
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
$effect(() => {
if (!fontA || !fontB) {
return;
}
const fontAUrl = getFontUrl(fontA, weight);
const fontBUrl = getFontUrl(fontB, weight);
if (!fontAUrl || !fontBUrl) {
return;
}
const fontAConfig = { 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]);
});
</script> </script>
{#if responsive.isMobile} {#if responsive.isMobile}

View File

@@ -70,7 +70,7 @@ function selectFontB(font: UnifiedFont) {
)} )}
> >
<div class="text-left flex-1 min-w-0"> <div class="text-left flex-1 min-w-0">
<FontApplicator name={font.name} id={font.id} {url}> <FontApplicator {font} weight={typography.weight}>
{font.name} {font.name}
</FontApplicator> </FontApplicator>
</div> </div>
@@ -95,9 +95,8 @@ function selectFontB(font: UnifiedFont) {
onclick={handleClick} onclick={handleClick}
> >
<FontApplicator <FontApplicator
name={fontListItem.name} font={fontListItem}
id={fontListItem.id} weight={typography.weight}
url={getFontUrl(fontListItem, typography.weight) ?? ''}
> >
{fontListItem.name} {fontListItem.name}
</FontApplicator> </FontApplicator>

View File

@@ -4,6 +4,8 @@
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { cubicOut } from 'svelte/easing';
import { fade } from 'svelte/transition';
interface Props { interface Props {
/** /**
@@ -27,6 +29,7 @@ let { sliderPos, isDragging }: Props = $props();
)} )}
style:left="{sliderPos}%" style:left="{sliderPos}%"
style:will-change={isDragging ? 'left' : 'auto'} style:will-change={isDragging ? 'left' : 'auto'}
in:fade={{ duration: 300, delay: 150, easing: cubicOut }}
> >
<!-- We use part of lucide cursor svg icon as a handle --> <!-- We use part of lucide cursor svg icon as a handle -->
<svg <svg

View File

@@ -14,6 +14,7 @@ import {
import { comparisonStore } from '$widgets/ComparisonSlider/model'; import { comparisonStore } from '$widgets/ComparisonSlider/model';
import AArrowUP from '@lucide/svelte/icons/a-arrow-up'; import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
import { type Orientation } from 'bits-ui'; import { type Orientation } from 'bits-ui';
import { untrack } from 'svelte';
import { Spring } from 'svelte/motion'; import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -74,7 +75,10 @@ function handleInputFocus() {
// Movement Logic // Movement Logic
$effect(() => { $effect(() => {
if (containerWidth === 0 || panelWidth === 0 || staticPosition) return; if (containerWidth === 0 || panelWidth === 0 || staticPosition) {
return;
}
const sliderX = (sliderPos / 100) * containerWidth; const sliderX = (sliderPos / 100) * containerWidth;
const buffer = 40; const buffer = 40;
const leftTrigger = margin + panelWidth + buffer; const leftTrigger = margin + panelWidth + buffer;
@@ -88,21 +92,29 @@ $effect(() => {
}); });
$effect(() => { $effect(() => {
const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0; // Trigger only when side changes
const currentSide = side;
untrack(() => {
if (containerWidth > 0 && panelWidth > 0) { if (containerWidth > 0 && panelWidth > 0) {
const targetX = currentSide === 'right'
? containerWidth - panelWidth - margin * 2
: 0;
// On side change set the position and the rotation // On side change set the position and the rotation
xSpring.target = targetX; xSpring.target = targetX;
rotateSpring.target = side === 'right' ? 3.5 : -3.5; rotateSpring.target = currentSide === 'right' ? 3.5 : -3.5;
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
rotateSpring.target = 0; rotateSpring.target = 0;
}, 600); }, 600);
} }
});
return () => { return () => {
if (timeoutId) { if (timeoutId) clearTimeout(timeoutId);
clearTimeout(timeoutId);
}
}; };
}); });
</script> </script>