chore(ComparisonSlider): remove widget in favor of ComparisonView

This commit is contained in:
Ilia Mashkov
2026-03-02 22:17:46 +03:00
parent 0c3dcc243a
commit 6cd325ce38
12 changed files with 0 additions and 1261 deletions

View File

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

View File

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

View File

@@ -1,224 +0,0 @@
import {
type UnifiedFont,
fetchFontsByIds,
unifiedFontStore,
} from '$entities/Font';
import {
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
createTypographyControlManager,
} from '$features/SetupFont';
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);
#fontsReady = $state(false);
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
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) {
this.#checkFontsLoaded();
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();
}
});
});
}
/**
* 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;
}
const weight = this.#typography.weight;
const size = this.#typography.renderedSize;
const fontAName = this.#fontA?.name;
const fontBName = this.#fontB?.name;
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 {
// Step 1: Load fonts into memory
await Promise.all([
document.fonts.load(fontAString),
document.fonts.load(fontBString),
]);
// 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
*/
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 typography() {
return this.#typography;
}
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 && this.#fontsReady;
}
get isLoading() {
return this.#isRestoring || !this.#fontsReady;
}
/**
* 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();
}
}
resetAll() {
this.#fontA = undefined;
this.#fontB = undefined;
storage.clear();
this.#typography.reset();
}
}
export const comparisonStore = new ComparisonStore();

View File

@@ -1,136 +0,0 @@
<script module>
import Providers from '$shared/lib/storybook/Providers.svelte';
import { defineMeta } from '@storybook/addon-svelte-csf';
import ComparisonSlider from './ComparisonSlider.svelte';
const { Story } = defineMeta({
title: 'Widgets/ComparisonSlider',
component: ComparisonSlider,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'A multiline text comparison slider that morphs between two fonts. Features character-level morphing, responsive layout, and performance optimization using offscreen canvas. Switch between slider mode (interactive text) and settings mode (controls panel).',
},
story: { inline: false },
},
layout: 'fullscreen',
},
argTypes: {
// This component uses internal stores, so no direct props to document
},
});
</script>
<script lang="ts">
import type { UnifiedFont } from '$entities/Font';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
// Mock fonts for testing - using web-safe fonts that are always available
const mockArial: UnifiedFont = {
id: 'arial',
name: 'Arial',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 1,
},
features: {
isVariable: false,
},
};
const mockGeorgia: UnifiedFont = {
id: 'georgia',
name: 'Georgia',
provider: 'google',
category: 'serif',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 2,
},
features: {
isVariable: false,
},
};
const mockVerdana: UnifiedFont = {
id: 'verdana',
name: 'Verdana',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 3,
},
features: {
isVariable: false,
},
};
const mockCourier: UnifiedFont = {
id: 'courier-new',
name: 'Courier New',
provider: 'google',
category: 'monospace',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 10,
},
features: {
isVariable: false,
},
};
</script>
<Story name="Default">
{@const _ = (comparisonStore.fontA = mockArial, comparisonStore.fontB = mockGeorgia)}
<Providers>
<div class="min-h-screen flex items-center justify-center p-8">
<div class="w-full max-w-5xl">
<ComparisonSlider />
</div>
</div>
</Providers>
</Story>
<Story name="Loading State">
{@const _ = (comparisonStore.fontA = undefined, comparisonStore.fontB = undefined)}
<Providers>
<div class="min-h-screen flex items-center justify-center p-8">
<div class="w-full max-w-5xl">
<ComparisonSlider />
</div>
</div>
</Providers>
</Story>

View File

@@ -1,278 +0,0 @@
<!--
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.
Modes:
- Slider mode: Text centered in 1st plan, controls hidden
- Settings mode: Text moves to left (2nd plan), controls appear on right (1st plan)
-->
<script lang="ts">
import {
type CharacterComparison,
type LineData,
type ResponsiveManager,
createCharacterComparison,
createPerspectiveManager,
} from '$shared/lib';
import {
Loader,
PerspectivePlan,
} from '$shared/ui';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { getContext } from 'svelte';
import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition';
import CharacterSlot from './components/CharacterSlot.svelte';
import Controls from './components/Controls.svelte';
import SliderLine from './components/SliderLine.svelte';
// Pair of fonts to compare
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(
comparisonStore.isLoading || !comparisonStore.isReady,
);
let container = $state<HTMLElement>();
let measureCanvas = $state<HTMLCanvasElement>();
let isDragging = $state(false);
const typography = $derived(comparisonStore.typography);
const responsive = getContext<ResponsiveManager>('responsive');
/**
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
* Manages line breaking and character state based on fonts and container dimensions.
*/
const charComparison: CharacterComparison = createCharacterComparison(
() => comparisonStore.text,
() => fontA,
() => fontB,
() => typography.weight,
() => typography.renderedSize,
);
/**
* Perspective manager for back/front state toggling:
* - Front (slider mode): Text fully visible, interactive
* - Back (settings mode): Text blurred, scaled down, shifted left, controls visible
*
* Uses simple boolean flag for smooth transitions between states.
*/
const perspective = createPerspectiveManager({
parallaxIntensity: 0, // Disabled to not interfere with slider
horizontalOffset: 0, // Text shifts left when in back position
scaleStep: 0.5,
blurStep: 2,
depthStep: 100,
opacityStep: 0.3,
});
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) {
e.preventDefault();
isDragging = true;
handleMove(e);
}
function togglePerspective() {
perspective.toggle();
}
/**
* Sets the multiplier for slider font size based on the current responsive state
*/
$effect(() => {
if (!responsive) {
return;
}
switch (true) {
case responsive.isMobile:
typography.multiplier = 0.5;
break;
case responsive.isTablet:
typography.multiplier = 0.75;
break;
case responsive.isDesktop:
typography.multiplier = 1;
break;
default:
typography.multiplier = 1;
break;
}
});
$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 = typography.weight;
const _size = typography.renderedSize;
const _height = typography.height;
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);
});
const isInSettingsMode = $derived(perspective.isBack);
</script>
{#snippet renderLine(line: LineData, index: number)}
{@const pos = sliderPos}
{@const element = lineElements[index]}
<div
bind:this={lineElements[index]}
class="relative flex w-full justify-center items-center whitespace-nowrap"
style:height={`${typography.height}em`}
style:line-height={`${typography.height}em`}
>
{#each line.text.split('') as char, index}
{@const { proximity, isPast } = charComparison.getCharState(index, pos, element, container)}
<!--
Single Character Span
- Font Family switches based on `isPast`
- Transitions/Transforms provide the "morph" feel
-->
{#if fontA && fontB}
<CharacterSlot {char} {proximity} {isPast} />
{/if}
{/each}
</div>
{/snippet}
<!-- Hidden canvas used for text measurement by the helper -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<!-- Main container with perspective and fixed height -->
<div
class="
relative w-full flex justify-center items-center
perspective-distant perspective-origin-center transform-3d
rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
min-h-72 sm:min-h-96 lg:min-h-128
backdrop-blur-lg bg-linear-to-br from-gray-200/40 via-white/80 to-gray-100/60
border border-border-muted
shadow-[inset_2px_0_8px_rgba(0,0,0,0.05)]
before:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-px
before:bg-linear-to-br before:from-black/5 before:via-black/2 before:to-transparent
before:-z-10 before:blur-sm
overflow-hidden
[perspective:1500px] perspective-origin-center transform-3d
"
>
{#if isLoading}
<div out:fade={{ duration: 300 }}>
<Loader size={24} />
</div>
{:else}
<!-- Text Plan -->
<PerspectivePlan
manager={perspective}
class="absolute inset-0 flex justify-center origin-right w-full h-full"
>
<div
bind:this={container}
role="slider"
tabindex="0"
aria-valuenow={Math.round(sliderPos)}
aria-label="Font comparison slider"
onpointerdown={startDragging}
class="
relative w-full h-full flex justify-center
py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24
select-none touch-none cursor-ew-resize
"
>
<div
class="
relative flex flex-col items-center gap-3 sm:gap-4
text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl: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)]
my-auto
"
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300 }}
>
{#each charComparison.lines as line, lineIndex}
<div
class="relative w-full whitespace-nowrap"
style:height={`${typography.height}em`}
style:display="flex"
style:align-items="center"
style:justify-content="center"
>
{@render renderLine(line, lineIndex)}
</div>
{/each}
</div>
<!-- Slider Line - visible in slider mode -->
{#if !isInSettingsMode}
<SliderLine {sliderPos} {isDragging} />
{/if}
</div>
</PerspectivePlan>
<Controls
class="absolute inset-y-0 left-0 transition-all duration-150"
handleToggle={togglePerspective}
/>
{/if}
</div>

View File

@@ -1,73 +0,0 @@
<!--
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';
import { comparisonStore } from '../../../model';
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;
}
let { char, proximity, isPast }: Props = $props();
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const typography = $derived(comparisonStore.typography);
</script>
{#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 ? 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) 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>
{/if}
<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

@@ -1,131 +0,0 @@
<!--
Component: Controls
Uses SidebarMenu to show ComparisonSlider's controls:
- List of fonts to pick
- Input to change text
- Sliders for font-weight, font-width, line-height
-->
<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 { SidebarMenu } from '$shared/ui';
import { Label } from '$shared/ui';
import Drawer from '$shared/ui/Drawer/Drawer.svelte';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { getContext } from 'svelte';
import FontList from './FontList.svelte';
import ToggleMenuButton from './ToggleMenuButton.svelte';
import TypographyControls from './TypographyControls.svelte';
interface Props {
/**
* Additional class
*/
class?: string;
/**
* Handler to trigger when menu opens/closes
*/
handleToggle?: () => void;
}
let { class: className, handleToggle }: Props = $props();
let visible = $state(false);
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const typography = $derived(comparisonStore.typography);
let menuWrapper = $state<HTMLElement | null>(null);
const responsive = getContext<ResponsiveManager>('responsive');
$effect(() => {
if (!fontA || !fontB) {
return;
}
const weight = typography.weight;
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}
<Drawer>
{#snippet trigger({ onClick })}
<div class={cn('absolute bottom-0.5 left-1/2 -translate-x-1/2 z-50')}>
<ToggleMenuButton bind:isActive={visible} {onClick} />
</div>
{/snippet}
{#snippet content({ className })}
<div class="w-full pt-4 grid grid-cols-[1fr_min-content_1fr] gap-2 items-center justify-center">
<div class="uppercase text-indigo-500 ml-auto font-semibold tracking-tight text-[0.825rem] whitespace-nowrap">
{fontB?.name ?? 'typeface_01'}
</div>
<div class="w-px h-2.5 bg-gray-400/50"></div>
<div class="uppercase text-neutral-950 mr-auto font-semibold tracking-tight text-[0.825rem] whitespace-nowrap">
{fontA?.name ?? 'typeface_02'}
</div>
</div>
<div class={cn(className, 'flex flex-col gap-2 h-[60vh]')}>
<Label class="mb-2" text="Available Fonts" align="center" />
<div class="h-full overflow-hidden">
<FontList />
</div>
<Label class="mb-2" text="Typography Controls" align="center" />
<div class="mx-4 flex-shrink-0">
<TypographyControls />
</div>
</div>
{/snippet}
</Drawer>
{:else}
<SidebarMenu
class={cn(
'w-96 flex flex-col h-full pl-4 lg:pl-6 py-4 sm:py-6 sm:pt-12 gap-0 sm:gap-0 pointer-events-auto overflow-hidden',
'relative h-full transition-all duration-700 ease-out',
className,
)}
bind:visible
bind:wrapper={menuWrapper}
onClickOutside={handleToggle}
>
{#snippet action()}
<!-- Always-visible mode switch -->
<div class={cn('absolute top-2 left-0 z-50', visible && 'w-full')}>
<ToggleMenuButton bind:isActive={visible} onClick={handleToggle} />
</div>
{/snippet}
<Label class="mb-2 mr-4 lg:mr-6" text="Available Fonts" align="left" />
<div class="mb-2 h-2/3 overflow-hidden">
<FontList />
</div>
<Label class="mb-2 mr-4 lg:mr-6" text="Typography Controls" align="left" />
<div class="mr-4 sm:mr-6">
<TypographyControls />
</div>
</SidebarMenu>
{/if}

View File

@@ -1,215 +0,0 @@
<!--
Component: FontList
A scrollable list of fonts with dual selection buttons for fontA and fontB.
-->
<script lang="ts">
import {
FontVirtualList,
type UnifiedFont,
} from '$entities/Font';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { cubicOut } from 'svelte/easing';
import { draw } from 'svelte/transition';
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const typography = $derived(comparisonStore.typography);
/**
* Select a font as fontA (right slot - compare_to)
*/
function selectFontA(font: UnifiedFont) {
comparisonStore.fontA = font;
}
/**
* Select a font as fontB (left slot - compare_from)
*/
function selectFontB(font: UnifiedFont) {
comparisonStore.fontB = font;
}
/**
* Check if a font is selected as fontA
*/
function isFontA(font: UnifiedFont): boolean {
return fontA?.id === font.id;
}
/**
* Check if a font is selected as fontB
*/
function isFontB(font: UnifiedFont): boolean {
return fontB?.id === font.id;
}
</script>
{#snippet rightBrackets(className?: string)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={cn(
'lucide lucide-focus-icon lucide-focus right-0 top-0 absolute',
className,
)}
>
<path
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
d="M17 3h2a2 2 0 0 1 2 2v2"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={cn(
'lucide lucide-focus-icon lucide-focus right-0 bottom-0 absolute',
className,
)}
>
<path
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
d="M21 17v2a2 2 0 0 1-2 2h-2"
/>
</svg>
{/snippet}
{#snippet leftBrackets(className?: string)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={cn(
'lucide lucide-focus-icon lucide-focus left-0 top-0 absolute',
className,
)}
>
<path
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
d="M3 7V5a2 2 0 0 1 2-2h2"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={cn(
'lucide lucide-focus-icon lucide-focus left-0 bottom-0 absolute',
className,
)}
>
<path
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
d="M7 21H5a2 2 0 0 1-2-2v-2"
/>
</svg>
{/snippet}
{#snippet brackets(
renderLeft?: boolean,
renderRight?: boolean,
className?: string,
)}
{#if renderLeft}
{@render leftBrackets(className)}
{/if}
{#if renderRight}
{@render rightBrackets(className)}
{/if}
{/snippet}
<div class="flex flex-col h-full min-h-0 bg-transparent">
<div class="flex-1 min-h-0">
<FontVirtualList
weight={typography.weight}
itemHeight={36}
class="bg-transparent h-full"
>
{#snippet children({ item: font })}
{@const isSelectedA = isFontA(font)}
{@const isSelectedB = isFontB(font)}
{@const isEither = isSelectedA || isSelectedB}
{@const isBoth = isSelectedA && isSelectedB}
{@const handleSelectFontA = () => selectFontA(font)}
{@const handleSelectFontB = () => selectFontB(font)}
<div class="group relative flex w-auto h-[36px] border-b border-black/[0.03] overflow-hidden sm:mr-4 lg:mr-6">
<div
class={cn(
'absolute inset-0 flex items-center justify-center z-20 pointer-events-none transition-all duration-500 cubic-bezier-out',
isSelectedB && !isBoth && '-translate-x-1/4',
isSelectedA && !isBoth && 'translate-x-1/4',
isBoth && 'translate-x-0',
)}
>
<div class="relative flex items-center px-6">
<span
class={cn(
'text-[0.625rem] sm:text-[0.75rem] tracking-tighter select-none transition-all duration-300',
isEither
? 'opacity-100 font-bold'
: 'opacity-30 group-hover:opacity-100',
isSelectedB && 'text-indigo-500',
isSelectedA && 'text-normal-950',
isBoth
&& 'bg-[linear-gradient(to_right,theme(colors.indigo.500)_50%,theme(colors.neutral.950)_50%)] bg-clip-text text-transparent',
)}
>
--- {font.name} ---
</span>
</div>
</div>
<button
onclick={handleSelectFontB}
class="flex-1 relative flex items-center justify-between transition-all duration-200 cursor-pointer hover:bg-indigo-500/[0.03]"
>
{@render brackets(
isSelectedB,
isSelectedB && !isBoth,
'stroke-1 size-7 stroke-indigo-600',
)}
</button>
<button
onclick={handleSelectFontA}
class="flex-1 relative flex items-center justify-end transition-all duration-200 cursor-pointer hover:bg-black/[0.02]"
>
{@render brackets(
isSelectedA && !isBoth,
isSelectedA,
'stroke-1 size-7 stroke-normal-950',
)}
</button>
</div>
{/snippet}
</FontVirtualList>
</div>
</div>

View File

@@ -1,55 +0,0 @@
<!--
Component: SliderLine
Visual representation of the comparison slider line.
1px red vertical rule with square handles at top and bottom.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { cubicOut } from 'svelte/easing';
import { fade } from 'svelte/transition';
interface Props {
/** Position of the slider as a percentage (0100) */
sliderPos: number;
/** Whether the slider is being dragged */
isDragging: boolean;
}
let { sliderPos, isDragging }: Props = $props();
</script>
<div
class="absolute top-0 bottom-0 z-50 pointer-events-none w-px bg-brand flex flex-col justify-between"
style:left="{sliderPos}%"
style:will-change={isDragging ? 'left' : 'auto'}
in:fade={{ duration: 300, delay: 150, easing: cubicOut }}
out:fade={{ duration: 150, easing: cubicOut }}
>
<!-- Top handle -->
<div class={cn(
'w-5 h-5 md:w-6 md:h-6',
'-ml-2.5 md:-ml-3',
'mt-2 md:mt-4',
'bg-brand text-white',
'flex items-center justify-center',
'rounded-none shadow-md',
'transition-transform duration-150',
isDragging ? 'scale-110' : 'scale-100',
)}>
<div class="w-0.5 h-2 md:h-3 bg-white/80"></div>
</div>
<!-- Bottom handle -->
<div class={cn(
'w-5 h-5 md:w-6 md:h-6',
'-ml-2.5 md:-ml-3',
'mb-2 md:mb-4',
'bg-brand text-white',
'flex items-center justify-center',
'rounded-none shadow-md',
'transition-transform duration-150',
isDragging ? 'scale-110' : 'scale-100',
)}>
<div class="w-0.5 h-2 md:h-3 bg-white/80"></div>
</div>
</div>

View File

@@ -1,88 +0,0 @@
<!--
Component: ToggleMenuButton
Toggles menu sidebar, displays selected fonts names
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { cubicOut } from 'svelte/easing';
import { draw } from 'svelte/transition';
interface Props {
isActive?: boolean;
onClick?: () => void;
}
let { isActive = $bindable(false), onClick }: Props = $props();
// Handle click and toggle
const toggle = () => {
onClick?.();
isActive = !isActive;
};
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
</script>
{#snippet icon(className?: string)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={cn(
'lucide lucide-circle-arrow-right-icon lucide-circle-arrow-right',
className,
)}
>
<circle cx="12" cy="12" r="10" />
{#if isActive}
<path
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
d="m15 9-6 6"
/><path
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
d="m9 9 6 6"
/>
{:else}
<path
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
d="m12 16 4-4-4-4"
/><path
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
d="M8 12h8"
/>
{/if}
</svg>
{/snippet}
<button
onclick={toggle}
aria-pressed={isActive}
class={cn(
'group relative flex items-center justify-center gap-2 sm:gap-3 px-4 sm:px-6 py-2',
'cursor-pointer select-none overflow-hidden',
'transition-transform duration-150 active:scale-98',
)}
>
{@render icon(
cn(
'size-4 stroke-[1.5] stroke-gray-500',
!isActive && 'rotate-90 sm:rotate-0',
),
)}
<div class="w-px h-2.5 bg-gray-400/50"></div>
<div class="text-[0.75rem] sm:text-xs transition-all delay-150 group-hover:text-semibold text-indigo-500 text-right whitespace-nowrap">
{fontB?.name}
</div>
<div class="w-px h-2.5 bg-gray-400/50"></div>
<div class="text-[0.75rem] sm:text-xs transition-all delay-150 group-hover:text-semibold text-neural-950 text-left whitespace-nowrap">
{fontA?.name}
</div>
</button>

View File

@@ -1,55 +0,0 @@
<!--
Component: TypographyControls
Controls for text input and typography settings (size, weight, height).
Simplified version for static positioning in settings mode.
-->
<script lang="ts">
import {
ComboControlV2,
Input,
} from '$shared/ui';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
const typography = $derived(comparisonStore.typography);
</script>
<!-- Text input -->
<Input
bind:value={comparisonStore.text}
size="sm"
label="Text"
placeholder="The quick brown fox..."
class="w-full h-10 px-3 py-2 sm:mr-4 mb-8 sm:mb-4 rounded-lg border border-border-muted bg-background-60 backdrop-blur-sm"
/>
<!-- Typography controls -->
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
<div class="flex flex-col mt-1.5">
<ComboControlV2
control={typography.weightControl}
orientation="horizontal"
class="sm:py-0 sm:px-0 mb-5 sm:mb-1.5"
label="font weight"
showScale={false}
reduced
/>
<ComboControlV2
control={typography.sizeControl}
orientation="horizontal"
class="sm:py-0 sm:px-0 mb-5 sm:mb-1.5"
label="font size"
showScale={false}
reduced
/>
<ComboControlV2
control={typography.heightControl}
orientation="horizontal"
class="sm:py-0 sm:px-0"
label="line height"
showScale={false}
reduced
/>
</div>
{/if}

View File

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