Compare commits

...

15 Commits

Author SHA1 Message Date
Ilia Mashkov
2022213921 feat(displayedFontsStore): fix store to work with fontA and fontB to compare 2026-01-26 12:56:35 +03:00
Ilia Mashkov
6725a3b391 feat(Layout): increase container width 2026-01-26 12:55:52 +03:00
Ilia Mashkov
2eddb656a9 chore(FontComparer): delete unused code 2026-01-26 12:55:27 +03:00
Ilia Mashkov
5973d241aa feat(Page): render ComparisonSlider directly 2026-01-26 12:54:40 +03:00
Ilia Mashkov
75a9c16070 feat(ComparisonWrapper): remove props and add checks for fonts absence 2026-01-26 12:54:01 +03:00
Ilia Mashkov
31e4c64193 chore(ComparisonSlider): add comments 2026-01-26 12:52:40 +03:00
Ilia Mashkov
48e25fffa7 feat(ExpandableWrapper): fix keyboard support, tweak styles and animation 2026-01-26 12:52:23 +03:00
Ilia Mashkov
407c741349 feat(ComparisonSlider): add blur background to controls 2026-01-26 12:49:15 +03:00
Ilia Mashkov
13e114fafe feat(TypographyMenu): add appearance/disappearance animation 2026-01-26 12:47:10 +03:00
Ilia Mashkov
1484ea024e chore(ComparisonSlider): add comments and remove unused code 2026-01-26 12:46:12 +03:00
Ilia Mashkov
67db6e22a7 feat(ComparisonSlider): rewrite slider labels to include selects for compared fonts 2026-01-26 12:45:30 +03:00
Ilia Mashkov
192ce2d34a feat(select): add shadcn select component 2026-01-26 12:43:25 +03:00
Ilia Mashkov
2b820230bc feat(createCharacterComparison): add generic for font type and checks for the absence of the fonts 2026-01-26 12:34:27 +03:00
Ilia Mashkov
9b8ebed1c3 fix(breakIntoLines): add word break for long words 2026-01-25 11:42:05 +03:00
Ilia Mashkov
3d11f7317d feat(ExpandableWrapper): add stories 2026-01-25 08:23:11 +03:00
28 changed files with 739 additions and 271 deletions

View File

@@ -41,7 +41,7 @@ let { children }: Props = $props();
<header></header>
<ScrollArea class="h-screen w-screen">
<main class="flex-1 w-full max-w-5xl mx-auto px-4 py-6 md:px-8 lg:py-10 relative">
<main class="flex-1 w-full max-w-6xl mx-auto px-4 py-6 md:px-8 lg:py-10 relative">
<TooltipProvider>
<TypographyMenu />
{@render children?.()}

View File

