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;
});
$inspect(fontsReady);
</script>
<svelte:head>

View File

@@ -112,7 +112,7 @@ export class AppliedFontsManager {
const internalName = `f_${config.id}`;
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,
style: 'normal',
display: 'swap',

View File

@@ -6,30 +6,24 @@
- Adds smooth transition when font appears
-->
<script lang="ts">
import { getFontUrl } from '$entities/Font/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion';
import { appliedFontsManager } from '../../model';
import {
type UnifiedFont,
appliedFontsManager,
} from '../../model';
interface Props {
/**
* Font name to set
* Applied font
*/
name: string;
/**
* Font id to load
*/
id: string;
/** */
url: string;
font: UnifiedFont;
/**
* Font weight
*/
weight?: number;
/**
* Variable font flag
*/
isVariable?: boolean;
/**
* Additional classes
*/
@@ -40,12 +34,12 @@ interface Props {
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;
// Track if the user has actually scrolled this into view
let hasEnteredViewport = $state(false);
const status = $derived(appliedFontsManager.getFontStatus(id, weight, isVariable));
const status = $derived(appliedFontsManager.getFontStatus(font.id, weight, font.features.isVariable));
$effect(() => {
if (status === 'loaded' || status === 'error') {
@@ -56,17 +50,19 @@ $effect(() => {
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,
id: font.id,
weight,
name,
name: font.name,
url,
isVariable,
isVariable: font.features.isVariable,
}]);
}
observer.unobserve(element);
}
@@ -88,7 +84,7 @@ const transitionClasses = $derived(
<div
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(
transitionClasses,
// If reduced motion is on, we skip the transform/blur entirely

View File

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

View File

@@ -109,7 +109,7 @@ function calculateScale(index: number): number | string {
{#snippet ComboControl()}
<div
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',
orientation === 'horizontal' ? 'flex-row items-end w-full' : 'flex-col items-center h-full',
className,

View File

@@ -3,6 +3,7 @@
Animated wrapper for content that can be expanded and collapsed.
-->
<script lang="ts">
import { debounce } from '$shared/lib/utils';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing';
@@ -38,6 +39,10 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
* Optional badge to render
*/
badge?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
/**
* Callback for when the element's size changes
*/
onResize?: (rect: DOMRectReadOnly) => void;
/**
* Rotation animation direction
* @default 'clockwise'
@@ -56,6 +61,7 @@ let {
visibleContent,
hiddenContent,
badge,
onResize,
rotation = 'clockwise',
class: className = '',
containerClassName = '',
@@ -64,7 +70,7 @@ let {
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
damping: 0.5, // Settle
});
@@ -79,7 +85,7 @@ const scaleSpring = new Spring(1, {
damping: 0.65,
});
export const rotateSpring = new Spring(0, {
const rotateSpring = new Spring(0, {
stiffness: 0.12,
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
$effect(() => {
if (expanded && !disabled) {
@@ -149,6 +158,21 @@ $effect(() => {
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>
<div
@@ -158,7 +182,7 @@ $effect(() => {
role="button"
tabindex={0}
class={cn(
'will-change-transform duration-300',
'will-change-[transform, width, height] duration-300',
disabled ? 'pointer-events-none' : 'pointer-events-auto',
className,
)}

View File

@@ -34,6 +34,7 @@ class ComparisonStore {
#fontB = $state<UnifiedFont | undefined>();
#sampleText = $state('The quick brown fox jumps over the lazy dog');
#isRestoring = $state(true);
#fontsReady = $state(false);
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
constructor() {
@@ -49,6 +50,7 @@ class ComparisonStore {
// If we already have a selection, do nothing
if (this.#fontA && this.#fontB) {
this.#checkFontsLoaded();
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
*/
@@ -141,13 +185,12 @@ class ComparisonStore {
* Check if both fonts are selected
*/
get isReady() {
return !!this.#fontA && !!this.#fontB;
return !!this.#fontA && !!this.#fontB && this.#fontsReady;
}
get isLoading() {
return this.#isRestoring;
return this.#isRestoring || !this.#fontsReady;
}
/**
* Public initializer (optional, as constructor starts it)
* Kept for compatibility if manual re-init is needed

View File

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

View File

@@ -3,7 +3,10 @@
Renders a character with particular styling based on proximity, isPast, weight, fontAName, and fontBName.
-->
<script lang="ts">
import { appliedFontsManager } from '$entities/Font';
import { getFontUrl } from '$entities/Font/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '../../../model';
interface Props {
/**
@@ -18,35 +21,51 @@ interface Props {
* 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();
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>
<span
{#if fontA && fontB}
<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:font-family={isPast ? fontB.name : fontA.name}
style:font-weight={typography.weight}
style:font-size={`${typography.renderedSize}px`}
style:transform="
scale({1 + proximity * 0.3})
translateY({-proximity * 12}px)
@@ -55,9 +74,10 @@ let { char, proximity, isPast, weight, size, fontAName, fontBName }: Props = $pr
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>
</span>
{/if}
<style>
span {

View File

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

View File

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

View File

@@ -4,6 +4,8 @@
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { cubicOut } from 'svelte/easing';
import { fade } from 'svelte/transition';
interface Props {
/**
@@ -27,6 +29,7 @@ let { sliderPos, isDragging }: Props = $props();
)}
style:left="{sliderPos}%"
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 -->
<svg

View File

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