feat(ComparisonView): add redesigned font comparison widget

This commit is contained in:
Ilia Mashkov
2026-03-02 22:18:05 +03:00
parent 6cd325ce38
commit ba186d00a1
14 changed files with 1884 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
<!--
Component: Character
Renders a single character with morphing animation
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '../../model';
interface Props {
/**
* Character
*/
char: string;
/**
* Proximity value
*/
proximity: number;
/**
* Past state
*/
isPast: boolean;
}
let { char, proximity, isPast }: Props = $props();
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const typography = $derived(comparisonStore.typography);
let slot = $state<0 | 1>(0);
let slotFonts = $state<[string, string]>(['', '']);
const displayChar = $derived(char === ' ' ? '\u00A0' : char);
const targetFont = $derived(isPast ? fontA?.name ?? '' : fontB?.name ?? '');
$effect(() => {
if (!targetFont || slotFonts[slot] === targetFont) return;
const next = slot === 0 ? 1 : 0;
slotFonts[next] = targetFont;
slot = next;
});
</script>
{#if fontA && fontB}
<span
class="char-wrap"
style:font-size="{typography.renderedSize}px"
style:will-change={proximity > 0 ? 'transform' : 'auto'}
>
{#each [0, 1] as s (s)}
<span
class={cn(
'char-inner',
isPast
? 'text-swiss-black/75 dark:text-brand/75'
: 'text-neutral-950 dark:text-white',
)}
style:font-family={slotFonts[s]}
style:font-weight={typography.weight}
style:opacity={slot === s ? '1' : '0'}
style:position={slot === s ? 'relative' : 'absolute'}
aria-hidden={slot !== s ? true : undefined}
>
{displayChar}
</span>
{/each}
</span>
{/if}
<style>
.char-wrap {
display: inline-block;
position: relative;
line-height: 1;
}
.char-inner {
top: 0;
left: 0;
backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
font-synthesis: none;
text-rendering: geometricPrecision;
font-optical-sizing: auto;
transition:
opacity 0.1s ease-out,
color 0.2s ease-out,
transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
</style>

View File

@@ -0,0 +1,101 @@
<script module>
import Providers from '$shared/lib/storybook/Providers.svelte';
import { defineMeta } from '@storybook/addon-svelte-csf';
import ComparisonView from './ComparisonView.svelte';
const { Story } = defineMeta({
title: 'Widgets/ComparisonView',
component: ComparisonView,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Top-level layout for the font comparison page. Owns all shared state and wires the pieces together. Includes sidebar with font list and main comparison area with typography controls.',
},
story: { inline: false },
},
viewport: {
viewports: {
mobile1: {
name: 'iPhone 5/SE',
styles: {
width: '320px',
height: '568px',
},
},
mobile2: {
name: 'iPhone 14 Pro Max',
styles: {
width: '414px',
height: '896px',
},
},
tablet: {
name: 'iPad (Portrait)',
styles: {
width: '834px',
height: '1112px',
},
},
desktop: {
name: 'Desktop (Small)',
styles: {
width: '1024px',
height: '1280px',
},
},
widgetMedium: {
name: 'Widget Medium',
styles: {
width: '768px',
height: '800px',
},
},
widgetWide: {
name: 'Widget Wide',
styles: {
width: '1024px',
height: '800px',
},
},
widgetExtraWide: {
name: 'Widget Extra Wide',
styles: {
width: '1280px',
height: '800px',
},
},
fullWidth: {
name: 'Full Width',
styles: {
width: '100%',
height: '800px',
},
},
fullScreen: {
name: 'Full Screen',
styles: {
width: '100%',
height: '100%',
},
},
},
},
},
argTypes: {},
});
</script>
<Story
name="Default"
parameters={{ globals: { viewport: 'fullScreen' } }}
>
{#snippet template()}
<Providers>
<div class="w-full h-screen overflow-hidden">
<ComparisonView />
</div>
</Providers>
{/snippet}
</Story>

View File

@@ -0,0 +1,112 @@
<!--
Component: ComparisonView
Top-level layout for the font comparison page.
Owns all shared state and wires the pieces together.
-->
<script lang="ts">
import { NavigationWrapper } from '$entities/Breadcrumb';
import type { ResponsiveManager } from '$shared/lib';
import {
ControlGroup,
SidebarContainer,
Slider,
} from '$shared/ui';
import {
getContext,
untrack,
} from 'svelte';
import { comparisonStore } from '../../model';
import FontList from '../FontList/FontList.svelte';
import Header from '../Header/Header.svelte';
import Sidebar from '../Sidebar/Sidebar.svelte';
import SliderArea from '../SliderArea/SliderArea.svelte';
const responsive = getContext<ResponsiveManager>('responsive');
const typography = $derived(comparisonStore.typography);
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
let isSidebarOpen = $state(!isMobileOrTabletPortrait);
$effect(() => {
if (isMobileOrTabletPortrait) {
untrack(() => {
isSidebarOpen = false;
});
}
});
</script>
<NavigationWrapper index={0} title="Comparison">
{#snippet content(action)}
<div class="flex h-screen w-full overflow-hidden bg-[#f3f0e9] dark:bg-[#0a0a0a]">
<!-- Sidebar -->
<SidebarContainer bind:isOpen={isSidebarOpen}>
{#snippet sidebar()}
<Sidebar class="w-full h-full border-none">
{#snippet main()}
<FontList />
{/snippet}
{#snippet controls()}
{#if typography.sizeControl && typography.weightControl && typography.heightControl && typography.spacingControl}
<ControlGroup label="Size">
<Slider
bind:value={typography.sizeControl.value}
min={typography.sizeControl.min}
max={typography.sizeControl.max}
step={typography.sizeControl.step}
/>
</ControlGroup>
<ControlGroup label="Weight">
<Slider
bind:value={typography.weightControl.value}
min={typography.weightControl.min}
max={typography.weightControl.max}
step={typography.weightControl.step}
/>
</ControlGroup>
<div class="grid grid-cols-2 gap-6 mt-4">
<ControlGroup label="Leading" class="border-0 py-0">
<Slider
bind:value={typography.heightControl.value}
min={typography.heightControl.min}
max={typography.heightControl.max}
step={typography.heightControl.step}
format={(v => v.toFixed(1))}
/>
</ControlGroup>
<ControlGroup label="Tracking" class="border-0 py-0">
<Slider
bind:value={typography.spacingControl.value}
min={typography.spacingControl.min}
max={typography.spacingControl.max}
step={typography.spacingControl.step}
format={(v => v.toFixed(2))}
/>
</ControlGroup>
</div>
{/if}
{/snippet}
</Sidebar>
{/snippet}
</SidebarContainer>
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
<!-- Header -->
<div use:action>
<Header
{isSidebarOpen}
onSidebarToggle={() => (isSidebarOpen = !isSidebarOpen)}
/>
</div>
<!-- Main: comparison slider fills remaining space -->
<SliderArea
{isSidebarOpen}
class="flex-1 min-w-0"
/>
</div>
</div>
{/snippet}
</NavigationWrapper>

View File

@@ -0,0 +1,121 @@
<!--
Component: FontList
Renders font list for A/B comparison with animated selection
-->
<script lang="ts">
import {
FontApplicator,
FontVirtualList,
type UnifiedFont,
} from '$entities/Font';
import {
Button,
Label,
} from '$shared/ui';
import DotIcon from '@lucide/svelte/icons/dot';
import { cubicOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
import { comparisonStore } from '../../model';
const side = $derived(comparisonStore.side);
const typography = $derived(comparisonStore.typography);
let prevIndexA: number | null = null;
let prevIndexB: number | null = null;
let selectedIndexA: number | null = null;
let selectedIndexB: number | null = null;
let pendingDirection: 1 | -1 = 1;
const [send, receive] = crossfade({
duration: 300,
easing: cubicOut,
fallback(node) {
// Read pendingDirection synchronously — no reactive timing issues
const fromY = pendingDirection * (node.closest('[data-font-list]')?.clientHeight ?? 300);
return {
duration: 300,
easing: cubicOut,
css: t => `transform: translateY(${fromY * (1 - t)}px);`,
};
},
});
function handleSelect(font: UnifiedFont, index: number) {
if (side === 'A') {
if (prevIndexA !== null) {
pendingDirection = index > prevIndexA ? -1 : 1;
}
prevIndexA = index;
selectedIndexA = index;
comparisonStore.fontA = font;
} else if (side === 'B') {
if (prevIndexB !== null) {
pendingDirection = index > prevIndexB ? -1 : 1;
}
prevIndexB = index;
selectedIndexB = index;
comparisonStore.fontB = font;
}
}
// When side switches, compute direction from relative positions of A vs B
$effect(() => {
const _ = side; // track side
if (selectedIndexA !== null && selectedIndexB !== null) {
// Switching TO B means dot moves toward B's position relative to A
pendingDirection = side === 'B'
? (selectedIndexB > selectedIndexA ? 1 : -1)
: (selectedIndexA > selectedIndexB ? 1 : -1);
}
});
</script>
<div class="flex-1 min-h-0 h-full">
<div class="py-2 pl-4 relative flex flex-col min-h-0 h-full">
<div class="px-2 py-4 mr-4 sticky border-b border-black/5 dark:border-white/10 mb-2">
<Label class="font-primary text-neutral-400" bold variant="default" size="sm" uppercase>
Typeface Selection
</Label>
</div>
<FontVirtualList
data-font-list
weight={typography.weight}
itemHeight={45}
class="bg-transparent min-h-0 h-full scroll-stable pr-4"
>
{#snippet children({ item: font, index })}
{@const isSelectedA = font.id === comparisonStore.fontA?.id}
{@const isSelectedB = font.id === comparisonStore.fontB?.id}
{@const active = (side === 'A' && isSelectedA) || (side === 'B' && isSelectedB)}
<Button
variant="tertiary"
{active}
onclick={() => handleSelect(font, index)}
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
iconPosition="right"
>
<FontApplicator {font} weight={typography.weight}>{font.name}</FontApplicator>
{#snippet icon()}
{#if active}
<div
in:receive={{ key: 'active-dot' }}
out:send={{ key: 'active-dot' }}
>
<DotIcon class="size-8 stroke-brand" />
</div>
{:else if isSelectedA || isSelectedB}
<div
in:receive={{ key: 'inactive-dot' }}
out:send={{ key: 'inactive-dot' }}
>
<DotIcon class="size-8 stroke-neutral-300 dark:stroke-neutral-600" />
</div>
{/if}
{/snippet}
</Button>
{/snippet}
</FontVirtualList>
</div>
</div>

View File

@@ -0,0 +1,132 @@
<!--
Component: Header
Top bar for the font comparison view.
-->
<script lang="ts">
import { ThemeSwitch } from '$features/ChangeAppTheme';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
Badge,
Divider,
IconButton,
Input,
Label,
Logo,
TechText,
} from '$shared/ui';
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
import { getContext } from 'svelte';
import { comparisonStore } from '../../model';
interface Props {
/**
* Sidebar open state
*/
isSidebarOpen: boolean;
/**
* Sidebar toggle callback
*/
onSidebarToggle: () => void;
/**
* CSS classes
*/
class?: string;
}
let {
isSidebarOpen,
onSidebarToggle,
class: className,
}: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
const position = $derived(comparisonStore.sliderPosition.toFixed(0));
const fontAName = $derived(comparisonStore.fontA?.name ?? '');
const fontBName = $derived(comparisonStore.fontB?.name ?? '');
</script>
<header
class={cn(
'flex items-center justify-between',
'px-4 md:px-8 py-4 md:py-6',
'h-16 md:h-20 z-20',
'border-b border-black/5 dark:border-white/10',
'bg-surface dark:bg-dark-bg',
className,
)}
>
<!-- Sidebar toggle + logo -->
<div class="flex items-center gap-3 md:gap-6 shrink-0">
<IconButton
onclick={onSidebarToggle}
title={isSidebarOpen ? 'Close Config' : 'Open Config'}
size={responsive.isMobile ? 'sm' : 'md'}
>
{#snippet icon()}
{#if isSidebarOpen}
<PanelLeftClose class={responsive.isMobile ? 'size-4' : 'size-5'} />
{:else}
<PanelLeftOpen class={responsive.isMobile ? 'size-4' : 'size-5'} />
{/if}
{/snippet}
</IconButton>
<!-- Logo + BETA badge -->
<Logo />
</div>
<!-- Center: text input (lg+ only) -->
<div class="flex-1 max-w-xl mx-4 md:mx-8 hidden lg:block">
<Input
class="text-center"
bind:value={comparisonStore.text}
variant="underline"
size="lg"
placeholder="The quick brown fox..."
fullWidth
/>
</div>
<!-- Font names + slider % + theme toggle -->
<div class="flex items-center gap-3 md:gap-8 shrink-0 select-none">
<div class="hidden lg:flex items-center gap-6">
<div class="flex flex-col items-end leading-tight gap-0.5">
<TechText class="uppercase" variant="default" size="sm">
{fontAName}
</TechText>
<Label variant="accent" size="xs">Primary</Label>
</div>
<!-- Rotated 1px divider -->
<Divider
orientation="vertical"
class="h-8 rotate-12"
/>
<div class="flex flex-col items-start leading-tight gap-0.5">
<TechText class="uppercase" variant="default" size="sm">
{fontBName}
</TechText>
<Label variant="muted" size="xs">Secondary</Label>
</div>
<Divider
orientation="vertical"
class="h-8 rotate-12"
/>
</div>
<!-- Slider percentage — sm+ only -->
<div class="hidden sm:block w-8 text-right tabular-nums">
<TechText variant="default" size="sm">
{position}<span class="text-neutral-400">%</span>
</TechText>
</div>
<!-- Theme toggle -->
<ThemeSwitch />
</div>
</header>

View File

@@ -0,0 +1,39 @@
<!--
Component: Line
Renders a line of text in the SliderArea
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import { comparisonStore } from '../../model';
interface Props {
/**
* Line text
*/
text: string;
/**
* DOM element reference
*/
element?: HTMLElement;
/**
* Character render snippet
*/
character: Snippet<[{ char: string; index: number }]>;
}
const typography = $derived(comparisonStore.typography);
let { text, element = $bindable<HTMLElement>(), character }: Props = $props();
const characters = $derived(text.split(''));
</script>
<div
bind:this={element}
class="relative flex w-full justify-center items-center whitespace-nowrap"
style:height="{typography.height}em"
style:line-height="{typography.height}em"
>
{#each characters as char, index}
{@render character?.({ char, index })}
{/each}
</div>

View File

@@ -0,0 +1,110 @@
<!--
Component: Sidebar
Layout shell for the font comparison sidebar.
Owns the wrapper, header, and A/B side toggle.
Content (font list, controls) is injected via snippets.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
ButtonGroup,
Label,
ToggleButton,
} from '$shared/ui';
import type { Snippet } from 'svelte';
import {
type Side,
comparisonStore,
} from '../../model';
interface Props {
/**
* Main area snippet
*/
main?: Snippet;
/**
* Controls area snippet
*/
controls?: Snippet;
/**
* CSS classes
*/
class?: string;
}
let {
main,
controls,
class: className,
}: Props = $props();
</script>
<div
class={cn(
'flex flex-col h-full',
'w-80',
'bg-surface dark:bg-dark-bg',
'border-r border-black/5 dark:border-white/10',
'transition-colors duration-500',
className,
)}
>
<!-- ── Header: title + A/B toggle ────────────────────────────────── -->
<div
class="
p-6 shrink-0
border-b border-black/5 dark:border-white/10
bg-surface dark:bg-dark-bg
"
>
<!-- Title -->
<Label variant="default" size="lg" bold class="mb-6 block tracking-tight leading-none">
Configuration
</Label>
<!--
A/B side toggle.
Two ghost buttons (unsized) side by side in a bordered grid.
active prop drives white card state on the selected side.
No size prop → no square sizing, layout comes from class.
-->
<ButtonGroup>
<ToggleButton
active={comparisonStore.side === 'A'}
onclick={() => comparisonStore.side = 'A'}
class="flex-1 tracking-wide font-bold uppercase text-[0.625rem]"
>
<span>Left Font</span>
</ToggleButton>
<ToggleButton
class="flex-1 tracking-wide font-bold uppercase text-[0.625rem]"
active={comparisonStore.side === 'B'}
onclick={() => comparisonStore.side = 'B'}
>
<span class="uppercase">Right Font</span>
</ToggleButton>
</ButtonGroup>
</div>
<!-- ── Main: content area (no scroll - VirtualList handles scrolling) ─────────────────────────────── -->
<div class="flex-1 min-h-0 bg-surface dark:bg-dark-bg">
{#if main}
{@render main()}
{/if}
</div>
<!-- ── Bottom: fixed controls ─────────────────────────────────────── -->
{#if controls}
<div
class="
shrink-0 p-6
bg-surface dark:bg-dark-bg
border-t border-black/5 dark:border-white/10
z-10
"
>
{@render controls()}
</div>
{/if}
</div>

View File

@@ -0,0 +1,236 @@
<!--
Component: ComparisonSlider
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.
-->
<script lang="ts">
import {
type CharacterComparison,
type ResponsiveManager,
createCharacterComparison,
debounce,
} from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Loader } from '$shared/ui';
import { getContext } from 'svelte';
import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition';
import { comparisonStore } from '../../model';
import Character from '../Character/Character.svelte';
import Line from '../Line/Line.svelte';
import Thumb from '../Thumb/Thumb.svelte';
interface Props {
/**
* Sidebar open state
* @default false
*/
isSidebarOpen?: boolean;
/**
* CSS classes
*/
class?: string;
}
let { isSidebarOpen = false, class: className }: Props = $props();
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
const typography = $derived(comparisonStore.typography);
let container = $state<HTMLElement>();
let measureCanvas = $state<HTMLCanvasElement>();
const responsive = getContext<ResponsiveManager>('responsive');
const isMobile = $derived(responsive?.isMobile ?? false);
let isDragging = $state(false);
const charComparison: CharacterComparison = createCharacterComparison(
() => comparisonStore.text,
() => fontA,
() => fontB,
() => typography.weight,
() => typography.renderedSize,
);
let lineElements = $state<(HTMLElement | undefined)[]>([]);
const sliderSpring = new Spring(50, {
stiffness: 0.2,
damping: 0.7,
});
const sliderPos = $derived(sliderSpring.current);
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);
}
const storeSliderPosition = debounce((value: number) => {
comparisonStore.sliderPosition = value;
}, 100);
$effect(() => {
storeSliderPosition(sliderPos);
});
$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;
}
});
$effect(() => {
if (isDragging) {
window.addEventListener('pointermove', handleMove);
const stop = () => (isDragging = false);
window.addEventListener('pointerup', stop);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', stop);
};
}
});
$effect(() => {
const _text = comparisonStore.text;
const _weight = typography.weight;
const _size = typography.renderedSize;
const _height = typography.height;
if (container && measureCanvas && fontA && fontB) {
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);
});
// Dynamic backgroundSize based on isMobile — can't express this in Tailwind.
// Color is set to currentColor so it respects dark mode via text color.
const gridStyle = $derived(
`background-image: linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px); `
+ `background-size: ${isMobile ? '10px 10px' : '20px 20px'};`,
);
// Replaces motion.div animate={{ scale: isSidebarOpen && !isMobile ? 0.94 :1 }}
const scaleClass = $derived(
isSidebarOpen && !isMobile
? 'scale-[0.94]'
: 'scale-100',
);
</script>
<!-- Hidden measurement canvas -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<!--
Outer flex container — fills parent.
The paper div inside scales down when the sidebar opens on desktop.
-->
<div class={cn('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}>
<!--
Paper surface.
Replaces the old glassmorphism card with a clean white/dark sheet.
Scale transition replaces motion.div spring — CSS transition-transform
is smooth enough here; a JS spring would add ~4kb for minimal gain.
-->
<div
class={cn(
'w-full h-full flex flex-col items-center justify-center relative',
'bg-white dark:bg-[#1e1e1e]',
'shadow-2xl shadow-black/5 dark:shadow-black/20',
'transition-transform duration-300 ease-out',
scaleClass,
)}
>
<!-- Subtle grid overlay — pointer-events-none, very low opacity -->
<div
class="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05] text-black dark:text-white"
style={gridStyle}
aria-hidden="true"
>
</div>
<!-- Slider interaction area -->
<div class="w-full h-full flex items-center justify-center p-4 md:p-8 overflow-hidden">
{#if isLoading}
<div out:fade={{ duration: 300 }}>
<Loader size={24} />
</div>
{:else}
<div
bind:this={container}
role="slider"
tabindex="0"
aria-valuenow={Math.round(sliderPos)}
aria-label="Font comparison slider"
onpointerdown={startDragging}
class="
relative w-full max-w-6xl h-full
flex flex-col justify-center
select-none touch-none cursor-ew-resize
py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24
"
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300 }}
>
<!-- Character lines -->
<div
class="
relative flex flex-col items-center gap-3 sm:gap-4
z-10 pointer-events-none text-center
my-auto
"
>
{#each charComparison.lines as line, lineIndex}
<Line bind:element={lineElements[lineIndex]} text={line.text}>
{#snippet character({ char, index })}
{@const { proximity, isPast } = charComparison.getCharState(index, sliderPos, lineElements[lineIndex], container)}
<Character {char} {proximity} {isPast} />
{/snippet}
</Line>
{/each}
</div>
<Thumb {sliderPos} {isDragging} />
</div>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,63 @@
<!--
Component: Thumb
Renders a thumb to control ComparisonSlider position.
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 {
/**
* Slider position percentage
*/
sliderPos: number;
/**
* Dragging state
*/
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

@@ -0,0 +1 @@
export { default as ComparisonView } from './ComparisonView/ComparisonView.svelte';