@@ -15,18 +15,9 @@ export class DisplayedFontsStore {
return selectedFontsStore.all;
});
#fontPairs = $derived.by(() => {
const fonts = this.#displayedFonts;
const pairs = fonts.flatMap((v1, i) =>
fonts.slice(i + 1).map<[UnifiedFont, UnifiedFont]>(v2 => [v1, v2])
);
if (pairs.length && this.isSelectedPairEmpty()) {
this.selectedPair = pairs[0];
}
return pairs;
});
#fontA = $state<UnifiedFont | undefined>(undefined);
#selectedPair = $state<Partial<[UnifiedFont, UnifiedFont]>>([]);
#fontB = $state<UnifiedFont | undefined>(undefined);
#hasAnySelectedFonts = $derived(this.#displayedFonts.length > 0);
@@ -34,18 +25,20 @@ export class DisplayedFontsStore {
return this.#displayedFonts;
}
get pairs() {
return this.#fontPairs;
get fontA() {
return this.#fontA ?? this.#displayedFonts[0];
}
get selectedPair() {
return this.#selectedPair;
set fontA(font: UnifiedFont | undefined) {
this.#fontA = font;
}
set selectedPair(pair: Partial<[UnifiedFont, UnifiedFont]>) {
const [first, second] = this.#selectedPair;
const [newFist, newSecond] = pair;
this.#selectedPair = [newFist ?? first, newSecond ?? second];
get fontB() {
return this.#fontB ?? this.#displayedFonts[1];
}
set fontB(font: UnifiedFont | undefined) {
this.#fontB = font;
}
get text() {
@@ -60,9 +53,8 @@ export class DisplayedFontsStore {
return this.#hasAnySelectedFonts;
}
isSelectedPairEmpty(): boolean {
const [font1, font2] = this.#selectedPair;
return !font1 || !font2;
getById(id: string): UnifiedFont | undefined {
return selectedFontsStore.getById(id);
}
}

View File

@@ -1,37 +0,0 @@
<script lang="ts">
import { appliedFontsManager } from '$entities/Font';
import { controlManager } from '$features/SetupFont';
import { ComparisonSlider } from '$widgets/ComparisonSlider/ui';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { displayedFontsStore } from '../../model';
import PairSelector from '../PairSelector/PairSelector.svelte';
let displayedText = $state('The quick brown fox jumps over the lazy dog...');
const [fontA, fontB] = $derived(displayedFontsStore.selectedPair);
const hasAnyPairs = $derived(displayedFontsStore.fonts.length > 0);
$effect(() => {
appliedFontsManager.touch(
displayedFontsStore.fonts.map(font => ({ slug: font.id, weight: controlManager.weight })),
);
});
</script>
{#if hasAnyPairs}
<div class="flex flex-col gap-4">
<div class="flex flex-row gap-4">
<PairSelector />
</div>
{#if fontA && fontB}
<div in:fly={{ y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 }}>
<ComparisonSlider
fontA={fontA}
fontB={fontB}
bind:text={displayedText}
/>
</div>
{/if}
</div>
{/if}

View File

@@ -4,12 +4,9 @@
-->
<script lang="ts">
import { displayedFontsStore } from '../../model';
import FontComparer from '../FontComparer/FontComparer.svelte';
import FontSampler from '../FontSampler/FontSampler.svelte';
</script>
<FontComparer />
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))]">
{#each displayedFontsStore.fonts as font (font.id)}
<FontSampler font={font} bind:text={displayedFontsStore.text} />

View File

@@ -1,29 +0,0 @@
<script lang="ts">
import {
FontApplicator,
type UnifiedFont,
} from '$entities/Font';
interface Props {
pair: [UnifiedFont, UnifiedFont];
selectedPair: Partial<[UnifiedFont, UnifiedFont]>;
}
let { pair, selectedPair = $bindable() }: Props = $props();
const [font1, font2] = $derived(pair);
function handleClick() {
selectedPair = pair;
}
</script>
<div class="flex flex-row justify-between w-full" onclick={handleClick}>
<FontApplicator id={font1.id} name={font1.name}>
{font1.name}
</FontApplicator>
vs
<FontApplicator id={font2.id} name={font2.name}>
{font2.name}
</FontApplicator>
</div>

View File

@@ -1,34 +0,0 @@
<script lang="ts">
import { FontVirtualList } from '$entities/Font';
import { buttonVariants } from '$shared/shadcn/ui/button';
import {
Content as PopoverContent,
Root as PopoverRoot,
Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover';
import { displayedFontsStore } from '../../model';
import FontPair from '../FontPair/FontPair.svelte';
let open = $state(false);
const triggerContent = $derived.by(() => {
const [beforeFont, afterFont] = displayedFontsStore.selectedPair ?? [];
if (!beforeFont || !afterFont) return 'Select a pair';
return `${beforeFont.name} vs ${afterFont.name}`;
});
const triggerDisabled = $derived(displayedFontsStore.pairs.length === 0);
</script>
<PopoverRoot bind:open>
<PopoverTrigger class={buttonVariants({ variant: 'outline' })} disabled={triggerDisabled}>
{triggerContent}
</PopoverTrigger>
<PopoverContent>
<FontVirtualList items={displayedFontsStore.pairs}>
{#snippet children({ item: pair })}
<FontPair {pair} bind:selectedPair={displayedFontsStore.selectedPair} />
{/snippet}
</FontVirtualList>
</PopoverContent>
</PopoverRoot>

View File

@@ -1,5 +1,9 @@
<script lang="ts">
import { appliedFontsManager } from '$entities/Font';
import { displayedFontsStore } from '$features/DisplayFont';
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
import { controlManager } from '$features/SetupFont';
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
import { FontSearch } from '$widgets/FontSearch';
/**
@@ -9,6 +13,12 @@ import { FontSearch } from '$widgets/FontSearch';
let searchContainer: HTMLElement;
let isExpanded = $state(false);
$effect(() => {
appliedFontsManager.touch(
displayedFontsStore.fonts.map(font => ({ slug: font.id, weight: controlManager.weight })),
);
});
</script>
<!-- Font List -->
@@ -17,6 +27,8 @@ let isExpanded = $state(false);
<FontSearch bind:showFilters={isExpanded} />
</div>
<ComparisonSlider />
<div class="will-change-tranform transition-transform content">
<FontDisplay />
</div>

View File

@@ -15,16 +15,22 @@ export interface LineData {
* @param fontB - The second font definition
* @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState)
*/
export function createCharacterComparison(
export function createCharacterComparison<
T extends { name: string; id: string } | undefined = undefined,
>(
text: () => string,
fontA: () => { name: string; id: string },
fontB: () => { name: string; id: string },
fontA: () => T,
fontB: () => T,
weight: () => number,
size: () => number,
) {
let lines = $state<LineData[]>([]);
let containerWidth = $state(0);
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
return font !== undefined;
}
/**
* Measures text width using a canvas context.
* @param ctx - Canvas rendering context
@@ -36,10 +42,11 @@ export function createCharacterComparison(
function measureText(
ctx: CanvasRenderingContext2D,
text: string,
fontFamily: string,
fontSize: number,
fontWeight: number,
fontFamily?: string,
): number {
if (!fontFamily) return 0;
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
return ctx.measureText(text).width;
}
@@ -73,10 +80,11 @@ export function createCharacterComparison(
container: HTMLElement | undefined,
measureCanvas: HTMLCanvasElement | undefined,
) {
if (!container || !measureCanvas) return;
if (!container || !measureCanvas || !fontA() || !fontB()) return;
const rect = container.getBoundingClientRect();
containerWidth = rect.width;
// Padding considerations - matches the container padding
const padding = window.innerWidth < 640 ? 48 : 96;
const availableWidth = rect.width - padding;
@@ -91,22 +99,24 @@ export function createCharacterComparison(
let currentLineWords: string[] = [];
function pushLine(words: string[]) {
if (words.length === 0) return;
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
return;
}
const lineText = words.join(' ');
// Measure both fonts at the CURRENT weight
const widthA = measureText(
ctx!,
lineText,
fontA().name,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontA()?.name,
);
const widthB = measureText(
ctx!,
lineText,
fontB().name,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontB()?.name,
);
const maxWidth = Math.max(widthA, widthB);
newLines.push({ text: lineText, width: maxWidth });
@@ -120,20 +130,64 @@ export function createCharacterComparison(
const widthA = measureText(
ctx,
testLine,
fontA().name,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontA()?.name,
);
const widthB = measureText(
ctx,
testLine,
fontB().name,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontB()?.name,
);
const maxWidth = Math.max(widthA, widthB);
const isContainerOverflown = maxWidth > availableWidth;
if (maxWidth > availableWidth && currentLineWords.length > 0) {
if (isContainerOverflown) {
if (currentLineWords.length > 0) {
pushLine(currentLineWords);
currentLineWords = [];
}
let remainingWord = word;
while (remainingWord.length > 0) {
let low = 1;
let high = remainingWord.length;
let bestBreak = 1;
// Binary Search to find the maximum characters that fit
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const testFragment = remainingWord.slice(0, mid);
const wA = measureText(
ctx,
testFragment,
fontSize,
currentWeight,
fontA()?.name,
);
const wB = measureText(
ctx,
testFragment,
fontSize,
currentWeight,
fontB()?.name,
);
if (Math.max(wA, wB) <= availableWidth) {
bestBreak = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
pushLine([remainingWord.slice(0, bestBreak)]);
remainingWord = remainingWord.slice(bestBreak);
}
} else if (maxWidth > availableWidth && currentLineWords.length > 0) {
pushLine(currentLineWords);
currentLineWords = [word];
} else {
@@ -141,7 +195,9 @@ export function createCharacterComparison(
}
}
if (currentLineWords.length > 0) pushLine(currentLineWords);
if (currentLineWords.length > 0) {
pushLine(currentLineWords);
}
lines = newLines;
}

View File

@@ -0,0 +1,37 @@
import Content from './select-content.svelte';
import GroupHeading from './select-group-heading.svelte';
import Group from './select-group.svelte';
import Item from './select-item.svelte';
import Label from './select-label.svelte';
import Portal from './select-portal.svelte';
import ScrollDownButton from './select-scroll-down-button.svelte';
import ScrollUpButton from './select-scroll-up-button.svelte';
import Separator from './select-separator.svelte';
import Trigger from './select-trigger.svelte';
import Root from './select.svelte';
export {
Content,
Content as SelectContent,
Group,
Group as SelectGroup,
GroupHeading,
GroupHeading as SelectGroupHeading,
Item,
Item as SelectItem,
Label,
Label as SelectLabel,
Portal,
Portal as SelectPortal,
Root,
//
Root as Select,
ScrollDownButton,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton,
ScrollUpButton as SelectScrollUpButton,
Separator,
Separator as SelectSeparator,
Trigger,
Trigger as SelectTrigger,
};

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import {
type WithoutChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import type { WithoutChildrenOrChild } from '$shared/shadcn/utils/shadcn-utils.js';
import { Select as SelectPrimitive } from 'bits-ui';
import type { ComponentProps } from 'svelte';
import SelectPortal from './select-portal.svelte';
import SelectScrollDownButton from './select-scroll-down-button.svelte';
import SelectScrollUpButton from './select-scroll-up-button.svelte';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1',
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
import { Select as SelectPrimitive } from 'bits-ui';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import {
type WithoutChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import CheckIcon from '@lucide/svelte/icons/check';
import { Select as SelectPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import {
type WithElementRef,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import {
type WithoutChildrenOrChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { Select as SelectPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn('flex cursor-default items-center justify-center py-1', className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import {
type WithoutChildrenOrChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import { Select as SelectPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn('flex cursor-default items-center justify-center py-1', className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { Separator } from '$shared/shadcn/ui/separator/index.js';
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
import type { Separator as SeparatorPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...restProps}
/>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import {
type WithoutChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { Select as SelectPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
children,
size = 'default',
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: 'sm' | 'default';
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />

View File

@@ -84,6 +84,7 @@ const handleSliderChange = (newValue: number) => {
class="
group relative border-none size-9
bg-white/20 hover:bg-white/60
backdrop-blur-3xl
transition-all duration-200 ease-out
will-change-transform
hover:-translate-y-0.5

View File

@@ -0,0 +1,94 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import { createRawSnippet } from 'svelte';
import ExpandableWrapper from './ExpandableWrapper.svelte';
const visibleSnippet = createRawSnippet(() => ({
render: () =>
`<div class="w-48 p-2 font-bold text-indigo-600">
Always visible
</div>`,
}));
const hiddenSnippet = createRawSnippet(() => ({
render: () =>
`<div class="p-4 space-y-2 border-t border-indigo-100 mt-2">
<div class="h-4 w-full bg-indigo-100 rounded animate-pulse"></div>
<div class="h-4 w-2/3 bg-indigo-50 rounded animate-pulse"></div>
</div>`,
}));
const badgeSnippet = createRawSnippet(() => ({
render: () =>
`<div class="">
<span class="badge badge-primary">*</span>
</div>`,
}));
const { Story } = defineMeta({
title: 'Shared/ExpandableWrapper',
component: ExpandableWrapper,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Animated styled wrapper for content that can be expanded and collapsed.',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
args: {
expanded: false,
disabled: false,
rotation: 'clockwise',
visibleContent: visibleSnippet,
hiddenContent: hiddenSnippet,
},
argTypes: {
expanded: { control: 'boolean' },
disabled: { control: 'boolean' },
rotation: {
control: 'select',
options: ['clockwise', 'counterclockwise'],
},
},
});
</script>
<script lang="ts">
</script>
<Story name="With hidden content">
{#snippet children(args)}
<div class="p-12 bg-slate-100 min-h-[300px] flex justify-center items-start">
<ExpandableWrapper
{...args}
bind:expanded={args.expanded}
/>
</div>
{/snippet}
</Story>
<Story name="Disabled" args={{ disabled: true }}>
{#snippet children(args)}
<div class="p-12 bg-slate-100 min-h-[300px] flex justify-center items-start">
<ExpandableWrapper
{...args}
bind:expanded={args.expanded}
disabled={args.disabled}
/>
</div>
{/snippet}
</Story>
<Story name="With badge" args={{ badge: badgeSnippet }}>
{#snippet children(args)}
<div class="p-12 bg-slate-100 min-h-[300px] flex justify-center items-start">
<ExpandableWrapper
{...args}
bind:expanded={args.expanded}
/>
</div>
{/snippet}
</Story>

View File

@@ -5,6 +5,7 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing';
import type { HTMLAttributes } from 'svelte/elements';
import { Spring } from 'svelte/motion';
import { slide } from 'svelte/transition';
@@ -42,6 +43,10 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
* @default 'clockwise'
*/
rotation?: 'clockwise' | 'counterclockwise';
/**
* Classes for intermnal container
*/
containerClassName?: string;
}
let {
@@ -53,6 +58,7 @@ let {
badge,
rotation = 'clockwise',
class: className = '',
containerClassName = '',
...props
}: Props = $props();
@@ -91,7 +97,7 @@ function handleWrapperClick() {
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === 'Enter' || (e.key === ' ' && !expanded)) {
e.preventDefault();
handleWrapperClick();
}
@@ -169,17 +175,18 @@ $effect(() => {
class={cn(
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
expanded
? 'bg-white/95 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
? 'bg-white/5 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)]',
disabled && 'opacity-80 grayscale-[0.2]',
containerClassName,
)}
>
{@render visibleContent?.({ expanded, disabled })}
{#if expanded}
<div
in:slide={{ duration: 250, delay: 50 }}
out:slide={{ duration: 250 }}
in:slide={{ duration: 250, easing: cubicOut }}
out:slide={{ duration: 250, easing: cubicOut }}
>
{@render hiddenContent?.({ expanded, disabled })}
</div>

View File

@@ -9,38 +9,26 @@
- 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 }">
<script lang="ts">
import { displayedFontsStore } from '$features/DisplayFont';
import {
createCharacterComparison,
createTypographyControl,
} from '$shared/lib';
import type { LineData } from '$shared/lib';
import { cubicOut } from 'svelte/easing';
import { Spring } from 'svelte/motion';
import { fly } from 'svelte/transition';
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();
// Displayed text
let text = $state('The quick brown fox jumps over the lazy dog...');
// Pair of fonts to compare
const fontA = $derived(displayedFontsStore.fontA);
const fontB = $derived(displayedFontsStore.fontB);
let container: HTMLElement | undefined = $state();
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
@@ -98,7 +86,6 @@ function handleMove(e: PointerEvent) {
function startDragging(e: PointerEvent) {
if (e.target === controlsWrapperElement || controlsWrapperElement?.contains(e.target as Node)) {
console.log('Pointer down on controls wrapper');
e.stopPropagation();
return;
}
@@ -162,82 +149,87 @@ $effect(() => {
- 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}
/>
{#if fontA && fontB}
<CharacterSlot
{char}
{proximity}
{isPast}
weight={weightControl.value}
size={sizeControl.value}
fontAName={fontA.name}
fontBName={fontB.name}
/>
{/if}
{/each}
</div>
{/snippet}
<!-- Hidden canvas used for text measurement by the helper -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
{#if fontA && fontB}
<!-- 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="relative">
<div
bind:this={container}
role="slider"
tabindex="0"
aria-valuenow={Math.round(sliderPos)}
aria-label="Font comparison slider"
onpointerdown={startDragging}
class="
absolute inset-0 bg-linear-to-br
from-slate-50/50 via-white to-slate-100/50
opacity-50 pointer-events-none
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;'}
in:fly={{ y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 }}
>
<!-- 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>
<SliderLine {sliderPos} {isDragging} />
</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} />
<Labels fontA={fontA} fontB={fontB} {sliderPos} />
<!-- 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>
<!-- 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>
{/if}

View File

@@ -1,3 +1,9 @@
<!--
Component: ControlsWrapper
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
-->
<script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { Input } from '$shared/shadcn/ui/input';
@@ -8,13 +14,37 @@ import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
import { Spring } from 'svelte/motion';
interface Props {
/**
* Ref
*/
wrapper?: HTMLDivElement | null;
/**
* Slider position
*/
sliderPos: number;
/**
* Whether slider is being dragged
*/
isDragging: boolean;
/**
* Text to display
*/
text: string;
/**
* Container width
*/
containerWidth: number;
/**
* Weight control
*/
weightControl: TypographyControl;
/**
* Size control
*/
sizeControl: TypographyControl;
/**
* Height control
*/
heightControl: TypographyControl;
}

View File

@@ -1,37 +1,110 @@
<script lang="ts">
import type { Snippet } from 'svelte';
<!--
Component: Labels
Displays labels for font selection in the comparison slider.
-->
<script lang="ts" generics="T extends { name: string; id: string }">
import {
FontVirtualList,
type UnifiedFont,
} from '$entities/Font';
import FontApplicator from '$entities/Font/ui/FontApplicator/FontApplicator.svelte';
import { displayedFontsStore } from '$features/DisplayFont';
import { buttonVariants } from '$shared/shadcn/ui/button';
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';
interface Props {
fontA: { name: string; id: string };
fontB: { name: string; id: string };
interface Props<T> {
/**
* First font to compare
*/
fontA: T;
/**
* Second font to compare
*/
fontB: T;
/**
* Position of the slider
*/
sliderPos: number;
}
let { fontA, fontB, sliderPos }: Props = $props();
let { fontA, fontB, sliderPos }: Props<T> = $props();
const fontList = $derived(
displayedFontsStore.fonts.filter(font => font.name !== fontA.name && font.name !== fontB.name),
);
function selectFontA(fontId: string) {
const newFontA = displayedFontsStore.getById(fontId);
if (!newFontA) return;
displayedFontsStore.fontA = newFontA;
}
function selectFontB(fontId: string) {
const newFontB = displayedFontsStore.getById(fontId);
if (!newFontB) return;
displayedFontsStore.fontB = newFontB;
}
</script>
<!-- Bottom Labels -->
<div class="absolute bottom-6 inset-x-8 sm:inset-x-12 flex justify-between items-center pointer-events-none z-20">
<!-- Left Label (Font A) -->
{#snippet fontSelector(
name: string,
id: string,
fonts: UnifiedFont[],
handleChange: (value: string) => void,
)}
<div
class="flex flex-col gap-1 transition-opacity duration-300"
style:opacity={sliderPos < 10 ? 0 : 1}
class="z-50 pointer-events-auto **:bg-transparent"
onpointerdown={(e => e.stopPropagation())}
>
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-indigo-400"
>Baseline</span>
<span class="text-xs sm:text-sm font-bold text-indigo-600">
{fontB.name}
</span>
<SelectRoot type="single" onValueChange={handleChange}>
<SelectTrigger
class={cn(buttonVariants({ variant: 'ghost' }), 'border-none, hover:bg-indigo-100')}
disabled={!fontList.length}
>
<FontApplicator name={name} id={id}>
{name}
</FontApplicator>
</SelectTrigger>
<SelectContent
class="h-60 bg-transparent **:bg-transparent backdrop-blur-0 data-[state=open]:backdrop-blur-lg transition-[backdrop-filter] duration-200"
scrollYThreshold={100}
side="top"
>
<FontVirtualList items={fonts}>
{#snippet children({ item: font })}
<SelectItem value={font.id} class="data-[highlighted]:bg-indigo-100">
<FontApplicator name={font.name} id={font.id}>
{font.name}
</FontApplicator>
</SelectItem>
{/snippet}
</FontVirtualList>
</SelectContent>
</SelectRoot>
</div>
{/snippet}
<div class="absolute bottom-6 inset-x-6 sm:inset-x-6 flex justify-between items-end pointer-events-none z-20">
<div
class="flex flex-col gap-0.5 transition-opacity duration-300 items-start"
style:opacity={sliderPos < 15 ? 0 : 1}
>
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-indigo-400">
Baseline</span>
{@render fontSelector(fontB.name, fontB.id, fontList, selectFontB)}
</div>
<!-- Right Label (Font B) -->
<div
class="flex flex-col items-end text-right gap-1 transition-opacity duration-300"
style:opacity={sliderPos > 90 ? 0 : 1}
style:opacity={sliderPos > 85 ? 0 : 1}
>
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-slate-400"
>Comparison</span>
<span class="text-xs sm:text-sm font-bold text-slate-900">
{fontA.name}
</span>
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-slate-400">
Comparison</span>
{@render fontSelector(fontA.name, fontA.id, fontList, selectFontA)}
</div>
</div>

View File

@@ -1,8 +1,18 @@
<!--
Component: SliderLine
Visual representation of the slider line.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props {
/**
* Position of the slider
*/
sliderPos: number;
/**
* Whether the slider is being dragged
*/
isDragging: boolean;
}
let { sliderPos, isDragging }: Props = $props();
@@ -44,12 +54,3 @@ let { sliderPos, isDragging }: Props = $props();
<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

@@ -1,15 +1,37 @@
<script lang="ts">
import SetupFontMenu from '$features/SetupFont/ui/SetupFontMenu.svelte';
import { SetupFontMenu } from '$features/SetupFont';
import {
Content as ItemContent,
Root as ItemRoot,
} from '$shared/shadcn/ui/item';
import { displayedFontsStore } from '$features/DisplayFont';
import { cubicOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
const [send, receive] = crossfade({
duration: 400,
easing: cubicOut,
fallback(node, params) {
// If it can't find a pair, it falls back to a simple fade/slide
return {
duration: 400,
css: t => `opacity: ${t}; transform: translateY(${(1 - t) * 10}px);`,
};
},
});
</script>
<div class="w-full p-2 backdrop-blur-2xl">
<ItemRoot variant="outline" class="w-full p-2.5">
<ItemContent class="flex flex-row justify-center items-center">
<SetupFontMenu />
</ItemContent>
</ItemRoot>
</div>
{#if displayedFontsStore.hasAnyFonts}
<div
class="w-auto fixed bottom-5 left-1/2 translate-x-[-50%] max-w-max z-10"
in:receive={{ key: 'panel' }}
out:send={{ key: 'panel' }}
>
<ItemRoot variant="outline" class="w-auto max-w-max p-2.5 rounded-2xl backdrop-blur-lg">
<ItemContent class="flex flex-row justify-center items-center max-w-max">
<SetupFontMenu />
</ItemContent>
</ItemRoot>
</div>
{/if}