Compare commits

...

18 Commits

Author SHA1 Message Date
Ilia Mashkov
940e20515b chore: remove unused code 2026-02-15 23:23:52 +03:00
Ilia Mashkov
f15114a78b fix(Input): change the way input types are exporting 2026-02-15 23:22:44 +03:00
Ilia Mashkov
6ba37c9e4a feat(ComparisonSlider): add perspective manager and tweak styles 2026-02-15 23:15:50 +03:00
Ilia Mashkov
858daff860 feat(ComparisonSlider): create a scrollable list of fonts with clever controls 2026-02-15 23:11:10 +03:00
Ilia Mashkov
b7f54b503c feat(Controls): rework component to use SidebarMenu 2026-02-15 23:10:07 +03:00
Ilia Mashkov
17de544bdb feat(ComparisonSlider): add a toggle button that shows selected fonts and opens the sidebar menu with settings 2026-02-15 23:09:21 +03:00
Ilia Mashkov
a0ac52a348 feat(SidebarMenu): create a shared sidebar menu that slides to the screen 2026-02-15 23:08:22 +03:00
Ilia Mashkov
99966d2de9 feat(TypographyControls): drasticaly reduce animations, keep only the container functional 2026-02-15 23:07:23 +03:00
Ilia Mashkov
72334a3d05 feat(ComboControlV2): hide input when control is reduced 2026-02-15 23:05:58 +03:00
Ilia Mashkov
8780b6932c chore: formatting 2026-02-15 23:04:47 +03:00
Ilia Mashkov
5d2c05e192 feat(PerspectivePlan): add a wrapper to work with perspective manager styles 2026-02-15 23:04:24 +03:00
Ilia Mashkov
1031b96ec5 chore: add exports/imports 2026-02-15 23:03:09 +03:00
Ilia Mashkov
4fdc99a15a feat(createPerspectiveManager): create perspective manager to work with perspective, moving objects along the z axis 2026-02-15 23:02:49 +03:00
Ilia Mashkov
9e74a2c2c6 feat(createCharacterComparison): create type CharacterComparison and export it 2026-02-15 23:01:43 +03:00
Ilia Mashkov
aa3f467821 feat(Input): add tailwind variants with sizes, update stories 2026-02-15 23:00:12 +03:00
Ilia Mashkov
6001f50cf5 feat(Slider): change thumb shape to circle 2026-02-15 22:57:29 +03:00
Ilia Mashkov
c2d0992015 feat(FontVirtualList): move logic related to loading next batch of fonts to the FontVirtualContainer 2026-02-15 22:56:37 +03:00
Ilia Mashkov
bc56265717 feat(ComparisonSlider): add out animation for SliderLine 2026-02-15 22:54:07 +03:00
21 changed files with 1008 additions and 580 deletions

View File

