Compare commits

..

9 Commits

20 changed files with 907 additions and 315 deletions

View File

@@ -2,66 +2,83 @@ import { SvelteMap } from 'svelte/reactivity';
export type FontStatus = 'loading' | 'loaded' | 'error'; export type FontStatus = 'loading' | 'loaded' | 'error';
export interface FontConfigRequest {
slug: string;
weight: number;
isVariable?: boolean;
}
/** /**
* Manager that handles loading of the fonts * Manager that handles loading of fonts from Fontshare.
* Adds <link /> tags to <head /> * Logic:
* - Uses batch loading to reduce the number of requests * - Variable fonts: Loaded once per slug (covers all weights).
* - Uses a queue to prevent too many requests at once * - Static fonts: Loaded per slug + weight combination.
* - Purges unused fonts after a certain time
*/ */
class AppliedFontsManager { class AppliedFontsManager {
// Stores: slug -> timestamp of last visibility // Tracking usage: Map<key, timestamp> where key is "slug" or "slug@weight"
#usageTracker = new Map<string, number>(); #usageTracker = new Map<string, number>();
// Stores: slug -> batchId // Map: key -> batchId
#slugToBatch = new Map<string, string>(); #slugToBatch = new Map<string, string>();
// Stores: batchId -> HTMLLinkElement (for physical cleanup) // Map: batchId -> HTMLLinkElement
#batchElements = new Map<string, HTMLLinkElement>(); #batchElements = new Map<string, HTMLLinkElement>();
#queue = new Set<string>(); #queue = new Set<string>();
#timeoutId: ReturnType<typeof setTimeout> | null = null; #timeoutId: ReturnType<typeof setTimeout> | null = null;
#PURGE_INTERVAL = 60000; // Check every minute
#TTL = 5 * 60 * 1000; // 5 minutes #PURGE_INTERVAL = 60000;
#TTL = 5 * 60 * 1000;
#CHUNK_SIZE = 3; #CHUNK_SIZE = 3;
// Reactive status map for UI feedback
statuses = new SvelteMap<string, FontStatus>(); statuses = new SvelteMap<string, FontStatus>();
constructor() { constructor() {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// Start the "Janitor" loop
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL); setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
} }
} }
/** /**
* Updates the 'last seen' timestamp for fonts. * Resolves a unique key for the font asset.
* Prevents them from being purged while they are on screen.
*/ */
touch(slugs: string[]) { #getFontKey(slug: string, weight: number, isVariable: boolean): string {
const s = slug.toLowerCase();
// Variable fonts only need one entry regardless of weight
return isVariable ? s : `${s}@${weight}`;
}
/**
* Call this when a font is rendered on screen.
*/
touch(configs: FontConfigRequest[]) {
const now = Date.now(); const now = Date.now();
const toRegister: string[] = []; const toRegister: string[] = [];
slugs.forEach(slug => { configs.forEach(({ slug, weight, isVariable = false }) => {
this.#usageTracker.set(slug, now); const key = this.#getFontKey(slug, weight, isVariable);
if (!this.#slugToBatch.has(slug)) {
toRegister.push(slug); this.#usageTracker.set(key, now);
if (!this.#slugToBatch.has(key)) {
toRegister.push(key);
} }
}); });
if (toRegister.length > 0) this.registerFonts(toRegister); if (toRegister.length > 0) this.registerFonts(toRegister);
} }
registerFonts(slugs: string[]) { registerFonts(keys: string[]) {
const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s)); const newKeys = keys.filter(k => !this.#slugToBatch.has(k) && !this.#queue.has(k));
if (newSlugs.length === 0) return; if (newKeys.length === 0) return;
newSlugs.forEach(s => this.#queue.add(s)); newKeys.forEach(k => this.#queue.add(k));
if (this.#timeoutId) clearTimeout(this.#timeoutId); if (this.#timeoutId) clearTimeout(this.#timeoutId);
this.#timeoutId = setTimeout(() => this.#processQueue(), 50); this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
} }
getFontStatus(slug: string) { getFontStatus(slug: string, weight: number, isVariable: boolean) {
return this.statuses.get(slug); return this.statuses.get(this.#getFontKey(slug, weight, isVariable));
} }
#processQueue() { #processQueue() {
@@ -76,16 +93,23 @@ class AppliedFontsManager {
this.#timeoutId = null; this.#timeoutId = null;
} }
#createBatch(slugs: string[]) { #createBatch(keys: string[]) {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
const batchId = crypto.randomUUID(); const batchId = crypto.randomUUID();
// font-display=swap included for better UX
const query = slugs.map(s => `f[]=${s.toLowerCase()}@400`).join('&'); /**
* Fontshare API Logic:
* - If key contains '@', it's static (e.g., satoshi@700)
* - If it's a plain slug, it's variable. We append '@1,2' for variable assets.
*/
const query = keys.map(k => {
return k.includes('@') ? `f[]=${k}` : `f[]=${k}@1,2`;
}).join('&');
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`; const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
// Mark all as loading immediately keys.forEach(key => this.statuses.set(key, 'loading'));
slugs.forEach(slug => this.statuses.set(slug, 'loading'));
const link = document.createElement('link'); const link = document.createElement('link');
link.rel = 'stylesheet'; link.rel = 'stylesheet';
@@ -94,21 +118,24 @@ class AppliedFontsManager {
document.head.appendChild(link); document.head.appendChild(link);
this.#batchElements.set(batchId, link); this.#batchElements.set(batchId, link);
slugs.forEach(slug => {
this.#slugToBatch.set(slug, batchId);
// Use the Native Font Loading API keys.forEach(key => {
// format: "font-size font-family" this.#slugToBatch.set(key, batchId);
document.fonts.load(`1em "${slug}"`)
// Determine what to check in the Font Loading API
const isVariable = !key.includes('@');
const [family, staticWeight] = key.split('@');
// For variable fonts, we check a standard weight;
// for static, we check the specific numeric weight requested.
const weightToCheck = isVariable ? '400' : staticWeight;
document.fonts.load(`${weightToCheck} 1em "${family}"`)
.then(loadedFonts => { .then(loadedFonts => {
if (loadedFonts.length > 0) { this.statuses.set(key, loadedFonts.length > 0 ? 'loaded' : 'error');
this.statuses.set(slug, 'loaded');
} else {
this.statuses.set(slug, 'error');
}
}) })
.catch(() => { .catch(() => {
this.statuses.set(slug, 'error'); this.statuses.set(key, 'error');
}); });
}); });
} }
@@ -116,31 +143,30 @@ class AppliedFontsManager {
#purgeUnused() { #purgeUnused() {
const now = Date.now(); const now = Date.now();
const batchesToPotentialDelete = new Set<string>(); const batchesToPotentialDelete = new Set<string>();
const slugsToDelete: string[] = []; const keysToDelete: string[] = [];
// Identify expired slugs for (const [key, lastUsed] of this.#usageTracker.entries()) {
for (const [slug, lastUsed] of this.#usageTracker.entries()) {
if (now - lastUsed > this.#TTL) { if (now - lastUsed > this.#TTL) {
const batchId = this.#slugToBatch.get(slug); const batchId = this.#slugToBatch.get(key);
if (batchId) batchesToPotentialDelete.add(batchId); if (batchId) batchesToPotentialDelete.add(batchId);
slugsToDelete.push(slug); keysToDelete.push(key);
} }
} }
// Only remove a batch if ALL fonts in that batch are expired
batchesToPotentialDelete.forEach(batchId => { batchesToPotentialDelete.forEach(batchId => {
const batchSlugs = Array.from(this.#slugToBatch.entries()) const batchKeys = Array.from(this.#slugToBatch.entries())
.filter(([_, bId]) => bId === batchId) .filter(([_, bId]) => bId === batchId)
.map(([slug]) => slug); .map(([key]) => key);
const allExpired = batchSlugs.every(s => slugsToDelete.includes(s)); const allExpired = batchKeys.every(k => keysToDelete.includes(k));
if (allExpired) { if (allExpired) {
this.#batchElements.get(batchId)?.remove(); this.#batchElements.get(batchId)?.remove();
this.#batchElements.delete(batchId); this.#batchElements.delete(batchId);
batchSlugs.forEach(s => { batchKeys.forEach(k => {
this.#slugToBatch.delete(s); this.#slugToBatch.delete(k);
this.#usageTracker.delete(s); this.#usageTracker.delete(k);
this.statuses.delete(k);
}); });
} }
}); });

View File

@@ -20,6 +20,10 @@ interface Props {
* Font id to load * Font id to load
*/ */
id: string; id: string;
/**
* Font weight
*/
weight?: number;
/** /**
* Additional classes * Additional classes
*/ */
@@ -30,7 +34,7 @@ interface Props {
children?: Snippet; children?: Snippet;
} }
let { name, id, className, children }: Props = $props(); let { name, id, weight = 400, className, children }: Props = $props();
let element: Element; let element: Element;
// Track if the user has actually scrolled this into view // Track if the user has actually scrolled this into view
@@ -40,7 +44,7 @@ $effect(() => {
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) { if (entries[0].isIntersecting) {
hasEnteredViewport = true; hasEnteredViewport = true;
appliedFontsManager.touch([id]); appliedFontsManager.touch([{ slug: id, weight }]);
// Once it has entered, we can stop observing to save CPU // Once it has entered, we can stop observing to save CPU
observer.unobserve(element); observer.unobserve(element);
@@ -50,7 +54,7 @@ $effect(() => {
return () => observer.disconnect(); return () => observer.disconnect();
}); });
const status = $derived(appliedFontsManager.getFontStatus(id)); const status = $derived(appliedFontsManager.getFontStatus(id, weight, false));
// The "Show" condition: Element is in view AND (Font is ready OR it errored out) // The "Show" condition: Element is in view AND (Font is ready OR it errored out)
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error')); const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { appliedFontsManager } from '$entities/Font'; import { appliedFontsManager } from '$entities/Font';
import { controlManager } from '$features/SetupFont';
import { Input } from '$shared/shadcn/ui/input'; import { Input } from '$shared/shadcn/ui/input';
import { ComparisonSlider } from '$shared/ui'; import { ComparisonSlider } from '$widgets/ComparisonSlider/ui';
import { displayedFontsStore } from '../../model'; import { displayedFontsStore } from '../../model';
import PairSelector from '../PairSelector/PairSelector.svelte'; import PairSelector from '../PairSelector/PairSelector.svelte';
@@ -10,7 +11,9 @@ const [fontA, fontB] = $derived(displayedFontsStore.selectedPair);
const hasAnyPairs = $derived(displayedFontsStore.fonts.length > 0); const hasAnyPairs = $derived(displayedFontsStore.fonts.length > 0);
$effect(() => { $effect(() => {
appliedFontsManager.touch(displayedFontsStore.fonts.map(font => font.id)); appliedFontsManager.touch(
displayedFontsStore.fonts.map(font => ({ slug: font.id, weight: controlManager.weight })),
);
}); });
</script> </script>
@@ -22,7 +25,11 @@ $effect(() => {
</div> </div>
{#if fontA && fontB} {#if fontA && fontB}
<ComparisonSlider fontA={fontA} fontB={fontB} text={displayedText} /> <ComparisonSlider
fontA={fontA}
fontB={fontB}
bind:text={displayedText}
/>
{/if} {/if}
</div> </div>
{/if} {/if}

View File

@@ -7,6 +7,7 @@ import {
FontApplicator, FontApplicator,
type UnifiedFont, type UnifiedFont,
} from '$entities/Font'; } from '$entities/Font';
import { controlManager } from '$features/SetupFont';
import { ContentEditable } from '$shared/ui'; import { ContentEditable } from '$shared/ui';
interface Props { interface Props {
@@ -31,6 +32,8 @@ let {
text = $bindable(), text = $bindable(),
...restProps ...restProps
}: Props = $props(); }: Props = $props();
const weight = $derived(controlManager.weight ?? 400);
</script> </script>
<div <div
@@ -39,6 +42,7 @@ let {
bg-white p-6 border border-slate-200 bg-white p-6 border border-slate-200
shadow-sm dark:border-slate-800 dark:bg-slate-950 shadow-sm dark:border-slate-800 dark:bg-slate-950
" "
style:font-weight={weight}
> >
<FontApplicator id={font.id} name={font.name}> <FontApplicator id={font.id} name={font.name}>
<ContentEditable bind:text={text} {...restProps} /> <ContentEditable bind:text={text} {...restProps} />

View File

@@ -1,7 +1,49 @@
import { import {
type ControlModel, type ControlModel,
type TypographyControl,
createTypographyControl, createTypographyControl,
} from '$shared/lib'; } from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity';
export interface Control {
id: string;
increaseLabel?: string;
decreaseLabel?: string;
controlLabel?: string;
instance: TypographyControl;
}
export class TypographyControlManager {
#controls = new SvelteMap<string, Control>();
constructor(configs: ControlModel[]) {
configs.forEach(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => {
this.#controls.set(id, {
id,
increaseLabel,
decreaseLabel,
controlLabel,
instance: createTypographyControl(config),
});
});
}
get controls() {
return this.#controls.values();
}
get weight() {
return this.#controls.get('font_weight')?.instance.value ?? 400;
}
get size() {
return this.#controls.get('font_size')?.instance.value;
}
get height() {
return this.#controls.get('line_height')?.instance.value;
}
}
/** /**
* Creates a typography control manager that handles a collection of typography controls. * Creates a typography control manager that handles a collection of typography controls.
@@ -10,19 +52,5 @@ import {
* @returns - Typography control manager instance. * @returns - Typography control manager instance.
*/ */
export function createTypographyControlManager(configs: ControlModel[]) { export function createTypographyControlManager(configs: ControlModel[]) {
const controls = $state( return new TypographyControlManager(configs);
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({
id,
increaseLabel,
decreaseLabel,
controlLabel,
instance: createTypographyControl(config),
})),
);
return {
get controls() {
return controls;
},
};
} }

View File

@@ -19,6 +19,8 @@ export function createCharacterComparison(
text: () => string, text: () => string,
fontA: () => { name: string; id: string }, fontA: () => { name: string; id: string },
fontB: () => { name: string; id: string }, fontB: () => { name: string; id: string },
weight: () => number,
size: () => number,
) { ) {
let lines = $state<LineData[]>([]); let lines = $state<LineData[]>([]);
let containerWidth = $state(0); let containerWidth = $state(0);
@@ -29,14 +31,16 @@ export function createCharacterComparison(
* @param text - Text string to measure * @param text - Text string to measure
* @param fontFamily - Font family name * @param fontFamily - Font family name
* @param fontSize - Font size in pixels * @param fontSize - Font size in pixels
* @param fontWeight - Font weight
*/ */
function measureText( function measureText(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
text: string, text: string,
fontFamily: string, fontFamily: string,
fontSize: number, fontSize: number,
fontWeight: number,
): number { ): number {
ctx.font = `bold ${fontSize}px ${fontFamily}`; ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
return ctx.measureText(text).width; return ctx.measureText(text).width;
} }
@@ -45,7 +49,13 @@ export function createCharacterComparison(
* Matches the Tailwind breakpoints used in the component. * Matches the Tailwind breakpoints used in the component.
*/ */
function getFontSize() { function getFontSize() {
if (typeof window === 'undefined') return 64; // const fontSize = size();
// if (fontSize) {
// return fontSize;
// }
if (typeof window === 'undefined') {
return 64;
}
return window.innerWidth >= 1024 return window.innerWidth >= 1024
? 112 ? 112
: window.innerWidth >= 768 : window.innerWidth >= 768
@@ -62,6 +72,7 @@ export function createCharacterComparison(
* @param container - The container element to measure width from * @param container - The container element to measure width from
* @param measureCanvas - The canvas element used for text measurement * @param measureCanvas - The canvas element used for text measurement
*/ */
function breakIntoLines( function breakIntoLines(
container: HTMLElement | undefined, container: HTMLElement | undefined,
measureCanvas: HTMLCanvasElement | undefined, measureCanvas: HTMLCanvasElement | undefined,
@@ -70,15 +81,15 @@ export function createCharacterComparison(
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
containerWidth = rect.width; containerWidth = rect.width;
// Padding considerations - matches the container padding // Padding considerations - matches the container padding
const padding = window.innerWidth < 640 ? 48 : 96; const padding = window.innerWidth < 640 ? 48 : 96;
const availableWidth = rect.width - padding; const availableWidth = rect.width - padding;
const ctx = measureCanvas.getContext('2d'); const ctx = measureCanvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
const controlledFontSize = size();
const fontSize = getFontSize(); const fontSize = getFontSize();
const currentWeight = weight(); // Get current weight
const words = text().split(' '); const words = text().split(' ');
const newLines: LineData[] = []; const newLines: LineData[] = [];
let currentLineWords: string[] = []; let currentLineWords: string[] = [];
@@ -86,9 +97,21 @@ export function createCharacterComparison(
function pushLine(words: string[]) { function pushLine(words: string[]) {
if (words.length === 0) return; if (words.length === 0) return;
const lineText = words.join(' '); const lineText = words.join(' ');
// Measure width to ensure we know exactly how wide this line renders // Measure both fonts at the CURRENT weight
const widthA = measureText(ctx!, lineText, fontA().name, fontSize); const widthA = measureText(
const widthB = measureText(ctx!, lineText, fontB().name, fontSize); ctx!,
lineText,
fontA().name,
Math.min(fontSize, controlledFontSize),
currentWeight,
);
const widthB = measureText(
ctx!,
lineText,
fontB().name,
Math.min(fontSize, controlledFontSize),
currentWeight,
);
const maxWidth = Math.max(widthA, widthB); const maxWidth = Math.max(widthA, widthB);
newLines.push({ text: lineText, width: maxWidth }); newLines.push({ text: lineText, width: maxWidth });
} }
@@ -97,10 +120,21 @@ export function createCharacterComparison(
const testLine = currentLineWords.length > 0 const testLine = currentLineWords.length > 0
? currentLineWords.join(' ') + ' ' + word ? currentLineWords.join(' ') + ' ' + word
: word; : word;
// Measure with both fonts and use the wider one to prevent layout shifts // Measure with both fonts and use the wider one to prevent layout shifts
const widthA = measureText(ctx, testLine, fontA().name, fontSize); const widthA = measureText(
const widthB = measureText(ctx, testLine, fontB().name, fontSize); ctx,
testLine,
fontA().name,
Math.min(fontSize, controlledFontSize),
currentWeight,
);
const widthB = measureText(
ctx,
testLine,
fontB().name,
Math.min(fontSize, controlledFontSize),
currentWeight,
);
const maxWidth = Math.max(widthA, widthB); const maxWidth = Math.max(widthA, widthB);
if (maxWidth > availableWidth && currentLineWords.length > 0) { if (maxWidth > availableWidth && currentLineWords.length > 0) {
@@ -111,10 +145,7 @@ export function createCharacterComparison(
} }
} }
if (currentLineWords.length > 0) { if (currentLineWords.length > 0) pushLine(currentLineWords);
pushLine(currentLineWords);
}
lines = newLines; lines = newLines;
} }

View File

@@ -30,15 +30,15 @@ export interface ControlModel extends ControlDataModel {
/** /**
* Area label for increase button * Area label for increase button
*/ */
increaseLabel: string; increaseLabel?: string;
/** /**
* Area label for decrease button * Area label for decrease button
*/ */
decreaseLabel: string; decreaseLabel?: string;
/** /**
* Control area label * Control area label
*/ */
controlLabel: string; controlLabel?: string;
} }
export function createTypographyControl<T extends ControlDataModel>( export function createTypographyControl<T extends ControlDataModel>(

View File

@@ -29,4 +29,5 @@ export {
export { export {
createCharacterComparison, createCharacterComparison,
type LineData,
} from './createCharacterComparison/createCharacterComparison.svelte'; } from './createCharacterComparison/createCharacterComparison.svelte';

View File

@@ -11,6 +11,7 @@ export {
type EntityStore, type EntityStore,
type Filter, type Filter,
type FilterModel, type FilterModel,
type LineData,
type Property, type Property,
type TypographyControl, type TypographyControl,
type VirtualItem, type VirtualItem,

View File

@@ -8,9 +8,13 @@
<script lang="ts"> <script lang="ts">
import type { TypographyControl } from '$shared/lib'; import type { TypographyControl } from '$shared/lib';
import { Button } from '$shared/shadcn/ui/button'; import { Button } from '$shared/shadcn/ui/button';
import * as ButtonGroup from '$shared/shadcn/ui/button-group'; import { Root as ButtonGroupRoot } from '$shared/shadcn/ui/button-group';
import { Input } from '$shared/shadcn/ui/input'; import { Input } from '$shared/shadcn/ui/input';
import * as Popover from '$shared/shadcn/ui/popover'; import {
Content as PopoverContent,
Root as PopoverRoot,
Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover';
import { Slider } from '$shared/shadcn/ui/slider'; import { Slider } from '$shared/shadcn/ui/slider';
import MinusIcon from '@lucide/svelte/icons/minus'; import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
@@ -67,30 +71,32 @@ const handleSliderChange = (newValue: number) => {
}; };
</script> </script>
<ButtonGroup.Root> <ButtonGroupRoot class="bg-transparent border-none shadow-none">
<Button <Button
variant="outline" variant="ghost"
class="hover:bg-white/50 bg-white/20 border-none"
size="icon" size="icon"
aria-label={decreaseLabel} aria-label={decreaseLabel}
onclick={control.decrease} onclick={control.decrease}
disabled={control.isAtMin} disabled={control.isAtMin}
> >
<MinusIcon /> <MinusIcon class="size-4" />
</Button> </Button>
<Popover.Root> <PopoverRoot>
<Popover.Trigger> <PopoverTrigger>
{#snippet child({ props })} {#snippet child({ props })}
<Button <Button
{...props} {...props}
variant="outline" variant="ghost"
class="hover:bg-white/50 bg-white/20 border-none"
size="icon" size="icon"
aria-label={controlLabel} aria-label={controlLabel}
> >
{control.value} {control.value}
</Button> </Button>
{/snippet} {/snippet}
</Popover.Trigger> </PopoverTrigger>
<Popover.Content class="w-auto p-4"> <PopoverContent class="w-auto p-4">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
<Slider <Slider
min={control.min} min={control.min}
@@ -110,15 +116,16 @@ const handleSliderChange = (newValue: number) => {
class="w-16 text-center" class="w-16 text-center"
/> />
</div> </div>
</Popover.Content> </PopoverContent>
</Popover.Root> </PopoverRoot>
<Button <Button
variant="outline" variant="ghost"
class="hover:bg-white/50 bg-white/20 border-none"
size="icon" size="icon"
aria-label={increaseLabel} aria-label={increaseLabel}
onclick={control.increase} onclick={control.increase}
disabled={control.isAtMax} disabled={control.isAtMax}
> >
<PlusIcon /> <PlusIcon class="size-4" />
</Button> </Button>
</ButtonGroup.Root> </ButtonGroupRoot>

View File

@@ -0,0 +1,72 @@
<!--
Component: ComboControl
Provides the same functionality as the original ComboControl but lacks increase/decrease buttons.
-->
<script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { Input } from '$shared/shadcn/ui/input';
import { Slider } from '$shared/shadcn/ui/slider';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import type { ChangeEventHandler } from 'svelte/elements';
interface Props {
control: TypographyControl;
ref?: Snippet;
}
let {
control,
ref = $bindable(),
}: Props = $props();
let sliderValue = $state(Number(control.value));
$effect(() => {
sliderValue = Number(control.value);
});
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
const parsedValue = parseFloat(event.currentTarget.value);
if (!isNaN(parsedValue)) {
control.value = parsedValue;
}
};
const handleSliderChange = (newValue: number) => {
control.value = newValue;
};
// Shared glass button class for consistency
// const glassBtnClass = cn(
// 'border-none transition-all duration-200',
// 'bg-white/10 hover:bg-white/40 active:scale-90',
// 'text-slate-900 font-medium',
// );
// const ghostStyle = cn(
// 'flex items-center justify-center transition-all duration-300 ease-out',
// 'text-slate-900/40 hover:text-slate-950 hover:bg-white/20 active:scale-90',
// 'disabled:opacity-10 disabled:pointer-events-none',
// );
</script>
<div class="flex flex-col items-center gap-4">
<Input
value={control.value}
onchange={handleInputChange}
min={control.min}
max={control.max}
class="w-14 h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50"
/>
<Slider
min={control.min}
max={control.max}
step={control.step}
value={sliderValue}
onValueChange={handleSliderChange}
type="single"
orientation="vertical"
class="h-30"
/>
</div>

View File

@@ -1,182 +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.
-->
<script lang="ts" generics="T extends { name: string; id: string }">
import { createCharacterComparison } from '$shared/lib';
import { Spring } from 'svelte/motion';
import Labels from './components/Labels.svelte';
import SliderLine from './components/SliderLine.svelte';
interface Props<T extends { name: string; id: string }> {
/** First font definition ({name, id}) */
fontA: T;
/** Second font definition ({name, id}) */
fontB: T;
/** Text to display and compare */
text?: string;
}
let {
fontA,
fontB,
text = 'The quick brown fox jumps over the lazy dog',
}: Props<T> = $props();
let container: HTMLElement | undefined = $state();
let measureCanvas: HTMLCanvasElement | undefined = $state();
let isDragging = $state(false);
/**
* 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(
() => text,
() => fontA,
() => fontB,
);
/** 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);
}
$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(() => {
if (container && measureCanvas) {
// 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);
});
</script>
<!-- Hidden canvas used for text measurement by the helper -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<div
bind:this={container}
role="slider"
tabindex="0"
aria-valuenow={Math.round(sliderPos)}
aria-label="Font comparison slider"
onpointerdown={startDragging}
class="group relative w-full py-16 px-6 sm:py-24 sm:px-12 overflow-hidden bg-white rounded-[2.5rem] border border-slate-100 shadow-2xl select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center"
>
<!-- Background Gradient Accent -->
<div
class="
absolute inset-0 bg-linear-to-br
from-slate-50/50 via-white to-slate-100/50
opacity-50 pointer-events-none
"
>
</div>
<!-- Text Rendering Container -->
<div
class="relative flex flex-col items-center gap-4 text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.15] z-10 pointer-events-none text-center"
style:perspective="1000px"
>
{#each charComparison.lines as line, lineIndex}
<div class="relative w-full whitespace-nowrap">
{#each line.text.split('') as char, charIndex}
{@const { proximity, isPast } = charComparison.getCharState(lineIndex, charIndex, line, sliderPos)}
<!--
Single Character Span
- Font Family switches based on `isPast`
- Transitions/Transforms provide the "morph" feel
-->
<span
class="inline-block transition-all duration-300 ease-out will-change-transform"
style:font-family={isPast ? fontB.name : fontA.name}
style:color={isPast
? 'rgb(79, 70, 229)'
: 'rgb(15, 23, 42)'}
style:transform="
scale({1 + proximity * 0.2}) translateY({-proximity *
12}px) rotateY({proximity *
25 *
(isPast ? -1 : 1)}deg)
"
style:will-change={proximity > 0
? 'transform, font-family, color'
: 'auto'}
>
{char === ' ' ? '\u00A0' : char}
</span>
{/each}
</div>
{/each}
</div>
<!-- Visual Components -->
<SliderLine {sliderPos} />
<Labels {fontA} {fontB} {sliderPos} />
</div>
<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,26 +0,0 @@
<script lang="ts">
interface Props {
sliderPos: number;
}
let { sliderPos }: Props = $props();
</script>
<!-- Vertical Divider & Knobs -->
<div
class="absolute top-0 bottom-0 z-30 pointer-events-none"
style:left="{sliderPos}%"
>
<!-- Vertical Line -->
<div class="absolute inset-y-0 -left-px w-0.5 bg-indigo-500 shadow-[0_0_10px_rgba(99,102,241,0.5)]">
</div>
<!-- Top Knob -->
<div class="absolute top-6 left-0 -translate-x-1/2">
<div class="w-2.5 h-2.5 bg-indigo-500 rounded-full shadow ring-2 ring-white"></div>
</div>
<!-- Bottom Knob -->
<div class="absolute bottom-6 left-0 -translate-x-1/2">
<div class="w-2.5 h-2.5 bg-indigo-500 rounded-full shadow ring-2 ring-white"></div>
</div>
</div>

View File

@@ -6,7 +6,7 @@
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte'; import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
import ComboControl from './ComboControl/ComboControl.svelte'; import ComboControl from './ComboControl/ComboControl.svelte';
import ComparisonSlider from './ComparisonSlider/ComparisonSlider.svelte'; import ComboControlV2 from './ComboControlV2/ComboControlV2.svelte';
import ContentEditable from './ContentEditable/ContentEditable.svelte'; import ContentEditable from './ContentEditable/ContentEditable.svelte';
import SearchBar from './SearchBar/SearchBar.svelte'; import SearchBar from './SearchBar/SearchBar.svelte';
import VirtualList from './VirtualList/VirtualList.svelte'; import VirtualList from './VirtualList/VirtualList.svelte';
@@ -14,7 +14,7 @@ import VirtualList from './VirtualList/VirtualList.svelte';
export { export {
CheckboxFilter, CheckboxFilter,
ComboControl, ComboControl,
ComparisonSlider, ComboControlV2,
ContentEditable, ContentEditable,
SearchBar, SearchBar,
VirtualList, VirtualList,

View File

@@ -0,0 +1,243 @@
<!--
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.
-->
<script lang="ts" generics="T extends { name: string; id: string }">
import {
createCharacterComparison,
createTypographyControl,
} from '$shared/lib';
import type { LineData } from '$shared/lib';
import { Spring } from 'svelte/motion';
import CharacterSlot from './components/CharacterSlot.svelte';
import ControlsWrapper from './components/ControlsWrapper.svelte';
import Labels from './components/Labels.svelte';
import SliderLine from './components/SliderLine.svelte';
interface Props<T extends { name: string; id: string }> {
/**
* First font definition ({name, id})
*/
fontA: T;
/**
* Second font definition ({name, id})
*/
fontB: T;
/**
* Text to display and compare
*/
text?: string;
}
let {
fontA,
fontB,
text = $bindable('The quick brown fox jumps over the lazy dog'),
}: Props<T> = $props();
let container: HTMLElement | undefined = $state();
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
let measureCanvas: HTMLCanvasElement | undefined = $state();
let isDragging = $state(false);
const weightControl = createTypographyControl({
min: 100,
max: 700,
step: 100,
value: 400,
});
const heightControl = createTypographyControl({
min: 1,
max: 2,
step: 0.05,
value: 1.2,
});
const sizeControl = createTypographyControl({
min: 1,
max: 112,
step: 1,
value: 64,
});
/**
* 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(
() => text,
() => fontA,
() => fontB,
() => weightControl.value,
() => sizeControl.value,
);
/** 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) {
if (e.target === controlsWrapperElement || controlsWrapperElement?.contains(e.target as Node)) {
console.log('Pointer down on controls wrapper');
e.stopPropagation();
return;
}
e.preventDefault();
isDragging = true;
handleMove(e);
}
$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 = text;
const _weight = weightControl.value;
const _size = sizeControl.value;
const _height = heightControl.value;
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);
});
</script>
{#snippet renderLine(line: LineData, lineIndex: number)}
<div
class="relative flex w-full justify-center items-center whitespace-nowrap"
style:height={`${heightControl.value}em`}
style:line-height={`${heightControl.value}em`}
>
{#each line.text.split('') as char, charIndex}
{@const { proximity, isPast } = charComparison.getCharState(lineIndex, charIndex, line, sliderPos)}
<!--
Single Character Span
- Font Family switches based on `isPast`
- Transitions/Transforms provide the "morph" feel
-->
<CharacterSlot
{char}
{proximity}
{isPast}
weight={weightControl.value}
size={sizeControl.value}
fontAName={fontA.name}
fontBName={fontB.name}
/>
{/each}
</div>
{/snippet}
<!-- Hidden canvas used for text measurement by the helper -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<div class="relative">
<div
bind:this={container}
role="slider"
tabindex="0"
aria-valuenow={Math.round(sliderPos)}
aria-label="Font comparison slider"
onpointerdown={startDragging}
class="
group relative w-full py-16 px-6 sm:py-24 sm:px-12 overflow-hidden
bg-indigo-50 rounded-[2.5rem] border border-slate-100 shadow-2xl
select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center
"
class:box-shadow={'-20px 20px 60px #bebebe, 20px -20px 60px #ffffff;'}
>
<!-- Background Gradient Accent -->
<div
class="
absolute inset-0 bg-linear-to-br
from-slate-50/50 via-white to-slate-100/50
opacity-50 pointer-events-none
"
>
</div>
<!-- Text Rendering Container -->
<div
class="
relative flex flex-col items-center gap-4
text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.15]
z-10 pointer-events-none text-center
"
style:perspective="1000px"
>
{#each charComparison.lines as line, lineIndex}
<div
class="relative w-full whitespace-nowrap"
style:height={`${heightControl.value}em`}
style:display="flex"
style:align-items="center"
style:justify-content="center"
>
{@render renderLine(line, lineIndex)}
</div>
{/each}
</div>
<!-- Visual Components -->
<SliderLine {sliderPos} {isDragging} />
<Labels {fontA} {fontB} {sliderPos} />
</div>
<!-- Since there're slider controls inside we put them outside the main one -->
<ControlsWrapper
bind:wrapper={controlsWrapperElement}
{sliderPos}
{isDragging}
bind:text={text}
containerWidth={container?.clientWidth}
weightControl={weightControl}
sizeControl={sizeControl}
heightControl={heightControl}
/>
</div>

View File

@@ -0,0 +1,77 @@
<!--
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';
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;
/**
* Font weight of the character
*/
weight: number;
/**
* Font size of the character
*/
size: number;
/**
* Name of the font for the character after the change
*/
fontAName: string;
/**
* Name of the font for the character before the change
*/
fontBName: string;
}
let { char, proximity, isPast, weight, size, fontAName, fontBName }: Props = $props();
</script>
<span
class={cn(
'inline-block transition-all duration-300 ease-out will-change-transform',
isPast ? 'text-indigo-500' : 'text-neutral-950',
)}
style:font-family={isPast ? fontBName : fontAName}
style:font-weight={weight}
style:font-size={`${size}px`}
style:transform="
scale({1 + proximity * 0.2})
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>
<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

@@ -0,0 +1,241 @@
<script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { Input } from '$shared/shadcn/ui/input';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { ComboControlV2 } from '$shared/ui';
import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
import { Spring } from 'svelte/motion';
import { slide } from 'svelte/transition';
interface Props {
wrapper?: HTMLDivElement | null;
sliderPos: number;
isDragging: boolean;
text: string;
containerWidth: number;
weightControl: TypographyControl;
sizeControl: TypographyControl;
heightControl: TypographyControl;
}
let {
sliderPos,
isDragging,
wrapper = $bindable(null),
text = $bindable(),
containerWidth = 0,
weightControl,
sizeControl,
heightControl,
}: Props = $props();
let panelWidth = $state(0);
const margin = 24;
let side = $state<'left' | 'right'>('left');
// Unified active state for the entire wrapper
let isActive = $state(false);
function handleWrapperClick() {
if (!isDragging) {
isActive = true;
}
}
function handleClickOutside(e: MouseEvent) {
if (wrapper && !wrapper.contains(e.target as Node)) {
isActive = false;
}
}
function handleInputFocus() {
isActive = true;
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleWrapperClick();
}
}
// Movement Logic
$effect(() => {
if (containerWidth === 0 || panelWidth === 0) 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';
}
});
// The "Dodge"
const xSpring = new Spring(0, {
stiffness: 0.14, // Lower is slower
damping: 0.5, // Settle
});
// The "Focus"
const ySpring = new Spring(0, {
stiffness: 0.32,
damping: 0.65,
});
// The "Rise"
const scaleSpring = new Spring(1, {
stiffness: 0.32,
damping: 0.65,
});
// The "Lean"
const rotateSpring = new Spring(0, {
stiffness: 0.12,
damping: 0.55,
});
$effect(() => {
const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0;
if (containerWidth > 0 && panelWidth > 0 && !isActive) {
// On side change set the position and the rotation
xSpring.target = targetX;
rotateSpring.target = side === 'right' ? 3.5 : -3.5;
setTimeout(() => {
rotateSpring.target = 0;
}, 600);
}
});
// Elevation and scale on focus and mouse over
$effect(() => {
if (isActive && !isDragging) {
// Lift up
ySpring.target = 8;
// Slightly bigger
scaleSpring.target = 1.1;
rotateSpring.target = side === 'right' ? -1.1 : 1.1;
setTimeout(() => {
rotateSpring.target = 0;
scaleSpring.target = 1.05;
}, 300);
} else {
ySpring.target = 0;
scaleSpring.target = 1;
rotateSpring.target = 0;
}
});
$effect(() => {
if (isDragging) {
isActive = false;
}
});
// Click outside handler
$effect(() => {
if (typeof window === 'undefined') return;
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
</script>
<div
onclick={handleWrapperClick}
bind:this={wrapper}
bind:clientWidth={panelWidth}
class={cn(
'absolute top-6 left-6 z-50 will-change-transform transition-opacity duration-300 flex items-top gap-1.5',
panelWidth === 0 ? 'opacity-0' : 'opacity-100',
)}
style:pointer-events={isDragging ? 'none' : 'auto'}
style:transform="
translate({xSpring.current}px, {ySpring.current}px)
scale({scaleSpring.current})
rotateZ({rotateSpring.current}deg)
"
role="button"
tabindex={0}
onkeydown={handleKeyDown}
aria-label="Font controls"
>
<div
class={cn(
'animate-nudge relative transition-all',
side === 'left' ? 'order-2' : 'order-0',
isActive ? 'opacity-0' : 'opacity-100',
isDragging && 'opacity-40 grayscale-[0.2]',
)}
>
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
</div>
<div
class={cn(
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5',
isActive
? 'bg-white/95 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
: ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
isDragging && 'opacity-40 grayscale-[0.2]',
)}
style:backdrop-filter="blur(24px)"
>
<div class="relative px-2 py-1">
<Input
bind:value={text}
disabled={isDragging}
onfocusin={handleInputFocus}
class={cn(
isActive
? 'h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50 text-slate-900'
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm font-medium focus-visible:ring-0 text-slate-900/50',
' placeholder:text-slate-400 selection:bg-indigo-100 flex-1 transition-all duration-350 w-56',
)}
placeholder="Edit label..."
/>
</div>
{#if isActive}
<div
in:slide={{ duration: 250, delay: 50 }}
out:slide={{ duration: 250 }}
class="flex justify-between items-center-safe"
>
<ComboControlV2 control={weightControl} />
<ComboControlV2 control={sizeControl} />
<ComboControlV2 control={heightControl} />
</div>
{/if}
</div>
</div>
<style>
@keyframes nudge {
0%, 100% {
transform: translateY(0) scale(1) rotate(0deg);
}
2% {
transform: translateY(-2px) scale(1.1) rotate(-1deg);
}
4% {
transform: translateY(0) scale(1) rotate(1deg);
}
6% {
transform: translateY(-2px) scale(1.1) rotate(0deg);
}
8% {
transform: translateY(0) scale(1) rotate(0deg);
}
}
.animate-nudge {
animation: nudge 10s ease-in-out infinite;
}
</style>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props {
sliderPos: number;
isDragging: boolean;
}
let { sliderPos, isDragging }: Props = $props();
</script>
<div
class="absolute inset-y-0 pointer-events-none -translate-x-1/2 z-30"
style:left="{sliderPos}%"
>
<!-- Subtle wave glow zone -->
<div
class={cn(
'absolute inset-y-0 w-24 -left-12 bg-linear-to-r from-transparent via-indigo-500/8 to-transparent transition-all duration-300',
isDragging ? 'via-indigo-500/12' : '',
)}
>
</div>
<!-- Vertical divider line -->
<div
class="absolute inset-y-0 w-0.5 bg-linear-to-b from-indigo-400/30 via-indigo-500 to-indigo-400/30 shadow-[0_0_12px_rgba(99,102,241,0.5)] transition-shadow duration-200"
class:shadow-[0_0_20px_rgba(99,102,241,0.7)]={isDragging}
>
</div>
<!-- Top knob -->
<div
class="absolute top-6 left-0 -translate-x-1/2 transition-transform duration-200"
class:scale-125={isDragging}
>
<div class="w-3 h-3 bg-indigo-500 rounded-full shadow-lg ring-2 ring-white"></div>
</div>
<!-- Bottom knob -->
<div
class="absolute bottom-6 left-0 -translate-x-1/2 transition-transform duration-200"
class:scale-125={isDragging}
>
<div class="w-3 h-3 bg-indigo-500 rounded-full shadow-lg ring-2 ring-white"></div>
</div>
</div>
<!--
<style>
div {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4);
}
</style>
-->

View File

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