Compare commits

..

16 Commits

Author SHA1 Message Date
Ilia Mashkov 359617212d feat: shadcn drawer dependencies 2026-02-07 18:17:09 +03:00
Ilia Mashkov beff194e5b fix(Layout): fix import path 2026-02-07 18:16:44 +03:00
Ilia Mashkov f24c93c105 chore: add exports/imports 2026-02-07 18:16:08 +03:00
Ilia Mashkov c16ef4acbf chore: remove unused code 2026-02-07 18:15:45 +03:00
Ilia Mashkov c91ced3617 chore(Page): uncomment compararison slider 2026-02-07 18:15:14 +03:00
Ilia Mashkov a48c9bce0c feat(ComparisonSlider): slightly tweak line styles for better mobile UX 2026-02-07 18:14:39 +03:00
Ilia Mashkov 152be85e34 feat(ComparisonSlider): add separate typographyManager instance into comparisonStore and use its controls in the slider. Improve mobile usability using Drawer for all the settings 2026-02-07 18:14:07 +03:00
Ilia Mashkov b09b89f4fc feat(ExpandableWrapper): slightly change wrapper styles for better UX on mobile 2026-02-07 18:08:49 +03:00
Ilia Mashkov 1a23ec2f28 feat(ComboControlV2): add orientation prop and remove unused code 2026-02-07 18:07:28 +03:00
Ilia Mashkov 86ea9cd887 chore(SetupFont): move initial typography control config into constants 2026-02-07 18:06:13 +03:00
Ilia Mashkov 10919a9881 feat(controlManager): add getters for controls and custom storageId parameter for persistent storage 2026-02-07 18:05:14 +03:00
Ilia Mashkov 180abd150d chore(TypographyMenu): move component to SetupFont feature layer 2026-02-07 18:03:54 +03:00
Ilia Mashkov c4bfb1db56 chore(SearchBar): replace input with reusable one 2026-02-07 18:02:32 +03:00
Ilia Mashkov 98a94e91ed feat(Input): create reusable input component 2026-02-07 18:01:48 +03:00
Ilia Mashkov a1b7f78fc4 feat(Drawer): create reusable Drawer component with snippets for trigger and content 2026-02-07 18:01:20 +03:00
Ilia Mashkov 41c5ceb848 feat(drawer): add shadcn drawer 2026-02-07 18:00:38 +03:00
40 changed files with 916 additions and 461 deletions
+1
View File
@@ -61,6 +61,7 @@
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^7.2.6",
"vitest": "^4.0.16",
"vitest-browser-svelte": "^2.0.1"
+1 -1
View File
@@ -11,11 +11,11 @@
* - Footer area (currently empty, reserved for future use)
*/
import { BreadcrumbHeader } from '$entities/Breadcrumb';
import { TypographyMenu } from '$features/SetupFont';
import favicon from '$shared/assets/favicon.svg';
import { ResponsiveProvider } from '$shared/lib';
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import { TypographyMenu } from '$widgets/TypographySettings';
import type { Snippet } from 'svelte';
interface Props {
+11 -2
View File
@@ -1,11 +1,16 @@
import SetupFontMenu from './ui/SetupFontMenu.svelte';
export {
SetupFontMenu,
TypographyMenu,
} from './ui';
export {
type ControlId,
controlManager,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
@@ -16,4 +21,8 @@ export {
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
} from './model';
export { SetupFontMenu };
export {
createTypographyControlManager,
type TypographyControlManager,
} from './lib';
@@ -1,4 +1,3 @@
import type { ControlId } from '$features/SetupFont/model/state/manager.svelte';
import {
type ControlDataModel,
type ControlModel,
@@ -9,6 +8,7 @@ import {
} from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity';
import {
type ControlId,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
@@ -16,6 +16,7 @@ import {
} from '../../model';
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
export interface Control extends ControlOnlyFields<ControlId> {
instance: TypographyControl;
}
@@ -29,11 +30,11 @@ export class TypographyControlManager {
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
this.#storage = storage;
// 1. Initial Load
// Initial Load
const saved = storage.value;
this.#baseSize = saved.fontSize;
// 2. Setup Controls
// Setup Controls
configs.forEach(config => {
const initialValue = this.#getInitialValue(config.id, saved);
@@ -46,7 +47,7 @@ export class TypographyControlManager {
});
});
// 3. The Sync Effect (UI -> Storage)
// The Sync Effect (UI -> Storage)
// We access .value explicitly to ensure Svelte 5 tracks the dependency
$effect.root(() => {
$effect(() => {
@@ -65,7 +66,7 @@ export class TypographyControlManager {
};
});
// 4. The Font Size Proxy Effect
// The Font Size Proxy Effect
// This handles the "Multiplier" logic specifically for the Font Size Control
$effect(() => {
const ctrl = this.#controls.get('font_size')?.instance;
@@ -123,10 +124,32 @@ export class TypographyControlManager {
if (ctrl) ctrl.value = val * this.#multiplier;
}
/**
* Getters for controls
*/
get controls() {
return Array.from(this.#controls.values());
}
get weightControl() {
return this.#controls.get('font_weight')?.instance;
}
get sizeControl() {
return this.#controls.get('font_size')?.instance;
}
get heightControl() {
return this.#controls.get('line_height')?.instance;
}
get spacingControl() {
return this.#controls.get('letter_spacing')?.instance;
}
/**
* Getters for values (besides font-size)
*/
get weight() {
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
}
@@ -175,10 +198,14 @@ export interface TypographySettings {
* Creates a typography control manager that handles a collection of typography controls.
*
* @param configs - Array of control configurations.
* @param storageId - Persistent storage identifier.
* @returns - Typography control manager instance.
*/
export function createTypographyControlManager(configs: ControlModel<ControlId>[]) {
const storage = createPersistentStore<TypographySettings>('glyphdiff:typography', {
export function createTypographyControlManager(
configs: ControlModel<ControlId>[],
storageId: string = 'glyphdiff:typography',
) {
const storage = createPersistentStore<TypographySettings>(storageId, {
fontSize: DEFAULT_FONT_SIZE,
fontWeight: DEFAULT_FONT_WEIGHT,
lineHeight: DEFAULT_LINE_HEIGHT,
@@ -1,3 +1,6 @@
import type { ControlModel } from '$shared/lib';
import type { ControlId } from '..';
/**
* Font size constants
*/
@@ -29,3 +32,50 @@ export const DEFAULT_LETTER_SPACING = 0;
export const MIN_LETTER_SPACING = -0.1;
export const MAX_LETTER_SPACING = 0.5;
export const LETTER_SPACING_STEP = 0.01;
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
step: FONT_SIZE_STEP,
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Font Size',
},
{
id: 'font_weight',
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Font Weight',
},
{
id: 'line_height',
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Line Height',
},
{
id: 'letter_spacing',
value: DEFAULT_LETTER_SPACING,
max: MAX_LETTER_SPACING,
min: MIN_LETTER_SPACING,
step: LETTER_SPACING_STEP,
increaseLabel: 'Increase Letter Spacing',
decreaseLabel: 'Decrease Letter Spacing',
controlLabel: 'Letter Spacing',
},
];
+5 -2
View File
@@ -3,6 +3,7 @@ export {
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
@@ -14,5 +15,7 @@ export {
MIN_LINE_HEIGHT,
} from './const/const';
export { type TypographyControlManager } from '../lib';
export { controlManager } from './state/manager.svelte';
export {
type ControlId,
controlManager,
} from './state/manager.svelte';
@@ -1,71 +1,6 @@
import type { ControlModel } from '$shared/lib';
import { createTypographyControlManager } from '../../lib';
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LETTER_SPACING_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LETTER_SPACING,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LETTER_SPACING,
MIN_LINE_HEIGHT,
} from '../const/const';
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../const/const';
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
const controlData: ControlModel<ControlId>[] = [
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
step: FONT_SIZE_STEP,
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Font Size',
},
{
id: 'font_weight',
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Font Weight',
},
{
id: 'line_height',
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Line Height',
},
{
id: 'letter_spacing',
value: DEFAULT_LETTER_SPACING,
max: MAX_LETTER_SPACING,
min: MIN_LETTER_SPACING,
step: LETTER_SPACING_STEP,
increaseLabel: 'Increase Letter Spacing',
decreaseLabel: 'Decrease Letter Spacing',
controlLabel: 'Letter Spacing',
},
];
export const controlManager = createTypographyControlManager(controlData);
export const controlManager = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA);
+2
View File
@@ -0,0 +1,2 @@
export { default as SetupFontMenu } from './SetupFontMenu.svelte';
export { default as TypographyMenu } from './TypographyMenu.svelte';
+1 -2
View File
@@ -57,7 +57,7 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
{/snippet}
<Logo />
</Section>
<!--
<Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}>
{#snippet icon({ className })}
<EyeIcon class={className} />
@@ -69,7 +69,6 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
{/snippet}
<ComparisonSlider />
</Section>
-->
<Section class="my-4 sm:my-10 md:my-12 gap-6 sm:gap-8" index={2} onTitleStatusChange={handleTitleStatusChanged}>
{#snippet icon({ className })}
@@ -0,0 +1,7 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.CloseProps = $props();
</script>
<DrawerPrimitive.Close bind:ref data-slot="drawer-close" {...restProps} />
@@ -0,0 +1,39 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
import type { WithoutChildrenOrChild } from '$shared/shadcn/utils/shadcn-utils.js';
import type { ComponentProps } from 'svelte';
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
import DrawerOverlay from './drawer-overlay.svelte';
import DrawerPortal from './drawer-portal.svelte';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
...restProps
}: DrawerPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DrawerPortal>>;
} = $props();
</script>
<DrawerPortal {...portalProps}>
<DrawerOverlay />
<DrawerPrimitive.Content
bind:ref
data-slot="drawer-content"
class={cn(
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:end-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-s data-[vaul-drawer-direction=right]:sm:max-w-sm',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:start-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-e data-[vaul-drawer-direction=left]:sm:max-w-sm',
className,
)}
{...restProps}
>
<div class="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block">
</div>
{@render children?.()}
</DrawerPrimitive.Content>
</DrawerPortal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.DescriptionProps = $props();
</script>
<DrawerPrimitive.Description
bind:ref
data-slot="drawer-description"
class={cn('text-muted-foreground text-sm', className)}
{...restProps}
/>
@@ -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="drawer-footer"
class={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -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="drawer-header"
class={cn('flex flex-col gap-1.5 p-4', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,12 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
let {
shouldScaleBackground = true,
open = $bindable(false),
activeSnapPoint = $bindable(null),
...restProps
}: DrawerPrimitive.RootProps = $props();
</script>
<DrawerPrimitive.NestedRoot {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.OverlayProps = $props();
</script>
<DrawerPrimitive.Overlay
bind:ref
data-slot="drawer-overlay"
class={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
let { ...restProps }: DrawerPrimitive.PortalProps = $props();
</script>
<DrawerPrimitive.Portal {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.TitleProps = $props();
</script>
<DrawerPrimitive.Title
bind:ref
data-slot="drawer-title"
class={cn('text-foreground font-semibold', className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.TriggerProps = $props();
</script>
<DrawerPrimitive.Trigger bind:ref data-slot="drawer-trigger" {...restProps} />
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
let {
shouldScaleBackground = true,
open = $bindable(false),
activeSnapPoint = $bindable(null),
...restProps
}: DrawerPrimitive.RootProps = $props();
</script>
<DrawerPrimitive.Root {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />
+37
View File
@@ -0,0 +1,37 @@
import Close from './drawer-close.svelte';
import Content from './drawer-content.svelte';
import Description from './drawer-description.svelte';
import Footer from './drawer-footer.svelte';
import Header from './drawer-header.svelte';
import NestedRoot from './drawer-nested.svelte';
import Overlay from './drawer-overlay.svelte';
import Portal from './drawer-portal.svelte';
import Title from './drawer-title.svelte';
import Trigger from './drawer-trigger.svelte';
import Root from './drawer.svelte';
export {
Close,
Close as DrawerClose,
Content,
Content as DrawerContent,
Description,
Description as DrawerDescription,
Footer,
Footer as DrawerFooter,
Header,
Header as DrawerHeader,
NestedRoot,
NestedRoot as DrawerNestedRoot,
Overlay,
Overlay as DrawerOverlay,
Portal,
Portal as DrawerPortal,
Root,
//
Root as Drawer,
Title,
Title as DrawerTitle,
Trigger,
Trigger as DrawerTrigger,
};
@@ -7,23 +7,29 @@ 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 { Orientation } from 'bits-ui';
import type { ChangeEventHandler } from 'svelte/elements';
interface Props {
/**
* Typography control instance
*/
control: TypographyControl;
ref?: Snippet;
/**
* Orientation of the component
*/
orientation?: Orientation;
}
let {
control,
ref = $bindable(),
orientation = 'vertical',
}: Props = $props();
let sliderValue = $state(Number(control.value));
$effect(() => {
sliderValue = Number(control.value);
sliderValue = Number(control?.value);
});
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
@@ -36,22 +42,9 @@ const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
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">
<div class={cn('flex flex-col items-center gap-4 w-full', orientation === 'vertical' ? 'flex-col' : 'flex-row')}>
<Input
value={control.value}
onchange={handleInputChange}
@@ -66,7 +59,7 @@ const handleSliderChange = (newValue: number) => {
value={sliderValue}
onValueChange={handleSliderChange}
type="single"
orientation="vertical"
class="h-30"
orientation={orientation}
class={cn(orientation === 'vertical' ? 'h-30' : 'w-full')}
/>
</div>
+41
View File
@@ -0,0 +1,41 @@
<!-- Component: Drawer -->
<script lang="ts">
import { Button } from '$shared/shadcn/ui/button';
import {
Content as DrawerContent,
Footer as DrawerFooter,
Header as DrawerHeader,
Root as DrawerRoot,
Trigger as DrawerTrigger,
} from '$shared/shadcn/ui/drawer';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
interface Props {
isOpen?: boolean;
trigger?: Snippet<[{ isOpen: boolean; onClick: () => void }]>;
content?: Snippet<[{ isOpen: boolean }]>;
contentClassName?: string;
}
let { isOpen = $bindable(false), trigger, content, contentClassName }: Props = $props();
function handleClick() {
isOpen = !isOpen;
}
</script>
<DrawerRoot bind:open={isOpen}>
<DrawerTrigger>
{#if trigger}
{@render trigger({ isOpen, onClick: handleClick })}
{:else}
<Button onclick={handleClick}>
Open
</Button>
{/if}
</DrawerTrigger>
<DrawerContent class={cn('min-h-60', contentClassName)}>
{@render content?.({ isOpen })}
</DrawerContent>
</DrawerRoot>
@@ -173,7 +173,7 @@ $effect(() => {
<div
class={cn(
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
'relative p-0.5 sm:p-2 rounded-lg sm:rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
expanded
? '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)]',
+42
View File
@@ -0,0 +1,42 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Input from './Input.svelte';
const { Story } = defineMeta({
title: 'Shared/Input',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Styles Input component',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
argTypes: {
placeholder: {
control: 'text',
description: "input's placeholder",
},
value: {
control: 'text',
description: "input's value",
},
},
});
</script>
<script lang="ts">
let value = $state('Initial value');
const placeholder = 'Enter text';
</script>
<Story
name="Default"
args={{
placeholder,
value,
}}
>
<Input value={value} placeholder={placeholder} />
</Story>
+54
View File
@@ -0,0 +1,54 @@
<!--
Component: Input
Provides styled input component with all the shadcn input props
-->
<script lang="ts">
import { Input } from '$shared/shadcn/ui/input';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { ComponentProps } from 'svelte';
type Props = ComponentProps<typeof Input> & {
/**
* Current search value (bindable)
*/
value: string;
/**
* Additional CSS classes for the container
*/
class?: string;
};
let {
value = $bindable(''),
class: className,
...rest
}: Props = $props();
</script>
<Input
bind:value={value}
class={cn(
'h-12 sm:h-14 md:h-16 w-full text-sm sm:text-base',
'backdrop-blur-md bg-white/80',
'border border-gray-300/50',
'shadow-[0_1px_3px_rgba(0,0,0,0.04)]',
'focus-visible:border-gray-400/60',
'focus-visible:outline-none',
'focus-visible:ring-1',
'focus-visible:ring-gray-400/30',
'focus-visible:bg-white/90',
'hover:bg-white/90',
'hover:border-gray-400/60',
'text-gray-900',
'placeholder:text-gray-400',
'placeholder:font-mono',
'placeholder:text-xs sm:placeholder:text-sm',
'placeholder:tracking-wide',
'pl-11 sm:pl-14 pr-4 sm:pr-6',
'rounded-xl',
'transition-all duration-200',
'font-medium',
className,
)}
{...rest}
/>
@@ -34,8 +34,6 @@ const { Story } = defineMeta({
<script lang="ts">
let defaultSearchValue = $state('');
let withLabelValue = $state('');
let noChildrenValue = $state('');
</script>
<Story
@@ -45,26 +43,5 @@ let noChildrenValue = $state('');
placeholder: 'Type here...',
}}
>
<SearchBar bind:value={defaultSearchValue} placeholder="Type here..."> </SearchBar>
</Story>
<Story
name="With Label"
args={{
value: withLabelValue,
placeholder: 'Search products...',
label: 'Search',
}}
>
<SearchBar bind:value={withLabelValue} placeholder="Search products..." label="Search"> </SearchBar>
</Story>
<Story
name="Minimal Content"
args={{
value: noChildrenValue,
placeholder: 'Quick search...',
}}
>
<SearchBar bind:value={noChildrenValue} placeholder="Quick search..."> </SearchBar>
<SearchBar bind:value={defaultSearchValue} placeholder="Type here..." />
</Story>
+2 -35
View File
@@ -1,6 +1,6 @@
<!-- Component: SearchBar -->
<script lang="ts">
import { Input } from '$shared/shadcn/ui/input';
import { Input } from '$shared/ui';
import AsteriskIcon from '@lucide/svelte/icons/asterisk';
interface Props {
@@ -32,44 +32,11 @@ let {
class: className,
placeholder,
}: Props = $props();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
event.preventDefault();
}
}
</script>
<div class="relative w-full">
<div class="absolute left-4 sm:left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
<AsteriskIcon class="size-3 sm:size-4 stroke-gray-400 stroke-[1.5]" />
</div>
<Input
id={id}
placeholder={placeholder}
bind:value={value}
onkeydown={handleKeyDown}
class="
h-12 sm:h-14 md:h-16 w-full text-sm sm:text-base
backdrop-blur-md bg-white/80
border border-gray-300/50
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
focus-visible:border-gray-400/60
focus-visible:outline-none
focus-visible:ring-1
focus-visible:ring-gray-400/30
focus-visible:bg-white/90
hover:bg-white/90
hover:border-gray-400/60
text-gray-900
placeholder:text-gray-400
placeholder:font-mono
placeholder:text-xs sm:placeholder:text-sm
placeholder:tracking-wide
pl-11 sm:pl-14 pr-4 sm:pr-6
rounded-xl
transition-all duration-200
font-medium
"
/>
<Input {id} class={className} bind:value={value} placeholder={placeholder} />
</div>
+2
View File
@@ -3,9 +3,11 @@ export { default as ComboControl } from './ComboControl/ComboControl.svelte';
// ComboControlV2 might vary, assuming pattern holds or I'll fix later if build fails
export { default as ComboControlV2 } from './ComboControlV2/ComboControlV2.svelte';
export { default as ContentEditable } from './ContentEditable/ContentEditable.svelte';
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 SearchBar } from './SearchBar/SearchBar.svelte';
@@ -3,6 +3,10 @@ import {
fetchFontsByIds,
unifiedFontStore,
} from '$entities/Font';
import {
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
createTypographyControlManager,
} from '$features/SetupFont';
import { createPersistentStore } from '$shared/lib';
/**
@@ -30,6 +34,7 @@ class ComparisonStore {
#fontB = $state<UnifiedFont | undefined>();
#sampleText = $state('The quick brown fox jumps over the lazy dog');
#isRestoring = $state(true);
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
constructor() {
this.restoreFromStorage();
@@ -102,6 +107,9 @@ class ComparisonStore {
}
// --- Getters & Setters ---
get typography() {
return this.#typography;
}
get fontA() {
return this.#fontA;
@@ -149,6 +157,13 @@ class ComparisonStore {
this.restoreFromStorage();
}
}
resetAll() {
this.#fontA = undefined;
this.#fontB = undefined;
storage.clear();
this.#typography.reset();
}
}
export const comparisonStore = new ComparisonStore();
@@ -14,14 +14,17 @@ import {
createCharacterComparison,
createTypographyControl,
} from '$shared/lib';
import type { LineData } from '$shared/lib';
import type {
LineData,
ResponsiveManager,
} from '$shared/lib';
import { Loader } from '$shared/ui';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { getContext } from 'svelte';
import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition';
import CharacterSlot from './components/CharacterSlot.svelte';
import ControlsWrapper from './components/ControlsWrapper.svelte';
import Labels from './components/Labels.svelte';
import Controls from './components/Controls.svelte';
import SliderLine from './components/SliderLine.svelte';
// Pair of fonts to compare
@@ -30,31 +33,13 @@ const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
let container: HTMLElement | undefined = $state();
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
let measureCanvas: HTMLCanvasElement | undefined = $state();
let container = $state<HTMLElement>();
let typographyControls = $state<HTMLDivElement | null>(null);
let measureCanvas = $state<HTMLCanvasElement>();
let isDragging = $state(false);
const typography = $derived(comparisonStore.typography);
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,
});
const responsive = getContext<ResponsiveManager>('responsive');
/**
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
@@ -64,8 +49,8 @@ const charComparison = createCharacterComparison(
() => comparisonStore.text,
() => fontA,
() => fontB,
() => weightControl.value,
() => sizeControl.value,
() => typography.weight,
() => typography.renderedSize,
);
let lineElements = $state<(HTMLElement | undefined)[]>([]);
@@ -88,8 +73,8 @@ function handleMove(e: PointerEvent) {
function startDragging(e: PointerEvent) {
if (
e.target === controlsWrapperElement
|| controlsWrapperElement?.contains(e.target as Node)
e.target === typographyControls
|| typographyControls?.contains(e.target as Node)
) {
e.stopPropagation();
return;
@@ -99,6 +84,30 @@ function startDragging(e: PointerEvent) {
handleMove(e);
}
/**
* Sets the multiplier for slider font size based on the current responsive state
*/
$effect(() => {
if (!responsive) {
return;
}
switch (true) {
case responsive.isMobile:
typography.multiplier = 0.5;
break;
case responsive.isTablet:
typography.multiplier = 0.75;
break;
case responsive.isDesktop:
typography.multiplier = 1;
break;
default:
typography.multiplier = 1;
break;
}
});
$effect(() => {
if (isDragging) {
window.addEventListener('pointermove', handleMove);
@@ -115,9 +124,9 @@ $effect(() => {
$effect(() => {
// React on text and typography settings changes
const _text = comparisonStore.text;
const _weight = weightControl.value;
const _size = sizeControl.value;
const _height = heightControl.value;
const _weight = typography.weight;
const _size = typography.renderedSize;
const _height = typography.height;
if (container && measureCanvas && fontA && fontB) {
// Using rAF to ensure DOM is ready/stabilized
@@ -143,8 +152,8 @@ $effect(() => {
<div
bind:this={lineElements[index]}
class="relative flex w-full justify-center items-center whitespace-nowrap"
style:height={`${heightControl.value}em`}
style:line-height={`${heightControl.value}em`}
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)}
@@ -158,8 +167,8 @@ $effect(() => {
{char}
{proximity}
{isPast}
weight={weightControl.value}
size={sizeControl.value}
weight={typography.weight}
size={typography.renderedSize}
fontAName={fontA.name}
fontBName={fontB.name}
/>
@@ -182,12 +191,12 @@ $effect(() => {
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-[300px] sm:min-h-[400px] md:min-h-[500px] flex flex-col justify-center
backdrop-blur-lg bg-gradient-to-br from-gray-100/70 via-white/50 to-gray-100/60
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-gray-300/40
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-[1px]
before:bg-gradient-to-br before:from-black/5 before:via-black/2 before:to-transparent
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
"
>
@@ -209,7 +218,7 @@ $effect(() => {
{#each charComparison.lines as line, lineIndex}
<div
class="relative w-full whitespace-nowrap"
style:height={`${heightControl.value}em`}
style:height={`${typography.height}em`}
style:display="flex"
style:align-items="center"
style:justify-content="center"
@@ -222,19 +231,6 @@ $effect(() => {
<SliderLine {sliderPos} {isDragging} />
{/if}
</div>
{#if fontA && fontB && !isLoading}
<Labels {fontA} {fontB} {sliderPos} weight={weightControl.value} />
<!-- Since there're slider controls inside we put them outside the main one -->
<ControlsWrapper
bind:wrapper={controlsWrapperElement}
{sliderPos}
{isDragging}
bind:text={comparisonStore.text}
containerWidth={container?.clientWidth}
{weightControl}
{sizeControl}
{heightControl}
/>
{/if}
<Controls {sliderPos} {isDragging} {typographyControls} {container} />
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import type { ResponsiveManager } from '$shared/lib';
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 TypographyControls from './TypographyControls.svelte';
interface Props {
sliderPos: number;
isDragging: boolean;
typographyControls?: HTMLDivElement | null;
container: HTMLElement;
}
let { sliderPos, isDragging, typographyControls = $bindable<HTMLDivElement | null>(null), container }: Props = $props();
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
const responsive = getContext<ResponsiveManager>('responsive');
</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 })}
<div class="px-2 py-4">
<SelectComparedFonts {sliderPos} />
</div>
<TypographyControls
{sliderPos}
{isDragging}
isActive={isOpen}
bind:wrapper={typographyControls}
containerWidth={container?.clientWidth}
staticPosition
/>
{/snippet}
</Drawer>
{:else}
{#if !isLoading}
<div class="absolute top-3 sm:top-6 left-3 sm:left-6">
<TypographyControls
{sliderPos}
{isDragging}
bind:wrapper={typographyControls}
containerWidth={container?.clientWidth}
/>
</div>
{/if}
{#if !isLoading}
<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>
{/if}
{/if}
@@ -1,177 +0,0 @@
<!--
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';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { ComboControlV2 } from '$shared/ui';
import { ExpandableWrapper } from '$shared/ui';
import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
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;
/**
* Text to display
*/
text: string;
/**
* Container width
*/
containerWidth: number;
/**
* Weight control
*/
weightControl: TypographyControl;
/**
* Size control
*/
sizeControl: TypographyControl;
/**
* Height control
*/
heightControl: TypographyControl;
}
let {
sliderPos,
isDragging,
wrapper = $bindable(null),
text = $bindable(),
containerWidth = 0,
weightControl,
sizeControl,
heightControl,
}: Props = $props();
let panelWidth = $derived(wrapper?.clientWidth ?? 0);
const margin = 24;
let side = $state<'left' | 'right'>('left');
// Unified active state for the entire wrapper
let isActive = $state(false);
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) 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(() => {
const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0;
if (containerWidth > 0 && panelWidth > 0) {
// On side change set the position and the rotation
xSpring.target = targetX;
rotateSpring.target = side === 'right' ? 3.5 : -3.5;
timeoutId = setTimeout(() => {
rotateSpring.target = 0;
}, 600);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
</script>
<div
class="absolute top-6 left-6 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 }}
>
<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',
)}
>
{#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()}
<div class="relative px-2 py-1">
<Input
bind:value={text}
disabled={isDragging}
onfocusin={handleInputFocus}
class={cn(
isActive
? 'h-7 sm:h-8 text-[11px] sm: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 sm:text-base 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-44 sm:w-56',
)}
placeholder="The quick brown fox..."
/>
</div>
{/snippet}
{#snippet hiddenContent()}
<div class="flex flex-col sm:flex-row justify-between items-center-safe gap-2 sm:gap-0">
<ComboControlV2 control={weightControl} />
<ComboControlV2 control={sizeControl} />
<ComboControlV2 control={heightControl} />
</div>
{/snippet}
</ExpandableWrapper>
</div>
@@ -1,13 +1,14 @@
<!--
Component: Labels
Displays labels for font selection in the comparison slider.
Component: SelectComparedFonts
Displays selects that change the compared fonts
-->
<script lang="ts" generics="T extends UnifiedFont">
<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,
@@ -19,23 +20,19 @@ import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { fade } from 'svelte/transition';
interface Props<T> {
/**
* First font to compare
*/
fontA: T;
/**
* Second font to compare
*/
fontB: T;
interface Props {
/**
* Position of the slider
*/
sliderPos: number;
weight: number;
}
let { fontA, fontB, sliderPos, weight }: Props<T> = $props();
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);
@@ -51,11 +48,10 @@ function selectFontB(font: UnifiedFont) {
</script>
{#snippet fontSelector(
name: string,
id: string,
url: string,
font: UnifiedFont,
fonts: UnifiedFont[],
selectFont: (font: UnifiedFont) => void,
url: string,
onSelect: (f: UnifiedFont) => void,
align: 'start' | 'end',
)}
<div
@@ -74,15 +70,15 @@ function selectFontB(font: UnifiedFont) {
)}
>
<div class="text-left flex-1 min-w-0">
<FontApplicator {name} {id} {url}>
{name}
<FontApplicator name={font.name} id={font.id} {url}>
{font.name}
</FontApplicator>
</div>
</SelectTrigger>
<SelectContent
class={cn(
'bg-white/95 backdrop-blur-xl border border-gray-300/50 shadow-xl',
'w-44 sm:w-52 max-h-[240px] sm:max-h-[280px] overflow-hidden rounded-lg',
'w-44 sm:w-52 max-h-60 sm:max-h-64 overflow-hidden rounded-lg',
)}
side="top"
{align}
@@ -90,16 +86,20 @@ function selectFontB(font: UnifiedFont) {
size="small"
>
<div class="p-1 sm:p-1.5">
<FontVirtualList items={fonts} {weight}>
{#snippet children({ item: font })}
{@const handleClick = () => selectFont(font)}
<FontVirtualList items={fonts} weight={typography.weight}>
{#snippet children({ item: fontListItem })}
{@const handleClick = () => onSelect(fontListItem)}
<SelectItem
value={font.id}
class="data-[highlighted]:bg-gray-100 font-mono text-[10px] sm:text-[11px] px-2 sm:px-3 py-2 sm:py-2.5 rounded-md cursor-pointer transition-colors"
value={fontListItem.id}
class="data-highlighted:bg-gray-100 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 name={font.name} id={font.id} url={font.styles.regular!}>
{font.name}
<FontApplicator
name={fontListItem.name}
id={fontListItem.id}
url={getFontUrl(fontListItem, typography.weight) ?? ''}
>
{fontListItem.name}
</FontApplicator>
</SelectItem>
{/snippet}
@@ -110,7 +110,7 @@ function selectFontB(font: UnifiedFont) {
</div>
{/snippet}
<div class="absolute bottom-4 sm:bottom-6 md:bottom-8 inset-x-4 sm:inset-x-6 md:inset-x-12 flex justify-between items-end pointer-events-none z-20">
<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}
@@ -123,14 +123,9 @@ function selectFontB(font: UnifiedFont) {
ch_01
</span>
</div>
{@render fontSelector(
fontB.name,
fontB.id,
fontB.styles.regular!,
fontList,
selectFontB,
'start',
)}
{#if fontB && fontBUrl}
{@render fontSelector(fontB, fontList, fontBUrl, selectFontB, 'start')}
{/if}
</div>
<div
@@ -145,13 +140,8 @@ function selectFontB(font: UnifiedFont) {
<div class="w-px h-2 sm:h-2.5 bg-gray-300/60"></div>
<div class="w-1.5 h-1.5 rounded-full bg-gray-900 shadow-[0_0_6px_rgba(0,0,0,0.4)]"></div>
</div>
{@render fontSelector(
fontA.name,
fontA.id,
fontA.styles.regular!,
fontList,
selectFontA,
'end',
)}
{#if fontA && fontAUrl}
{@render fontSelector(fontA, fontList, fontAUrl, selectFontA, 'end')}
{/if}
</div>
</div>
@@ -18,14 +18,14 @@ interface Props {
let { sliderPos, isDragging }: Props = $props();
</script>
<div
class="absolute inset-y-4 pointer-events-none -translate-x-1/2 z-50 transition-all duration-300 ease-out flex flex-col justify-center items-center"
class="absolute inset-y-2 sm:inset-y-4 pointer-events-none -translate-x-1/2 z-50 transition-all duration-300 ease-out flex flex-col justify-center items-center"
style:left="{sliderPos}%"
>
<!-- We use part of lucide cursor svg icon as a handle -->
<svg
class={cn(
'transition-all relative duration-300 text-black/80 drop-shadow-sm',
isDragging ? 'w-12 h-12' : 'w-8 h-8',
isDragging ? 'size-6 sm:size-12' : 'size-4 sm:size-8',
)}
viewBox="0 0 24 12"
fill="none"
@@ -41,12 +41,12 @@ let { sliderPos, isDragging }: Props = $props();
<div
class={cn(
'relative h-full rounded-sm transition-all duration-500',
'bg-white/[0.03] backdrop-blur-md',
'bg-white/3 backdrop-blur-md',
// These are the visible "edges" of the glass
'shadow-[0_0_40px_rgba(0,0,0,0.1)_inset_0_0_20px_rgba(255,255,255,0.1)]',
'shadow-[0_10px_30px_-10px_rgba(0,0,0,0.2),inset_0_1px_1px_rgba(255,255,255,0.4)]',
'rounded-full',
isDragging ? 'w-32' : 'w-16',
isDragging ? 'w-16 sm:w-32' : 'w-12 sm:w-16',
)}
>
</div>
@@ -55,7 +55,7 @@ let { sliderPos, isDragging }: Props = $props();
<svg
class={cn(
'transition-all relative duration-500 text-black/80 drop-shadow-sm',
isDragging ? 'w-12 h-12' : 'w-8 h-8',
isDragging ? 'size-6 sm:size-12' : 'size-4 sm:size-8',
)}
viewBox="0 0 24 12"
fill="none"
@@ -0,0 +1,205 @@
<!--
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
-->
<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 { 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(() => {
const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0;
if (containerWidth > 0 && panelWidth > 0) {
// On side change set the position and the rotation
xSpring.target = targetX;
rotateSpring.target = side === 'right' ? 3.5 : -3.5;
timeoutId = setTimeout(() => {
rotateSpring.target = 0;
}, 600);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
</script>
<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 px-2 py-4">
<Input
class="p-6"
bind:value={comparisonStore.text}
disabled={isDragging}
onfocusin={handleInputFocus}
placeholder="The quick brown fox..."
/>
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
<div class="flex flex-col justify-between items-center-safe gap-6">
<ComboControlV2 control={typography.weightControl} orientation="horizontal" />
<ComboControlV2 control={typography.sizeControl} orientation="horizontal" />
<ComboControlV2 control={typography.heightControl} orientation="horizontal" />
</div>
{/if}
</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()}
<div class="relative">
<!--
<Input
bind:value={comparisonStore.text}
disabled={isDragging}
onfocusin={handleInputFocus}
class={cn(
isActive
? 'h-7 sm:h-8 text-[11px] sm: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 sm:text-base 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-44 sm:w-56',
)}
placeholder="The quick brown fox..."
/>
-->
<Input
class={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',
)}
bind:value={comparisonStore.text}
disabled={isDragging}
onfocusin={handleInputFocus}
placeholder="The quick brown fox..."
/>
</div>
{/snippet}
{#snippet hiddenContent()}
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
<div class="flex flex-row justify-between items-center-safe gap-2 sm:gap-0">
<ComboControlV2 control={typography.weightControl} />
<ComboControlV2 control={typography.sizeControl} />
<ComboControlV2 control={typography.heightControl} />
</div>
{/if}
{/snippet}
</ExpandableWrapper>
{/if}
</div>
-3
View File
@@ -1,3 +0,0 @@
import TypographyMenu from './ui/TypographyMenu.svelte';
export { TypographyMenu };
-1
View File
@@ -1,3 +1,2 @@
export { ComparisonSlider } from './ComparisonSlider';
export { FontSearch } from './FontSearch';
export { TypographyMenu } from './TypographySettings';
+37
View File
@@ -2470,6 +2470,7 @@ __metadata:
tailwindcss: "npm:^4.1.18"
tw-animate-css: "npm:^1.4.0"
typescript: "npm:^5.9.3"
vaul-svelte: "npm:^1.0.0-next.7"
vite: "npm:^7.2.6"
vitest: "npm:^4.0.16"
vitest-browser-svelte: "npm:^2.0.1"
@@ -3625,6 +3626,17 @@ __metadata:
languageName: node
linkType: hard
"runed@npm:^0.23.2":
version: 0.23.4
resolution: "runed@npm:0.23.4"
dependencies:
esm-env: "npm:^1.0.0"
peerDependencies:
svelte: ^5.7.0
checksum: 10c0/e27400af9e69b966dca449b851e82e09b3d2ddde4095ba72237599aa80fc248a23d0737c0286f751ca6c12721a5e09eb21b9d8cc872cbd70e7b161442818eece
languageName: node
linkType: hard
"runed@npm:^0.35.1":
version: 0.35.1
resolution: "runed@npm:0.35.1"
@@ -3908,6 +3920,19 @@ __metadata:
languageName: node
linkType: hard
"svelte-toolbelt@npm:^0.7.1":
version: 0.7.1
resolution: "svelte-toolbelt@npm:0.7.1"
dependencies:
clsx: "npm:^2.1.1"
runed: "npm:^0.23.2"
style-to-object: "npm:^1.0.8"
peerDependencies:
svelte: ^5.0.0
checksum: 10c0/a50db97c851fa65af7fbf77007bd76730a179ac0239c0121301bd26682c1078a4ffea77835492550b133849a42d3dffee0714ae076154d86be8d0b3a84c9a9bf
languageName: node
linkType: hard
"svelte2tsx@npm:^0.7.44, svelte2tsx@npm:~0.7.46":
version: 0.7.46
resolution: "svelte2tsx@npm:0.7.46"
@@ -4232,6 +4257,18 @@ __metadata:
languageName: node
linkType: hard
"vaul-svelte@npm:^1.0.0-next.7":
version: 1.0.0-next.7
resolution: "vaul-svelte@npm:1.0.0-next.7"
dependencies:
runed: "npm:^0.23.2"
svelte-toolbelt: "npm:^0.7.1"
peerDependencies:
svelte: ^5.0.0
checksum: 10c0/7a459122b39c9ef6bd830b525d5f6acbc07575491e05c758d9dfdb993cc98ab4dee4a9c022e475760faaf1d7bd8460a1434965431d36885a3ee48315ffa54eb3
languageName: node
linkType: hard
"vite@npm:^6.0.0 || ^7.0.0, vite@npm:^7.2.6":
version: 7.3.0
resolution: "vite@npm:7.3.0"