@@ -3,7 +3,7 @@
- Renders a virtualized list of fonts
- Handles font registration with the manager
-->
<script lang="ts" generics="T extends UnifiedFont">
<script lang="ts">
import {
Skeleton,
VirtualList,
@@ -11,26 +11,23 @@ import {
import type { ComponentProps } from 'svelte';
import { fade } from 'svelte/transition';
import { getFontUrl } from '../../lib';
import type { FontConfigRequest } from '../../model';
import {
type FontConfigRequest,
type UnifiedFont,
appliedFontsManager,
unifiedFontStore,
} from '../../model';
interface Props extends
Omit<
ComponentProps<typeof VirtualList<T>>,
'onVisibleItemsChange'
ComponentProps<typeof VirtualList<UnifiedFont>>,
'items' | 'total' | 'isLoading' | 'onVisibleItemsChange' | 'onNearBottom'
>
{
/**
* Callback for when visible items change
*/
onVisibleItemsChange?: (items: T[]) => void;
/**
* Callback for when near bottom is reached
*/
onNearBottom?: (lastVisibleIndex: number) => void;
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
/**
* Weight of the font
*/
@@ -38,23 +35,20 @@ interface Props extends
* Weight of the font
*/
weight: number;
/**
* Whether the list is in a loading state
*/
isLoading?: boolean;
}
let {
items,
children,
onVisibleItemsChange,
onNearBottom,
weight,
isLoading = false,
...rest
}: Props = $props();
function handleInternalVisibleChange(visibleItems: T[]) {
const isLoading = $derived(
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
);
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
const configs: FontConfigRequest[] = [];
visibleItems.forEach(item => {
@@ -77,9 +71,32 @@ function handleInternalVisibleChange(visibleItems: T[]) {
// onVisibleItemsChange?.(visibleItems);
}
function handleNearBottom(lastVisibleIndex: number) {
// Forward the call to any external listener
onNearBottom?.(lastVisibleIndex);
/**
* Load more fonts by moving to the next page
*/
function loadMore() {
if (
!unifiedFontStore.pagination.hasMore
|| unifiedFontStore.isFetching
) {
return;
}
unifiedFontStore.nextPage();
}
/**
* Handle scroll near bottom - auto-load next page
*
* Triggered by VirtualList when the user scrolls within 5 items of the end
* of the loaded items. Only fetches if there are more pages available.
*/
function handleNearBottom(_lastVisibleIndex: number) {
const { hasMore } = unifiedFontStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items
if (hasMore && !unifiedFontStore.isFetching) {
loadMore();
}
}
</script>
@@ -99,10 +116,11 @@ function handleNearBottom(lastVisibleIndex: number) {
</div>
{:else}
<VirtualList
{items}
{...rest}
items={unifiedFontStore.fonts}
total={unifiedFontStore.pagination.total}
onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom}
{...rest}
>
{#snippet children(scope)}
{@render children(scope)}

View File

@@ -276,3 +276,5 @@ export function createCharacterComparison<
getCharState,
};
}
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;

View File

@@ -0,0 +1,130 @@
import { Spring } from 'svelte/motion';
export interface PerspectiveConfig {
/**
* How many px to move back per level
*/
depthStep?: number;
/**
* Scale reduction per level
*/
scaleStep?: number;
/**
* Blur amount per level
*/
blurStep?: number;
/**
* Opacity reduction per level
*/
opacityStep?: number;
/**
* Parallax intensity per level
*/
parallaxIntensity?: number;
/**
* Horizontal offset for each plan (x-axis positioning)
* Positive = right, Negative = left
*/
horizontalOffset?: number;
/**
* Layout mode: 'center' (default) or 'split' for Swiss-style side-by-side
*/
layoutMode?: 'center' | 'split';
}
/**
* Manages perspective state with a simple boolean flag.
*
* Drastically simplified from the complex camera/index system.
* Just manages whether content is in "back" or "front" state.
*
* @example
* ```typescript
* const perspective = createPerspectiveManager({
* depthStep: 100,
* scaleStep: 0.5,
* blurStep: 4,
* });
*
* // Toggle back/front
* perspective.toggle();
*
* // Check state
* const isBack = perspective.isBack; // reactive boolean
* ```
*/
export class PerspectiveManager {
/**
* Spring for smooth back/front transitions
*/
spring = new Spring(0, {
stiffness: 0.2,
damping: 0.8,
});
/**
* Reactive boolean: true when in back position (blurred, scaled down)
*/
isBack = $derived(this.spring.current > 0.5);
/**
* Reactive boolean: true when in front position (fully visible, interactive)
*/
isFront = $derived(this.spring.current < 0.5);
/**
* Configuration values for style computation
*/
private config: Required<PerspectiveConfig>;
constructor(config: PerspectiveConfig = {}) {
this.config = {
depthStep: config.depthStep ?? 100,
scaleStep: config.scaleStep ?? 0.5,
blurStep: config.blurStep ?? 4,
opacityStep: config.opacityStep ?? 0.5,
parallaxIntensity: config.parallaxIntensity ?? 0,
horizontalOffset: config.horizontalOffset ?? 0,
layoutMode: config.layoutMode ?? 'center',
};
}
/**
* Toggle between front (0) and back (1) positions.
* Smooth spring animation handles the transition.
*/
toggle = () => {
const target = this.spring.current < 0.5 ? 1 : 0;
this.spring.target = target;
};
/**
* Force to back position
*/
setBack = () => {
this.spring.target = 1;
};
/**
* Force to front position
*/
setFront = () => {
this.spring.target = 0;
};
/**
* Get configuration for style computation
* @internal
*/
getConfig = () => this.config;
}
/**
* Factory function to create a PerspectiveManager instance.
*
* @param config - Configuration options
* @returns Configured PerspectiveManager instance
*/
export function createPerspectiveManager(config: PerspectiveConfig = {}) {
return new PerspectiveManager(config);
}

View File

@@ -28,6 +28,7 @@ export {
} from './createEntityStore/createEntityStore.svelte';
export {
type CharacterComparison,
createCharacterComparison,
type LineData,
} from './createCharacterComparison/createCharacterComparison.svelte';
@@ -48,3 +49,8 @@ export {
getLenisContext,
setLenisContext,
} from './createScrollContext/createScrollContext.svelte';
export {
createPerspectiveManager,
type PerspectiveManager,
} from './createPerspectiveManager/createPerspectiveManager.svelte';

View File

@@ -1,4 +1,5 @@
export {
type CharacterComparison,
type ControlDataModel,
type ControlModel,
createCharacterComparison,
@@ -7,6 +8,7 @@ export {
createFilter,
createLenisContext,
createPersistentStore,
createPerspectiveManager,
createResponsiveManager,
createTypographyControl,
createVirtualizer,
@@ -17,6 +19,7 @@ export {
getLenisContext,
type LineData,
type PersistentStore,
type PerspectiveManager,
type Property,
type ResponsiveManager,
responsiveManager,

View File

@@ -150,6 +150,7 @@ function calculateScale(index: number): number | string {
/>
</div>
{#if !reduced}
<Input
class="h-10 rounded-lg w-12 pl-1 pr-1 sm:pr-1 md:pr-1 sm:pl-1 md:pl-1 text-center"
value={inputValue}
@@ -160,6 +161,7 @@ function calculateScale(index: number): number | string {
pattern={REGEXP_ONLY_DIGITS}
variant="ghost"
/>
{/if}
{#if label}
<div class="flex items-center gap-2 opacity-70">

View File

@@ -43,8 +43,10 @@ let { rotation = 'clockwise', icon, ...rest }: Props = $props();
>
{@render icon({
className: cn(
'size-4 transition-all duration-200 stroke-[1.5] stroke-text-muted group-hover:stroke-foreground group-hover:scale-110 group-hover:stroke-3 group-active:scale-90 group-disabled:stroke-transparent',
rotation === 'clockwise' ? 'group-active:rotate-6' : 'group-active:-rotate-6',
'size-4 transition-all duration-200 stroke-[1.5] stroke-text-muted group-hover:stroke-foreground group-hover:scale-110 group-hover:stroke-2 group-active:scale-90 group-disabled:stroke-transparent',
rotation === 'clockwise'
? 'group-active:rotate-6'
: 'group-active:-rotate-6',
),
})}
</Button>

View File

@@ -8,10 +8,11 @@ const { Story } = defineMeta({
parameters: {
docs: {
description: {
component: 'Styles Input component',
component: 'Styled input component with size and variant options',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
layout: 'centered',
},
argTypes: {
placeholder: {
@@ -22,21 +23,76 @@ const { Story } = defineMeta({
control: 'text',
description: "input's value",
},
variant: {
control: 'select',
options: ['default', 'ghost'],
description: 'Visual style variant',
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
description: 'Size variant',
},
},
});
</script>
<script lang="ts">
let value = $state('Initial value');
let valueDefault = $state('Initial value');
let valueSm = $state('');
let valueMd = $state('');
let valueLg = $state('');
let valueGhostSm = $state('');
let valueGhostMd = $state('');
let valueGhostLg = $state('');
const placeholder = 'Enter text';
</script>
<Story
name="Default"
args={{
placeholder,
value,
}}
>
<Input value={value} placeholder={placeholder} />
<!-- Default Story -->
<Story name="Default" args={{ placeholder }}>
<Input bind:value={valueDefault} {placeholder} />
</Story>
<!-- Size Variants -->
<Story name="Small" args={{ placeholder }}>
<Input bind:value={valueSm} {placeholder} size="sm" />
</Story>
<Story name="Medium" args={{ placeholder }}>
<Input bind:value={valueMd} {placeholder} size="md" />
</Story>
<Story name="Large" args={{ placeholder }}>
<Input bind:value={valueLg} {placeholder} size="lg" />
</Story>
<!-- Ghost Variant with Sizes -->
<Story name="Ghost Small" args={{ placeholder }}>
<Input bind:value={valueGhostSm} {placeholder} variant="ghost" size="sm" />
</Story>
<Story name="Ghost Medium" args={{ placeholder }}>
<Input bind:value={valueGhostMd} {placeholder} variant="ghost" size="md" />
</Story>
<Story name="Ghost Large" args={{ placeholder }}>
<Input bind:value={valueGhostLg} {placeholder} variant="ghost" size="lg" />
</Story>
<!-- Size Comparison -->
<Story name="All Sizes" tags={['!autodocs']}>
<div class="flex flex-col gap-4 w-full max-w-md p-8">
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-text-muted">Small</span>
<Input placeholder="Small input" size="sm" />
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-text-muted">Medium</span>
<Input placeholder="Medium input" size="md" />
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-text-muted">Large</span>
<Input placeholder="Large input" size="lg" />
</div>
</div>
</Story>

View File

@@ -2,60 +2,89 @@
Component: Input
Provides styled input component with all the shadcn input props
-->
<script lang="ts">
import { Input } from '$shared/shadcn/ui/input';
<script lang="ts" module>
import { Input as BaseInput } from '$shared/shadcn/ui/input';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { ComponentProps } from 'svelte';
import {
type VariantProps,
tv,
} from 'tailwind-variants';
type Props = ComponentProps<typeof Input> & {
export const inputVariants = tv({
base: [
'w-full backdrop-blur-md border font-medium transition-all duration-200',
'focus-visible:border-border-soft focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-muted/30 focus-visible:bg-background-95',
'hover:bg-background-95 hover:border-border-soft',
'text-foreground placeholder:text-text-muted placeholder:font-mono placeholder:tracking-wide',
],
variants: {
variant: {
default: 'bg-background-80 border-border-muted shadow-[0_1px_3px_rgba(0,0,0,0.04)]',
ghost: 'bg-transparent border-transparent shadow-none',
},
size: {
sm: [
'h-9 sm:h-10 md:h-11 rounded-lg',
'px-3 sm:px-3.5 md:px-4',
'text-xs sm:text-sm md:text-base',
'placeholder:text-xs sm:placeholder:text-sm md:placeholder:text-base',
],
md: [
'h-10 sm:h-12 md:h-14 rounded-xl',
'px-3.5 sm:px-4 md:px-5',
'text-sm sm:text-base md:text-lg',
'placeholder:text-xs sm:placeholder:text-sm md:placeholder:text-base',
],
lg: [
'h-12 sm:h-14 md:h-16 rounded-2xl',
'px-4 sm:px-5 md:px-6',
'text-sm sm:text-base md:text-lg',
'placeholder:text-xs sm:placeholder:text-sm md:placeholder:text-base',
],
},
},
defaultVariants: {
variant: 'default',
size: 'lg',
},
});
type InputVariant = VariantProps<typeof inputVariants>['variant'];
type InputSize = VariantProps<typeof inputVariants>['size'];
export type InputProps = {
/**
* Current search value (bindable)
*/
value: string;
value?: string;
/**
* Additional CSS classes for the container
*/
class?: string;
variant?: 'default' | 'ghost';
/**
* Visual style variant
*/
variant?: InputVariant;
/**
* Size variant
*/
size?: InputSize;
[key: string]: any;
};
</script>
<script lang="ts">
let {
value = $bindable(''),
class: className,
variant = 'default',
size = 'lg',
...rest
}: Props = $props();
const isGhost = $derived(variant === 'ghost');
}: InputProps = $props();
</script>
<Input
<BaseInput
bind:value={value}
class={cn(
'h-12 sm:h-14 md:h-16 w-full text-sm sm:text-base',
'backdrop-blur-md',
isGhost ? 'bg-transparent' : 'bg-background-80',
'border border-border-muted',
isGhost ? 'border-transparent' : 'border-border-muted',
isGhost ? 'shadow-none' : 'shadow-[0_1px_3px_rgba(0,0,0,0.04)]',
'focus-visible:border-border-soft',
'focus-visible:outline-none',
'focus-visible:ring-1',
'focus-visible:ring-border-muted/30',
'focus-visible:bg-background-95',
'hover:bg-background-95',
'hover:border-border-soft',
'text-foreground',
'placeholder:text-text-muted',
'placeholder:font-mono',
'placeholder:text-xs sm:placeholder:text-sm',
'placeholder:tracking-wide',
'pl-4 sm:pl-6 pr-4 sm:pr-6',
'rounded-xl',
'transition-all duration-200',
'font-medium',
className,
)}
class={cn(inputVariants({ variant, size }), className)}
{...rest}
/>

View File

@@ -0,0 +1,83 @@
<!--
Component: PerspectivePlan
Wrapper that applies perspective transformations based on back/front state.
Style computation moved from manager to component for simpler architecture.
-->
<script lang="ts">
import type { PerspectiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { type Snippet } from 'svelte';
interface Props {
/**
* Perspective manager
*/
manager: PerspectiveManager;
/**
* Additional classes
*/
class?: string;
/**
* Children
*/
children: Snippet<[{ className?: string }]>;
/**
* Constrain plan to a horizontal region
* 'left' | 'right' | 'full' (default)
*/
region?: 'left' | 'right' | 'full';
/**
* Width percentage when using left/right region (default 50)
*/
regionWidth?: number;
}
let { manager, children, class: className = '', region = 'full', regionWidth = 50 }: Props = $props();
const config = $derived(manager.getConfig());
// Computed style based on spring position (0 = front, 1 = back)
const style = $derived.by(() => {
const distance = manager.spring.current;
const baseX = config.horizontalOffset ?? 0;
// Back state: blurred, scaled down, pushed back
// Front state: fully visible, in focus
const scale = 1 - distance * (config.scaleStep ?? 0.5);
const blur = distance * (config.blurStep ?? 4);
const opacity = Math.max(0, 1 - distance * (config.opacityStep ?? 0.5));
const zIndex = 10;
const pointerEvents = distance < 0.4 ? 'auto' : ('none' as const);
return {
transform: `translate3d(${baseX}px, 0px, ${-distance * (config.depthStep ?? 100)}px) scale(${scale})`,
filter: `blur(${blur}px)`,
opacity,
pointerEvents,
zIndex,
};
});
// Calculate horizontal constraints based on region
const regionStyleStr = $derived(() => {
if (region === 'full') return '';
const side = region === 'left' ? 'left' : 'right';
return `position: absolute; ${side}: 0; width: ${regionWidth}%; top: 0; bottom: 0;`;
});
// Visibility: front = visible, back = hidden
const isVisible = $derived(manager.isFront);
</script>
<div
class={cn('will-change-transform', className)}
style:transform-style="preserve-3d"
style:transform={style?.transform}
style:filter={style?.filter}
style:opacity={style?.opacity}
style:pointer-events={style?.pointerEvents}
style:z-index={style?.zIndex}
style:custom={regionStyleStr()}
>
{@render children({ className: isVisible ? 'visible' : 'hidden' })}
</div>

View File

@@ -0,0 +1,99 @@
<!--
Component: SidebarMenu
Slides out from the right, closes on click outside
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing';
import {
fade,
slide,
} from 'svelte/transition';
interface Props {
/**
* Children to render conditionally
*/
children?: Snippet;
/**
* Action (always visible) to render
*/
action?: Snippet;
/**
* Wrapper reference to bind
*/
wrapper?: HTMLElement | null;
/**
* Class to add to the wrapper
*/
class?: string;
/**
* Bindable visibility flag
*/
visible?: boolean;
/**
* Handler for click outside
*/
onClickOutside?: () => void;
}
let {
children,
action,
wrapper = $bindable<HTMLElement | null>(null),
class: className,
visible = $bindable(false),
onClickOutside,
}: Props = $props();
/**
* Closes menu on click outside
*/
function handleClick(event: MouseEvent) {
if (!wrapper || !visible) {
return;
}
if (!wrapper.contains(event.target as Node)) {
visible = false;
onClickOutside?.();
}
}
</script>
<svelte:window on:click={handleClick} />
<div
class={cn(
'transition-all duration-300 delay-200 cubic-bezier-out',
className,
)}
bind:this={wrapper}
>
{@render action?.()}
{#if visible}
<div
class="relative z-20 h-full w-auto flex flex-col gap-4"
in:fade={{ duration: 300, delay: 400, easing: cubicOut }}
out:fade={{ duration: 150, easing: cubicOut }}
>
{@render children?.()}
</div>
<!-- Background Gradient -->
<div
class="
absolute inset-0 z-10 h-full transition-all duration-700
bg-linear-to-r from-white/75 via-white/45 to-white/10
bg-[radial-gradient(ellipse_at_left,_rgba(255,252,245,0.4)_0%,_transparent_70%)]
shadow-[_inset_-1px_0_0_rgba(0,0,0,0.04)]
border-r border-white/90
after:absolute after:right-[-1px] after:top-0 after:h-full after:w-[1px] after:bg-black/[0.05]
backdrop-blur-md
"
in:slide={{ axis: 'x', duration: 250, delay: 100, easing: cubicOut }}
out:slide={{ axis: 'x', duration: 150, easing: cubicOut }}
>
</div>
{/if}
</div>

View File

@@ -54,7 +54,8 @@ let { value = $bindable(), orientation = 'horizontal', class: className, ...rest
index={0}
class={cn(
'group/thumb relative block',
orientation === 'horizontal' ? '-top-1 w-2 h-2.25' : '-left-1 h-2 w-2.25',
'size-2',
orientation === 'horizontal' ? '-top-1' : '-left-1',
'rounded-sm',
'bg-foreground',
// Glow shadow
@@ -64,7 +65,7 @@ let { value = $bindable(), orientation = 'horizontal', class: className, ...rest
orientation === 'horizontal' ? 'transition-[height,top,left,box-shadow]' : 'transition-[width,top,left,box-shadow]',
// Hover: bigger glow
'hover:shadow-[0_0_10px_rgba(0,0,0,0.5)]',
orientation === 'horizontal' ? 'hover:h-3 hover:-top-[5.5px]' : 'hover:w-3 hover:-left-[5.5px]',
orientation === 'horizontal' ? 'hover:size-3 hover:-top-[5.5px]' : 'hover:size-3 hover:-left-[5.5px]',
// Active: smaller glow
'active:shadow-[0_0_4px_rgba(0,0,0,0.3)]',
orientation === 'horizontal' ? 'active:h-2.5 active:-top-[4.5px]' : 'active:w-2.5 active:-left-[4.5px]',

View File

@@ -1,3 +1,6 @@
import type { ComponentProps } from 'svelte';
import Input from './Input/Input.svelte';
export { default as CheckboxFilter } from './CheckboxFilter/CheckboxFilter.svelte';
export { default as ComboControl } from './ComboControl/ComboControl.svelte';
export { default as ComboControlV2 } from './ComboControlV2/ComboControlV2.svelte';
@@ -6,12 +9,25 @@ export { default as Drawer } from './Drawer/Drawer.svelte';
export { default as ExpandableWrapper } from './ExpandableWrapper/ExpandableWrapper.svelte';
export { default as Footnote } from './Footnote/Footnote.svelte';
export { default as IconButton } from './IconButton/IconButton.svelte';
export { default as Input } from './Input/Input.svelte';
export { default as Loader } from './Loader/Loader.svelte';
export { default as Logo } from './Logo/Logo.svelte';
export { default as PerspectivePlan } from './PerspectivePlan/PerspectivePlan.svelte';
export { default as SearchBar } from './SearchBar/SearchBar.svelte';
export { default as Section } from './Section/Section.svelte';
export { default as SidebarMenu } from './SidebarMenu/SidebarMenu.svelte';
export { default as Skeleton } from './Skeleton/Skeleton.svelte';
export { default as Slider } from './Slider/Slider.svelte';
export { default as SmoothScroll } from './SmoothScroll/SmoothScroll.svelte';
export { default as VirtualList } from './VirtualList/VirtualList.svelte';
type InputProps = ComponentProps<typeof Input>;
type InputSize = InputProps['size'];
type InputVariant = InputProps['variant'];
export {
Input,
type InputProps,
type InputSize,
type InputVariant,
};

View File

@@ -8,17 +8,23 @@
- 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,
createTypographyControl,
createPerspectiveManager,
} from '$shared/lib';
import type {
LineData,
ResponsiveManager,
} from '$shared/lib';
import { Loader } from '$shared/ui';
import {
Loader,
PerspectivePlan,
} from '$shared/ui';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { getContext } from 'svelte';
import { Spring } from 'svelte/motion';
@@ -31,10 +37,11 @@ import SliderLine from './components/SliderLine.svelte';
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
const isLoading = $derived(
comparisonStore.isLoading || !comparisonStore.isReady,
);
let container = $state<HTMLElement>();
let typographyControls = $state<HTMLDivElement | null>(null);
let measureCanvas = $state<HTMLCanvasElement>();
let isDragging = $state(false);
const typography = $derived(comparisonStore.typography);
@@ -45,7 +52,7 @@ 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 = createCharacterComparison(
const charComparison: CharacterComparison = createCharacterComparison(
() => comparisonStore.text,
() => fontA,
() => fontB,
@@ -53,6 +60,22 @@ const charComparison = createCharacterComparison(
() => 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 */
@@ -64,7 +87,10 @@ const sliderPos = $derived(sliderSpring.current);
/** Updates spring target based on pointer position */
function handleMove(e: PointerEvent) {
if (!isDragging || !container) return;
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;
@@ -72,18 +98,15 @@ function handleMove(e: PointerEvent) {
}
function startDragging(e: PointerEvent) {
if (
e.target === typographyControls
|| typographyControls?.contains(e.target as Node)
) {
e.stopPropagation();
return;
}
e.preventDefault();
isDragging = true;
handleMove(e);
}
function togglePerspective() {
perspective.toggle();
}
/**
* Sets the multiplier for slider font size based on the current responsive state
*/
@@ -146,28 +169,28 @@ $effect(() => {
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, charIndex}
{@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)}
{#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}
/>
<CharacterSlot {char} {proximity} {isPast} />
{/if}
{/each}
</div>
@@ -176,7 +199,33 @@ $effect(() => {
<!-- Hidden canvas used for text measurement by the helper -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<div class="relative">
<!-- 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
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"
@@ -185,31 +234,19 @@ $effect(() => {
aria-label="Font comparison slider"
onpointerdown={startDragging}
class="
group relative w-full py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24 overflow-hidden
rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
select-none touch-none cursor-ew-resize min-h-72 sm:min-h-96 flex flex-col justify-center
backdrop-blur-lg bg-linear-to-br from-gray-100/70 via-white/50 to-gray-100/60
border border-border-muted
shadow-[inset_0_4px_12px_0_rgba(0,0,0,0.12),inset_0_2px_4px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(255,255,255,0.8)]
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
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
"
>
<!-- Text Rendering Container -->
{#if isLoading}
<div out:fade={{ duration: 300 }}>
<Loader size={24} />
</div>
{:else}
<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
"
style:perspective="1000px"
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300 }}
>
@@ -226,9 +263,16 @@ $effect(() => {
{/each}
</div>
<!-- Slider Line - visible in slider mode -->
{#if !isInSettingsMode}
<SliderLine {sliderPos} {isDragging} />
{/if}
</div>
<!-- Since there're slider controls inside we put them outside the main one -->
<Controls {sliderPos} {isDragging} {typographyControls} {container} />
</PerspectivePlan>
<Controls
class="absolute inset-y-0 left-0 transition-all duration-150"
handleToggle={togglePerspective}
/>
{/if}
</div>

View File

@@ -1,44 +1,45 @@
<!--
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 {
Drawer,
IconButton,
} from '$shared/ui';
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
import { getContext } from 'svelte';
import { comparisonStore } from '../../../model';
import SelectComparedFonts from './SelectComparedFonts.svelte';
import { SidebarMenu } from '$shared/ui';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import ComparisonList from './FontList.svelte';
import ToggleMenuButton from './ToggleMenuButton.svelte';
import TypographyControls from './TypographyControls.svelte';
interface Props {
sliderPos: number;
isDragging: boolean;
typographyControls?: HTMLDivElement | null;
container: HTMLElement;
/**
* Additional class
*/
class?: string;
/**
* Handler to trigger when menu opens/closes
*/
handleToggle?: () => void;
}
let {
sliderPos,
isDragging,
typographyControls = $bindable<HTMLDivElement | null>(null),
container,
}: Props = $props();
let { class: className, handleToggle }: Props = $props();
let visible = $state(false);
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const weight = $derived(comparisonStore.typography.weight);
const responsive = getContext<ResponsiveManager>('responsive');
const typography = $derived(comparisonStore.typography);
let menuWrapper = $state<HTMLElement | null>(null);
$effect(() => {
if (!fontA || !fontB) {
return;
}
const weight = typography.weight;
const fontAUrl = getFontUrl(fontA, weight);
const fontBUrl = getFontUrl(fontB, weight);
@@ -63,41 +64,28 @@ $effect(() => {
});
</script>
{#if responsive.isMobile}
<Drawer>
{#snippet trigger({ isOpen, onClick })}
<IconButton class="absolute right-3 top-3" onclick={onClick}>
{#snippet icon({ className })}
<SlidersIcon class={className} />
{/snippet}
</IconButton>
{/snippet}
{#snippet content({ isOpen, className })}
<div class={cn(className, 'flex flex-col gap-6')}>
<SelectComparedFonts {sliderPos} />
<TypographyControls
{sliderPos}
{isDragging}
isActive={isOpen}
bind:wrapper={typographyControls}
containerWidth={container?.clientWidth}
staticPosition
/>
<SidebarMenu
class={cn(
'w-96 flex flex-col h-full pl-4 lg:pl-6 py-4 sm:py-6 sm:pt-12 gap-4 sm:gap-6 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-4 left-0 z-50', visible && 'w-full')}>
<ToggleMenuButton bind:isActive={visible} onClick={handleToggle} />
</div>
{/snippet}
</Drawer>
{:else}
<div class="absolute top-3 sm:top-6 left-3 sm:left-6 z-50">
<TypographyControls
{sliderPos}
{isDragging}
bind:wrapper={typographyControls}
containerWidth={container?.clientWidth}
/>
<div class="h-2/3 overflow-hidden">
<ComparisonList />
</div>
<div class="absolute bottom-3 sm:bottom-6 md:bottom-8 inset-x-3 sm:inset-x-6 md:inset-x-12">
<SelectComparedFonts {sliderPos} />
<div class="relative flex w-auto border-b border-gray-400/50 px-2 ml-4 mr-8 lg:mr-10"></div>
<div class="mr-4 sm:mr-6">
<TypographyControls />
</div>
{/if}
</SidebarMenu>

View File

@@ -0,0 +1,202 @@
<!--
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"
>
{#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 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(
'font-mono text-[10px] sm:text-[11px] uppercase 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 && 'text-indigo-600',
)}
>
--- {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,146 +0,0 @@
<!--
Component: SelectComparedFonts
Displays selects that change the compared fonts
-->
<script lang="ts">
import {
FontVirtualList,
type UnifiedFont,
unifiedFontStore,
} from '$entities/Font';
import { getFontUrl } from '$entities/Font/lib';
import FontApplicator from '$entities/Font/ui/FontApplicator/FontApplicator.svelte';
import {
Content as SelectContent,
Item as SelectItem,
Root as SelectRoot,
Trigger as SelectTrigger,
} from '$shared/shadcn/ui/select';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { fade } from 'svelte/transition';
interface Props {
/**
* Position of the slider
*/
sliderPos: number;
}
let { sliderPos }: Props = $props();
const typography = $derived(comparisonStore.typography);
const fontA = $derived(comparisonStore.fontA);
const fontAUrl = $derived(fontA && getFontUrl(fontA, typography.weight));
const fontB = $derived(comparisonStore.fontB);
const fontBUrl = $derived(fontB && getFontUrl(fontB, typography.weight));
const fontList = $derived(unifiedFontStore.fonts);
function selectFontA(font: UnifiedFont) {
if (!font) return;
comparisonStore.fontA = font;
}
function selectFontB(font: UnifiedFont) {
if (!font) return;
comparisonStore.fontB = font;
}
</script>
{#snippet fontSelector(
font: UnifiedFont,
fonts: UnifiedFont[],
url: string,
onSelect: (f: UnifiedFont) => void,
align: 'start' | 'end',
)}
<div
class="z-50 pointer-events-auto"
onpointerdown={(e => e.stopPropagation())}
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300, delay: 300 }}
>
<SelectRoot type="single" disabled={!fontList.length}>
<SelectTrigger
class={cn(
'w-36 sm:w-44 md:w-52 h-8 sm:h-9 border border-border-muted bg-background-60 backdrop-blur-sm',
'px-2 sm:px-3 rounded-lg transition-all flex items-center justify-between gap-2',
'font-mono text-[10px] sm:text-[11px] tracking-tight font-medium text-foreground',
'hover:bg-background-80 hover:border-border-soft hover:shadow-sm',
)}
>
<div class="text-left flex-1 min-w-0">
<FontApplicator {font} weight={typography.weight}>
{font.name}
</FontApplicator>
</div>
</SelectTrigger>
<SelectContent
class={cn(
'bg-background-95 backdrop-blur-xl border border-border-muted shadow-xl',
'w-44 sm:w-52 max-h-60 sm:max-h-64 overflow-hidden rounded-lg',
)}
side="top"
{align}
sideOffset={8}
size="small"
>
<div class="p-1 sm:p-1.5">
<FontVirtualList items={fonts} weight={typography.weight}>
{#snippet children({ item: fontListItem })}
{@const handleClick = () => onSelect(fontListItem)}
<SelectItem
value={fontListItem.id}
class="data-highlighted:bg-background-muted font-mono text-[10px] sm:text-[11px] px-2 sm:px-3 py-2 sm:py-2.5 rounded-md cursor-pointer transition-colors"
onclick={handleClick}
>
<FontApplicator
font={fontListItem}
weight={typography.weight}
>
{fontListItem.name}
</FontApplicator>
</SelectItem>
{/snippet}
</FontVirtualList>
</div>
</SelectContent>
</SelectRoot>
</div>
{/snippet}
<div class="flex justify-between items-end pointer-events-none z-20">
<div
class="flex flex-col gap-1.5 sm:gap-2 transition-all duration-500 items-start"
style:opacity={sliderPos < 20 ? 0 : 1}
style:transform="translateY({sliderPos < 20 ? '8px' : '0px'})"
>
<div class="flex items-center gap-2 sm:gap-2.5 px-1">
<div class="w-1.5 h-1.5 rounded-full bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.6)]"></div>
<div class="w-px h-2 sm:h-2.5 bg-border-subtle"></div>
<span class="font-mono text-[8px] sm:text-[9px] uppercase tracking-[0.2em] text-text-subtle font-medium">
ch_01
</span>
</div>
{#if fontB && fontBUrl}
{@render fontSelector(fontB, fontList, fontBUrl, selectFontB, 'start')}
{/if}
</div>
<div
class="flex flex-col items-end text-right gap-1.5 sm:gap-2 transition-all duration-500"
style:opacity={sliderPos > 80 ? 0 : 1}
style:transform="translateY({sliderPos > 80 ? '8px' : '0px'})"
>
<div class="flex items-center gap-2 sm:gap-2.5 px-1">
<span class="font-mono text-[8px] sm:text-[9px] uppercase tracking-[0.2em] text-text-subtle font-medium">
ch_02
</span>
<div class="w-px h-2 sm:h-2.5 bg-border-subtle"></div>
<div class="w-1.5 h-1.5 rounded-full bg-foreground shadow-[0_0_6px_rgba(0,0,0,0.4)]"></div>
</div>
{#if fontA && fontAUrl}
{@render fontSelector(fontA, fontList, fontAUrl, selectFontA, 'end')}
{/if}
</div>
</div>

View File

@@ -19,6 +19,7 @@ interface Props {
}
let { sliderPos, isDragging }: Props = $props();
</script>
<div
class={cn(
'absolute inset-y-2 sm:inset-y-4 pointer-events-none -translate-x-1/2 z-50 flex flex-col justify-center items-center',
@@ -30,6 +31,7 @@ let { sliderPos, isDragging }: Props = $props();
style:left="{sliderPos}%"
style:will-change={isDragging ? 'left' : 'auto'}
in:fade={{ duration: 300, delay: 150, easing: cubicOut }}
out:fade={{ duration: 150, easing: cubicOut }}
>
<!-- We use part of lucide cursor svg icon as a handle -->
<svg

View File

@@ -0,0 +1,74 @@
<!--
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('size-4 stroke-[1.5] stroke-gray-500')}
<div class="w-px h-2.5 bg-gray-400/50"></div>
<div class="text-xs uppercase transition-all delay-150 group-hover:opacity-100 text-indigo-500 text-right">
{fontB?.name}
</div>
<div class="w-px h-2.5 bg-gray-400/50"></div>
<div class="text-xs uppercase transition-all delay-150 group-hover:opacity-100 text-neural-950 text-left">
{fontA?.name}
</div>
</button>

View File

@@ -1,198 +1,51 @@
<!--
Component: TypographyControls
Wrapper for the controls of the slider.
- Input to change the text
- Three combo controls with inputs and sliders for font-weight, font-size, and line-height
Controls for text input and typography settings (size, weight, height).
Simplified version for static positioning in settings mode.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
ComboControlV2,
ExpandableWrapper,
Input,
} from '$shared/ui';
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';
interface Props {
/**
* Ref
*/
wrapper?: HTMLDivElement | null;
/**
* Slider position
*/
sliderPos: number;
/**
* Whether slider is being dragged
*/
isDragging: boolean;
/** */
isActive?: boolean;
/**
* Container width
*/
containerWidth: number;
/**
* Reduced animations flag
*/
staticPosition?: boolean;
}
let {
sliderPos,
isDragging,
isActive = $bindable(false),
wrapper = $bindable(null),
containerWidth = 0,
staticPosition = false,
}: Props = $props();
const typography = $derived(comparisonStore.typography);
const panelWidth = $derived(wrapper?.clientWidth ?? 0);
const margin = 24;
let side = $state<'left' | 'right'>('left');
// Unified active state for the entire wrapper
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
const xSpring = new Spring(0, {
stiffness: 0.14, // Lower is slower
damping: 0.5, // Settle
});
const rotateSpring = new Spring(0, {
stiffness: 0.12,
damping: 0.55,
});
function handleInputFocus() {
isActive = true;
}
// Movement Logic
$effect(() => {
if (containerWidth === 0 || panelWidth === 0 || staticPosition) {
return;
}
const sliderX = (sliderPos / 100) * containerWidth;
const buffer = 40;
const leftTrigger = margin + panelWidth + buffer;
const rightTrigger = containerWidth - (margin + panelWidth + buffer);
if (side === 'left' && sliderX < leftTrigger) {
side = 'right';
} else if (side === 'right' && sliderX > rightTrigger) {
side = 'left';
}
});
$effect(() => {
// 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 = currentSide === 'right' ? 3.5 : -3.5;
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
rotateSpring.target = 0;
}, 600);
}
});
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
});
</script>
{#snippet InputComponent(className: string)}
<!-- Text input -->
<Input
class={className}
bind:value={comparisonStore.text}
disabled={isDragging}
onfocusin={handleInputFocus}
size="sm"
label="Text"
placeholder="The quick brown fox..."
class="w-full px-3 py-2 h-10 rounded-lg border border-border-muted bg-background-60 backdrop-blur-sm mr-4"
/>
{/snippet}
{#snippet Controls(className: string, orientation: Orientation)}
<!-- Typography controls -->
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
<div class={className}>
<ComboControlV2 control={typography.weightControl} {orientation} reduced />
<ComboControlV2 control={typography.sizeControl} {orientation} reduced />
<ComboControlV2 control={typography.heightControl} {orientation} reduced />
<div class="flex flex-col gap-1.5 mt-1.5">
<ComboControlV2
control={typography.weightControl}
orientation="horizontal"
class="sm:py-0"
showScale={false}
reduced
/>
<ComboControlV2
control={typography.sizeControl}
orientation="horizontal"
class="sm:py-0"
showScale={false}
reduced
/>
<ComboControlV2
control={typography.heightControl}
orientation="horizontal"
class="sm:py-0"
showScale={false}
reduced
/>
</div>
{/if}
{/snippet}
<div
class="z-50 will-change-transform"
style:transform="
translateX({xSpring.current}px)
rotateZ({rotateSpring.current}deg)
"
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300, delay: 300 }}
>
{#if staticPosition}
<div class="flex flex-col gap-6">
{@render InputComponent?.('p-6')}
{@render Controls?.('flex flex-col justify-between items-center-safe gap-6', 'horizontal')}
</div>
{:else}
<ExpandableWrapper
bind:element={wrapper}
bind:expanded={isActive}
disabled={isDragging}
aria-label="Font controls"
rotation={side === 'right' ? 'counterclockwise' : 'clockwise'}
class={cn(
'transition-opacity flex items-top gap-1.5',
panelWidth === 0 ? 'opacity-0' : 'opacity-100',
)}
containerClassName={cn(!isActive && 'p-2 sm:p-0')}
>
{#snippet badge()}
<div
class={cn(
'animate-nudge relative transition-all',
side === 'left' ? 'order-2' : 'order-0',
isActive ? 'opacity-0' : 'opacity-100',
isDragging && 'opacity-80 grayscale-[0.2]',
)}
>
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
</div>
{/snippet}
{#snippet visibleContent()}
{@render InputComponent(cn(
'pl-1 sm:pl-3 pr-1 sm:pr-3',
'h-6 sm:h-8 md:h-10',
'rounded-lg',
isActive
? 'h-7 sm:h-8 text-[0.825rem]'
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm sm:text-base font-medium focus-visible:ring-0 text-slate-900/50',
))}
{/snippet}
{#snippet hiddenContent()}
{@render Controls?.('flex flex-row justify-between items-center-safe gap-2 sm:gap-0 h-64', 'vertical')}
{/snippet}
</ExpandableWrapper>
{/if}
</div>

View File

@@ -24,38 +24,6 @@ let innerHeight = $state(0);
// Is the component above the middle of the viewport?
let isAboveMiddle = $state(false);
const isLoading = $derived(
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
);
/**
* Load more fonts by moving to the next page
*/
function loadMore() {
if (
!unifiedFontStore.pagination.hasMore
|| unifiedFontStore.isFetching
) {
return;
}
unifiedFontStore.nextPage();
}
/**
* Handle scroll near bottom - auto-load next page
*
* Triggered by VirtualList when the user scrolls within 5 items of the end
* of the loaded items. Only fetches if there are more pages available.
*/
function handleNearBottom(_lastVisibleIndex: number) {
const { hasMore } = unifiedFontStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items
if (hasMore && !unifiedFontStore.isFetching) {
loadMore();
}
}
/**
* Calculate display range for pagination info
*/
@@ -83,13 +51,9 @@ const checkPosition = throttle(() => {
<div bind:this={wrapper}>
<FontVirtualList
items={unifiedFontStore.fonts}
total={unifiedFontStore.pagination.total}
onNearBottom={handleNearBottom}
itemHeight={220}
useWindowScroll={true}
weight={controlManager.weight}
{isLoading}
>
{#snippet children({
item: font,