feature/responsive #22

Merged
ilia merged 49 commits from feature/responsive into main 2026-02-09 06:49:25 +00:00
70 changed files with 2312 additions and 828 deletions

View File

@@ -61,6 +61,7 @@
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^7.2.6", "vite": "^7.2.6",
"vitest": "^4.0.16", "vitest": "^4.0.16",
"vitest-browser-svelte": "^2.0.1" "vitest-browser-svelte": "^2.0.1"

View File

@@ -1,15 +1,20 @@
<!--
Component: QueryProvider
Provides a QueryClientProvider for child components.
All components that use useQueryClient() or createQuery() must be
descendants of this provider.
-->
<script lang="ts"> <script lang="ts">
/**
* Query Provider Component
*
* All components that use useQueryClient() or createQuery() must be
* descendants of this provider.
*/
import { queryClient } from '$shared/api/queryClient'; import { queryClient } from '$shared/api/queryClient';
import { QueryClientProvider } from '@tanstack/svelte-query'; import { QueryClientProvider } from '@tanstack/svelte-query';
import type { Snippet } from 'svelte';
/** Slot content for child components */ interface Props {
let { children } = $props(); children?: Snippet;
}
let { children }: Props = $props();
</script> </script>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>

View File

@@ -11,10 +11,10 @@
* - Footer area (currently empty, reserved for future use) * - Footer area (currently empty, reserved for future use)
*/ */
import { BreadcrumbHeader } from '$entities/Breadcrumb'; import { BreadcrumbHeader } from '$entities/Breadcrumb';
import favicon from '$shared/assets/favicon.svg'; import GD from '$shared/assets/GD.svg';
import { ResponsiveProvider } from '$shared/lib';
import { ScrollArea } from '$shared/shadcn/ui/scroll-area'; import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip'; import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import { TypographyMenu } from '$widgets/TypographySettings';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
@@ -26,7 +26,7 @@ let { children }: Props = $props();
</script> </script>
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={GD} />
<link rel="preconnect" href="https://api.fontshare.com" /> <link rel="preconnect" href="https://api.fontshare.com" />
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" /> <link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
@@ -40,20 +40,25 @@ let { children }: Props = $props();
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Karla:wght@300&display=swap" href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Karla:wght@300&display=swap"
rel="stylesheet" rel="stylesheet"
> >
<title>
Compare Typography & Typefaces | GlyphDiff
</title>
</svelte:head> </svelte:head>
<div id="app-root" class="min-h-screen flex flex-col bg-background"> <ResponsiveProvider>
<div id="app-root" class="min-h-screen flex flex-col bg-background">
<header> <header>
<BreadcrumbHeader /> <BreadcrumbHeader />
</header> </header>
<!-- <ScrollArea class="h-screen w-screen"> --> <!-- <ScrollArea class="h-screen w-screen"> -->
<main class="flex-1 h-full w-full max-w-6xl mx-auto px-4 pt-6 pb-10 md:px-8 lg:pt-10 lg:pb-20 relative"> <main class="flex-1 h-full w-full max-w-6xl mx-auto px-0 pt-0 pb-10 sm:px-6 sm:pt-8 sm:pb-12 md:px-8 md:pt-10 md:pb-16 lg:px-10 lg:pt-12 lg:pb-20 xl:px-16 relative overflow-x-hidden">
<TooltipProvider> <TooltipProvider>
<TypographyMenu />
{@render children?.()} {@render children?.()}
</TooltipProvider> </TooltipProvider>
</main> </main>
<!-- </ScrollArea> --> <!-- </ScrollArea> -->
<footer></footer> <footer></footer>
</div> </div>
</ResponsiveProvider>

View File

@@ -19,29 +19,29 @@ import { scrollBreadcrumbsStore } from '../../model';
backdrop-blur-lg bg-white/20 backdrop-blur-lg bg-white/20
border-b border-gray-300/50 border-b border-gray-300/50
shadow-[0_1px_3px_rgba(0,0,0,0.04)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]
h-12 h-10 sm:h-12
" "
> >
<div class="max-w-8xl mx-auto px-6 h-full flex items-center gap-4"> <div class="max-w-8xl mx-auto px-4 sm:px-6 h-full flex items-center gap-2 sm:gap-4">
<h1 class={cn('font-[Barlow] font-extralight')}> <h1 class={cn('font-[Barlow] font-extralight text-sm sm:text-base')}>
GLYPHDIFF GLYPHDIFF
</h1> </h1>
<div class="h-4 w-px bg-gray-300/60"></div> <div class="h-3.5 sm:h-4 w-px bg-gray-300/60 hidden sm:block"></div>
<nav class="flex items-center gap-3 overflow-x-auto scrollbar-hide flex-1"> <nav class="flex items-center gap-2 sm:gap-3 overflow-x-auto scrollbar-hide flex-1">
{#each scrollBreadcrumbsStore.items as item, idx (item.index)} {#each scrollBreadcrumbsStore.items as item, idx (item.index)}
<div <div
in:fly={{ duration: 300, y: -10, x: 100, opacity: 0 }} in:fly={{ duration: 300, y: -10, x: 100, opacity: 0 }}
out:fly={{ duration: 300, y: 10, x: 100, opacity: 0 }} out:fly={{ duration: 300, y: 10, x: 100, opacity: 0 }}
class="flex items-center gap-3 whitespace-nowrap shrink-0" class="flex items-center gap-2 sm:gap-3 whitespace-nowrap shrink-0"
> >
<span class="font-mono text-[9px] text-gray-400 tracking-wider"> <span class="font-mono text-[8px] sm:text-[9px] text-gray-400 tracking-wider">
{String(item.index).padStart(2, '0')} {String(item.index).padStart(2, '0')}
</span> </span>
{@render item.title({ {@render item.title({
className: 'text-[10px] md:text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-gray-900', className: 'text-[9px] sm:text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-gray-900',
})} })}
{#if idx < scrollBreadcrumbsStore.items.length - 1} {#if idx < scrollBreadcrumbsStore.items.length - 1}
@@ -55,9 +55,9 @@ import { scrollBreadcrumbsStore } from '../../model';
{/each} {/each}
</nav> </nav>
<div class="flex items-center gap-2 opacity-50 ml-auto"> <div class="flex items-center gap-1.5 sm:gap-2 opacity-50 ml-auto">
<div class="w-px h-2.5 bg-gray-300/60"></div> <div class="w-px h-2 sm:h-2.5 bg-gray-300/60 hidden sm:block"></div>
<span class="font-mono text-[8px] text-gray-400 tracking-wider"> <span class="font-mono text-[7px] sm:text-[8px] text-gray-400 tracking-wider">
[{scrollBreadcrumbsStore.items.length}] [{scrollBreadcrumbsStore.items.length}]
</span> </span>
</div> </div>

View File

@@ -86,14 +86,14 @@ function handleNearBottom(lastVisibleIndex: number) {
{#key isLoading} {#key isLoading}
<div class="relative w-full h-full" transition:fade={{ duration: 300 }}> <div class="relative w-full h-full" transition:fade={{ duration: 300 }}>
{#if isLoading} {#if isLoading}
<div class="flex flex-col gap-4 p-4"> <div class="flex flex-col gap-3 sm:gap-4 p-3 sm:p-4">
{#each Array(5) as _, i} {#each Array(5) as _, i}
<div class="flex flex-col gap-2 p-4 border rounded-xl border-gray-200/50 bg-white/40"> <div class="flex flex-col gap-1.5 sm:gap-2 p-3 sm:p-4 border rounded-lg sm:rounded-xl border-gray-200/50 bg-white/40">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-3 sm:mb-4">
<Skeleton class="h-8 w-1/3" /> <Skeleton class="h-6 sm:h-8 w-1/3" />
<Skeleton class="h-8 w-8 rounded-full" /> <Skeleton class="h-6 sm:h-8 w-6 sm:w-8 rounded-full" />
</div> </div>
<Skeleton class="h-32 w-full" /> <Skeleton class="h-24 sm:h-32 w-full" />
</div> </div>
{/each} {/each}
</div> </div>

View File

@@ -11,6 +11,7 @@ import {
import { controlManager } from '$features/SetupFont'; import { controlManager } from '$features/SetupFont';
import { import {
ContentEditable, ContentEditable,
Footnote,
// IconButton, // IconButton,
} from '$shared/ui'; } from '$shared/ui';
// import XIcon from '@lucide/svelte/icons/x'; // import XIcon from '@lucide/svelte/icons/x';
@@ -44,7 +45,7 @@ let {
}: Props = $props(); }: Props = $props();
const fontWeight = $derived(controlManager.weight); const fontWeight = $derived(controlManager.weight);
const fontSize = $derived(controlManager.size); const fontSize = $derived(controlManager.renderedSize);
const lineHeight = $derived(controlManager.height); const lineHeight = $derived(controlManager.height);
const letterSpacing = $derived(controlManager.spacing); const letterSpacing = $derived(controlManager.spacing);
@@ -55,7 +56,7 @@ function removeSample() {
<div <div
class=" class="
w-full h-full rounded-2xl w-full h-full rounded-xl sm:rounded-2xl
flex flex-col flex flex-col
backdrop-blur-md bg-white/80 backdrop-blur-md bg-white/80
border border-gray-300/50 border border-gray-300/50
@@ -64,15 +65,15 @@ function removeSample() {
" "
style:font-weight={fontWeight} style:font-weight={fontWeight}
> >
<div class="px-6 py-3 border-b border-gray-200/60 flex items-center justify-between"> <div class="px-4 sm:px-5 md:px-6 py-2.5 sm:py-3 border-b border-gray-200/60 flex items-center justify-between">
<div class="flex items-center gap-2.5"> <div class="flex items-center gap-2 sm:gap-2.5">
<span class="font-mono text-[9px] uppercase tracking-[0.25em] text-gray-500 font-medium"> <Footnote>
typeface_{String(index).padStart(3, '0')} typeface_{String(index).padStart(3, '0')}
</span> </Footnote>
<div class="w-px h-2.5 bg-gray-300/60"></div> <div class="w-px h-2 sm:h-2.5 bg-gray-300/60"></div>
<span class="font-mono text-[10px] tracking-[0.15em] font-bold uppercase text-gray-900"> <Footnote class="tracking-[0.15em] font-bold text-gray-900">
{font.name} {font.name}
</span> </Footnote>
</div> </div>
<!-- <!--
@@ -87,7 +88,7 @@ function removeSample() {
--> -->
</div> </div>
<div class="p-8 relative z-10"> <div class="p-4 sm:p-5 md:p-8 relative z-10">
<!-- TODO: Fix this ! --> <!-- TODO: Fix this ! -->
<FontApplicator id={font.id} name={font.name} url={font.styles.regular!}> <FontApplicator id={font.id} name={font.name} url={font.styles.regular!}>
<ContentEditable <ContentEditable
@@ -100,21 +101,21 @@ function removeSample() {
</FontApplicator> </FontApplicator>
</div> </div>
<div class="px-6 py-2 border-t border-gray-200/40 w-full flex gap-4 bg-gray-50/30 mt-auto"> <div class="px-4 sm:px-5 md:px-6 py-1.5 sm:py-2 border-t border-gray-200/40 w-full flex flex-row gap-2 sm:gap-4 bg-gray-50/30 mt-auto">
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider ml-auto"> <Footnote class="text-[7px] sm:text-[8px] tracking-wider ml-auto">
SZ:{fontSize}PX SZ:{fontSize}PX
</span> </Footnote>
<div class="w-px h-2.5 self-center bg-gray-300/40"></div> <div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div>
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider"> <Footnote class="text-[7px] sm:text-[8px] tracking-wider">
WGT:{fontWeight} WGT:{fontWeight}
</span> </Footnote>
<div class="w-px h-2.5 self-center bg-gray-300/40"></div> <div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div>
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider"> <Footnote class="text-[7px] sm:text-[8px] tracking-wider">
LH:{lineHeight?.toFixed(2)} LH:{lineHeight?.toFixed(2)}
</span> </Footnote>
<div class="w-px h-2.5 self-center bg-gray-300/40"></div> <div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div>
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider"> <Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider">
LTR:{letterSpacing} LTR:{letterSpacing}
</span> </Footnote>
</div> </div>
</div> </div>

View File

@@ -1,11 +1,13 @@
import SetupFontMenu from './ui/SetupFontMenu.svelte'; export { TypographyMenu } from './ui';
export { export {
type ControlId,
controlManager, controlManager,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING, DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
FONT_SIZE_STEP, FONT_SIZE_STEP,
FONT_WEIGHT_STEP, FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP, LINE_HEIGHT_STEP,
@@ -15,5 +17,12 @@ export {
MIN_FONT_SIZE, MIN_FONT_SIZE,
MIN_FONT_WEIGHT, MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT, MIN_LINE_HEIGHT,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from './model'; } from './model';
export { SetupFontMenu };
export {
createTypographyControlManager,
type TypographyControlManager,
} from './lib';

View File

@@ -1,60 +1,215 @@
import { import {
type ControlDataModel,
type ControlModel, type ControlModel,
type PersistentStore,
type TypographyControl, type TypographyControl,
createPersistentStore,
createTypographyControl, createTypographyControl,
} from '$shared/lib'; } from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import {
type ControlId,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
} from '../../model';
export interface Control { type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
id: string;
increaseLabel?: string; export interface Control extends ControlOnlyFields<ControlId> {
decreaseLabel?: string;
controlLabel?: string;
instance: TypographyControl; instance: TypographyControl;
} }
export class TypographyControlManager { export class TypographyControlManager {
#controls = new SvelteMap<string, Control>(); #controls = new SvelteMap<string, Control>();
#multiplier = $state(1);
#storage: PersistentStore<TypographySettings>;
#baseSize = $state(DEFAULT_FONT_SIZE);
constructor(configs: ControlModel[]) { constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
configs.forEach(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => { this.#storage = storage;
this.#controls.set(id, {
id, // Initial Load
increaseLabel, const saved = storage.value;
decreaseLabel, this.#baseSize = saved.fontSize;
controlLabel,
instance: createTypographyControl(config), // Setup Controls
configs.forEach(config => {
const initialValue = this.#getInitialValue(config.id, saved);
this.#controls.set(config.id, {
...config,
instance: createTypographyControl({
...config,
value: initialValue,
}),
});
});
// The Sync Effect (UI -> Storage)
// We access .value explicitly to ensure Svelte 5 tracks the dependency
$effect.root(() => {
$effect(() => {
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
const fontSize = this.#baseSize;
const fontWeight = this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
const lineHeight = this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
const letterSpacing = this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
// Syncing back to storage
this.#storage.value = {
fontSize,
fontWeight,
lineHeight,
letterSpacing,
};
});
// 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;
if (!ctrl) return;
// If the user moves the slider/clicks buttons in the UI:
// We update the baseSize (User Intent)
const currentDisplayValue = ctrl.value;
const calculatedBase = currentDisplayValue / this.#multiplier;
// Only update if the difference is significant (prevents rounding jitter)
if (Math.abs(this.#baseSize - calculatedBase) > 0.01) {
this.#baseSize = calculatedBase;
}
}); });
}); });
} }
#getInitialValue(id: string, saved: TypographySettings): number {
if (id === 'font_size') return saved.fontSize * this.#multiplier;
if (id === 'font_weight') return saved.fontWeight;
if (id === 'line_height') return saved.lineHeight;
if (id === 'letter_spacing') return saved.letterSpacing;
return 0;
}
// --- Getters / Setters ---
get multiplier() {
return this.#multiplier;
}
set multiplier(value: number) {
if (this.#multiplier === value) return;
this.#multiplier = value;
// When multiplier changes, we must update the Font Size Control's display value
const ctrl = this.#controls.get('font_size')?.instance;
if (ctrl) {
ctrl.value = this.#baseSize * this.#multiplier;
}
}
/** The scaled size for CSS usage */
get renderedSize() {
return this.#baseSize * this.#multiplier;
}
/** The base size (User Preference) */
get baseSize() {
return this.#baseSize;
}
set baseSize(val: number) {
this.#baseSize = val;
const ctrl = this.#controls.get('font_size')?.instance;
if (ctrl) ctrl.value = val * this.#multiplier;
}
/**
* Getters for controls
*/
get controls() { get controls() {
return this.#controls.values(); 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() { get weight() {
return this.#controls.get('font_weight')?.instance.value ?? 400; return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
}
get size() {
return this.#controls.get('font_size')?.instance.value;
} }
get height() { get height() {
return this.#controls.get('line_height')?.instance.value; return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
} }
get spacing() { get spacing() {
return this.#controls.get('letter_spacing')?.instance.value; return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
} }
reset() {
this.#storage.clear();
const defaults = this.#storage.value;
this.#baseSize = defaults.fontSize;
// Reset all control instances
this.#controls.forEach(c => {
if (c.id === 'font_size') {
c.instance.value = defaults.fontSize * this.#multiplier;
} else {
// Map storage key to control id
const key = c.id.replace('_', '') as keyof TypographySettings;
// Simplified for brevity, you'd map these properly:
if (c.id === 'font_weight') c.instance.value = defaults.fontWeight;
if (c.id === 'line_height') c.instance.value = defaults.lineHeight;
if (c.id === 'letter_spacing') c.instance.value = defaults.letterSpacing;
}
});
}
}
/**
* Storage schema for typography settings
*/
export interface TypographySettings {
fontSize: number;
fontWeight: number;
lineHeight: number;
letterSpacing: number;
} }
/** /**
* Creates a typography control manager that handles a collection of typography controls. * Creates a typography control manager that handles a collection of typography controls.
* *
* @param configs - Array of control configurations. * @param configs - Array of control configurations.
* @param storageId - Persistent storage identifier.
* @returns - Typography control manager instance. * @returns - Typography control manager instance.
*/ */
export function createTypographyControlManager(configs: ControlModel[]) { export function createTypographyControlManager(
return new TypographyControlManager(configs); configs: ControlModel<ControlId>[],
storageId: string = 'glyphdiff:typography',
) {
const storage = createPersistentStore<TypographySettings>(storageId, {
fontSize: DEFAULT_FONT_SIZE,
fontWeight: DEFAULT_FONT_WEIGHT,
lineHeight: DEFAULT_LINE_HEIGHT,
letterSpacing: DEFAULT_LETTER_SPACING,
});
return new TypographyControlManager(configs, storage);
} }

View File

@@ -1 +1,4 @@
export { createTypographyControlManager } from './controlManager/controlManager.svelte'; export {
createTypographyControlManager,
type TypographyControlManager,
} from './controlManager/controlManager.svelte';

View File

@@ -1,3 +1,6 @@
import type { ControlModel } from '$shared/lib';
import type { ControlId } from '..';
/** /**
* Font size constants * Font size constants
*/ */
@@ -29,3 +32,57 @@ export const DEFAULT_LETTER_SPACING = 0;
export const MIN_LETTER_SPACING = -0.1; export const MIN_LETTER_SPACING = -0.1;
export const MAX_LETTER_SPACING = 0.5; export const MAX_LETTER_SPACING = 0.5;
export const LETTER_SPACING_STEP = 0.01; 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',
},
];
/**
* Font size multipliers
*/
export const MULTIPLIER_S = 0.5;
export const MULTIPLIER_M = 0.75;
export const MULTIPLIER_L = 1;

View File

@@ -3,6 +3,7 @@ export {
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING, DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
FONT_SIZE_STEP, FONT_SIZE_STEP,
FONT_WEIGHT_STEP, FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP, LINE_HEIGHT_STEP,
@@ -12,6 +13,12 @@ export {
MIN_FONT_SIZE, MIN_FONT_SIZE,
MIN_FONT_WEIGHT, MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT, MIN_LINE_HEIGHT,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from './const/const'; } from './const/const';
export { controlManager } from './state/manager.svelte'; export {
type ControlId,
controlManager,
} from './state/manager.svelte';

View File

@@ -1,69 +1,6 @@
import type { ControlModel } from '$shared/lib';
import { createTypographyControlManager } from '../../lib'; import { createTypographyControlManager } from '../../lib';
import { import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../const/const';
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';
const controlData: ControlModel[] = [ export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
step: FONT_SIZE_STEP,
increaseLabel: 'Increase Font Size', export const controlManager = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA);
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);

View File

@@ -1,21 +0,0 @@
<!--
Component: SetupFontMenu
Contains controls for setting up font properties.
-->
<script lang="ts">
import { ComboControl } from '$shared/ui';
import { controlManager } from '../model';
</script>
<div class="py-2 px-10 flex flex-row items-center gap-2">
<div class="flex flex-row gap-3">
{#each controlManager.controls as control (control.id)}
<ComboControl
control={control.instance}
increaseLabel={control.increaseLabel}
decreaseLabel={control.decreaseLabel}
controlLabel={control.controlLabel}
/>
{/each}
</div>
</div>

View File

@@ -0,0 +1,121 @@
<!--
Component: TypographyMenu
Provides a menu for selecting and configuring typography settings
- On mobile the menu is displayed as a drawer
-->
<script lang="ts">
import type { ResponsiveManager } from '$shared/lib';
import {
Content as ItemContent,
Root as ItemRoot,
} from '$shared/shadcn/ui/item';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
ComboControlV2,
Drawer,
IconButton,
} from '$shared/ui';
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
controlManager,
} from '../model';
interface Props {
class?: string;
}
const { class: className }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
const [send, receive] = crossfade({
duration: 300,
easing: cubicOut,
fallback(node, params) {
// If it can't find a pair, it falls back to a simple fade/slide
return {
duration: 300,
css: t => `opacity: ${t}; transform: translateY(${(1 - t) * 10}px);`,
};
},
});
/**
* Sets the common font size multiplier based on the current responsive state.
*/
$effect(() => {
if (!responsive) {
return;
}
switch (true) {
case responsive.isMobile:
controlManager.multiplier = MULTIPLIER_S;
break;
case responsive.isTablet:
controlManager.multiplier = MULTIPLIER_M;
break;
case responsive.isDesktop:
controlManager.multiplier = MULTIPLIER_L;
break;
default:
controlManager.multiplier = MULTIPLIER_L;
break;
}
});
</script>
<div
class={cn('w-auto max-screen z-10 flex justify-center', className)}
in:receive={{ key: 'panel' }}
out:send={{ key: 'panel' }}
>
{#if responsive.isMobile}
<Drawer>
{#snippet trigger({ onClick })}
<IconButton onclick={onClick}>
{#snippet icon({ className })}
<SlidersIcon class={className} />
{/snippet}
</IconButton>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'flex flex-col gap-6')}>
{#each controlManager.controls as control (control.id)}
<ComboControlV2
control={control.instance}
orientation="horizontal"
reduced
/>
{/each}
</div>
{/snippet}
</Drawer>
{:else}
<ItemRoot
variant="outline"
class="w-full sm:w-auto max-w-full sm:max-w-max p-2 sm:p-2.5 rounded-xl sm:rounded-2xl backdrop-blur-lg"
>
<ItemContent class="flex flex-row justify-center items-center max-w-full sm:max-w-max">
<div class="sm:py-2 sm:px-10 flex flex-row items-center gap-2">
<div class="flex flex-row gap-3">
{#each controlManager.controls as control (control.id)}
<ComboControlV2
control={control.instance}
increaseLabel={control.increaseLabel}
decreaseLabel={control.decreaseLabel}
controlLabel={control.controlLabel}
orientation="vertical"
/>
{/each}
</div>
</div>
</ItemContent>
</ItemRoot>
{/if}
</div>

View File

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

View File

@@ -5,7 +5,10 @@
<script lang="ts"> <script lang="ts">
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb'; import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Section } from '$shared/ui'; import {
Logo,
Section,
} from '$shared/ui';
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte'; import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
import { FontSearch } from '$widgets/FontSearch'; import { FontSearch } from '$widgets/FontSearch';
import { SampleList } from '$widgets/SampleList'; import { SampleList } from '$widgets/SampleList';
@@ -42,8 +45,8 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
</script> </script>
<!-- Font List --> <!-- Font List -->
<div class="p-2 h-full flex flex-col gap-3"> <div class="p-2 sm:p-3 md:p-4 h-full flex flex-col gap-3 sm:gap-4">
<Section class="my-12 gap-8" onTitleStatusChange={handleTitleStatusChanged}> <Section class="my-4 sm:my-10 md:my-12 gap-6 sm:gap-8" onTitleStatusChange={handleTitleStatusChanged}>
{#snippet icon({ className })} {#snippet icon({ className })}
<CodeIcon class={className} /> <CodeIcon class={className} />
{/snippet} {/snippet}
@@ -52,9 +55,7 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
Project_Codename Project_Codename
</span> </span>
{/snippet} {/snippet}
<h2 class={cn('font-[Barlow] font-thin text-7xl md:text-8xl text-justify [text-align-last:justify] [text-justify:inter-character]')}> <Logo />
GLYPHDIFF
</h2>
</Section> </Section>
<Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}> <Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}>
@@ -69,7 +70,7 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
<ComparisonSlider /> <ComparisonSlider />
</Section> </Section>
<Section class="my-12 gap-8" index={2} onTitleStatusChange={handleTitleStatusChanged}> <Section class="my-4 sm:my-10 md:my-12 gap-6 sm:gap-8" index={2} onTitleStatusChange={handleTitleStatusChanged}>
{#snippet icon({ className })} {#snippet icon({ className })}
<ScanSearchIcon class={className} /> <ScanSearchIcon class={className} />
{/snippet} {/snippet}
@@ -81,7 +82,7 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
<FontSearch bind:showFilters={isExpanded} /> <FontSearch bind:showFilters={isExpanded} />
</Section> </Section>
<Section class="my-12 gap-8" index={3} onTitleStatusChange={handleTitleStatusChanged}> <Section class="my-4 sm:my-10 md:my-12 gap-6 sm:gap-8" index={3} onTitleStatusChange={handleTitleStatusChanged}>
{#snippet icon({ className })} {#snippet icon({ className })}
<LineSquiggleIcon class={className} /> <LineSquiggleIcon class={className} />
{/snippet} {/snippet}

4
src/shared/assets/GD.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg width="52" height="35" viewBox="0 0 52 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.608 34.368C8.496 34.368 6.64 33.968 5.04 33.168C3.44 32.336 2.192 31.184 1.296 29.712C0.432 28.208 0 26.48 0 24.528V9.84C0 7.888 0.432 6.176 1.296 4.704C2.192 3.2 3.44 2.048 5.04 1.248C6.64 0.415999 8.496 0 10.608 0C12.688 0 14.528 0.415999 16.128 1.248C17.728 2.048 18.96 3.2 19.824 4.704C20.688 6.176 21.12 7.872 21.12 9.792V10.512C21.12 10.832 20.96 10.992 20.64 10.992H20.16C19.84 10.992 19.68 10.832 19.68 10.512V9.744C19.68 7.216 18.848 5.184 17.184 3.648C15.52 2.112 13.328 1.344 10.608 1.344C7.856 1.344 5.632 2.128 3.936 3.696C2.272 5.232 1.44 7.264 1.44 9.792V24.576C1.44 27.104 2.272 29.152 3.936 30.72C5.632 32.256 7.856 33.024 10.608 33.024C13.328 33.024 15.52 32.272 17.184 30.768C18.848 29.232 19.68 27.2 19.68 24.672V19.152C19.68 19.024 19.616 18.96 19.488 18.96H11.472C11.152 18.96 10.992 18.8 10.992 18.48V18.144C10.992 17.824 11.152 17.664 11.472 17.664H20.64C20.96 17.664 21.12 17.824 21.12 18.144V24.48C21.12 26.464 20.688 28.208 19.824 29.712C18.96 31.184 17.728 32.336 16.128 33.168C14.528 33.968 12.688 34.368 10.608 34.368Z" fill="white"/>
<path d="M31.2124 33.984C30.8924 33.984 30.7324 33.824 30.7324 33.504V0.863997C30.7324 0.543998 30.8924 0.383998 31.2124 0.383998H42.1084C45.0204 0.383998 47.3084 1.168 48.9724 2.736C50.6684 4.272 51.5164 6.4 51.5164 9.12V25.248C51.5164 27.968 50.6684 30.112 48.9724 31.68C47.3084 33.216 45.0204 33.984 42.1084 33.984H31.2124ZM32.1724 32.448C32.1724 32.576 32.2364 32.64 32.3644 32.64H42.2044C44.6364 32.64 46.5564 31.984 47.9644 30.672C49.3724 29.328 50.0764 27.504 50.0764 25.2V9.216C50.0764 6.88 49.3724 5.056 47.9644 3.744C46.5564 2.4 44.6364 1.728 42.2044 1.728H32.3644C32.2364 1.728 32.1724 1.792 32.1724 1.92V32.448Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -49,3 +49,5 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
}, },
}; };
} }
export type PersistentStore<T> = ReturnType<typeof createPersistentStore<T>>;

View File

@@ -0,0 +1,231 @@
// $shared/lib/createResponsiveManager.svelte.ts
/**
* Breakpoint definitions following common device sizes
* Customize these values to match your design system
*/
export interface Breakpoints {
/** Mobile devices (portrait phones) */
mobile: number;
/** Tablet portrait */
tabletPortrait: number;
/** Tablet landscape */
tablet: number;
/** Desktop */
desktop: number;
/** Large desktop */
desktopLarge: number;
}
/**
* Default breakpoints (matches common Tailwind-like breakpoints)
*/
const DEFAULT_BREAKPOINTS: Breakpoints = {
mobile: 640, // sm
tabletPortrait: 768, // md
tablet: 1024, // lg
desktop: 1280, // xl
desktopLarge: 1536, // 2xl
};
/**
* Orientation type
*/
export type Orientation = 'portrait' | 'landscape';
/**
* Creates a reactive responsive manager that tracks viewport size and breakpoints.
*
* Provides reactive getters for:
* - Current breakpoint detection (isMobile, isTablet, etc.)
* - Viewport dimensions (width, height)
* - Device orientation (portrait/landscape)
* - Custom breakpoint matching
*
* @param customBreakpoints - Optional custom breakpoint values
* @returns Responsive manager instance with reactive properties
*
* @example
* ```svelte
* <script lang="ts">
* const responsive = createResponsiveManager();
* </script>
*
* {#if responsive.isMobile}
* <MobileNav />
* {:else if responsive.isTablet}
* <TabletNav />
* {:else}
* <DesktopNav />
* {/if}
*
* <p>Width: {responsive.width}px</p>
* <p>Orientation: {responsive.orientation}</p>
* ```
*/
export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) {
const breakpoints: Breakpoints = {
...DEFAULT_BREAKPOINTS,
...customBreakpoints,
};
// Reactive state
let width = $state(typeof window !== 'undefined' ? window.innerWidth : 0);
let height = $state(typeof window !== 'undefined' ? window.innerHeight : 0);
// Derived breakpoint states
const isMobile = $derived(width < breakpoints.mobile);
const isTabletPortrait = $derived(
width >= breakpoints.mobile && width < breakpoints.tabletPortrait,
);
const isTablet = $derived(
width >= breakpoints.tabletPortrait && width < breakpoints.desktop,
);
const isDesktop = $derived(
width >= breakpoints.desktop && width < breakpoints.desktopLarge,
);
const isDesktopLarge = $derived(width >= breakpoints.desktopLarge);
// Convenience groupings
const isMobileOrTablet = $derived(width < breakpoints.desktop);
const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait);
// Orientation
const orientation = $derived<Orientation>(height > width ? 'portrait' : 'landscape');
const isPortrait = $derived(orientation === 'portrait');
const isLandscape = $derived(orientation === 'landscape');
// Touch device detection (best effort)
const isTouchDevice = $derived(
typeof window !== 'undefined'
&& ('ontouchstart' in window || navigator.maxTouchPoints > 0),
);
/**
* Initialize responsive tracking
* Call this in an $effect or component mount
*/
function init() {
if (typeof window === 'undefined') return;
const handleResize = () => {
width = window.innerWidth;
height = window.innerHeight;
};
// Use ResizeObserver for more accurate tracking
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(document.documentElement);
// Fallback to window resize event
window.addEventListener('resize', handleResize, { passive: true });
// Initial measurement
handleResize();
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', handleResize);
};
}
/**
* Check if current width matches a custom breakpoint
* @param min - Minimum width (inclusive)
* @param max - Maximum width (exclusive)
*/
function matches(min: number, max?: number): boolean {
if (max !== undefined) {
return width >= min && width < max;
}
return width >= min;
}
/**
* Get the current breakpoint name
*/
const currentBreakpoint = $derived<keyof Breakpoints | 'xs'>(
(() => {
if (isMobile) return 'mobile';
if (isTabletPortrait) return 'tabletPortrait';
if (isTablet) return 'tablet';
if (isDesktop) return 'desktop';
if (isDesktopLarge) return 'desktopLarge';
return 'xs'; // Fallback for very small screens
})(),
);
return {
// Dimensions
get width() {
return width;
},
get height() {
return height;
},
// Standard breakpoints
get isMobile() {
return isMobile;
},
get isTabletPortrait() {
return isTabletPortrait;
},
get isTablet() {
return isTablet;
},
get isDesktop() {
return isDesktop;
},
get isDesktopLarge() {
return isDesktopLarge;
},
// Convenience groupings
get isMobileOrTablet() {
return isMobileOrTablet;
},
get isTabletOrDesktop() {
return isTabletOrDesktop;
},
// Orientation
get orientation() {
return orientation;
},
get isPortrait() {
return isPortrait;
},
get isLandscape() {
return isLandscape;
},
// Device capabilities
get isTouchDevice() {
return isTouchDevice;
},
// Current breakpoint
get currentBreakpoint() {
return currentBreakpoint;
},
// Methods
init,
matches,
// Breakpoint values (for custom logic)
breakpoints,
};
}
export const responsiveManager = createResponsiveManager();
if (typeof window !== 'undefined') {
responsiveManager.init();
}
/**
* Type for the responsive manager instance
*/
export type ResponsiveManager = ReturnType<typeof createResponsiveManager>;

View File

@@ -22,11 +22,11 @@ export interface ControlDataModel {
step: number; step: number;
} }
export interface ControlModel extends ControlDataModel { export interface ControlModel<T extends string = string> extends ControlDataModel {
/** /**
* Control identifier * Control identifier
*/ */
id: string; id: T;
/** /**
* Area label for increase button * Area label for increase button
*/ */
@@ -59,10 +59,10 @@ export function createTypographyControl<T extends ControlDataModel>(
return value; return value;
}, },
set value(newValue) { set value(newValue) {
value = roundToStepPrecision( const rounded = roundToStepPrecision(clampNumber(newValue, min, max), step);
clampNumber(newValue, min, max), if (value !== rounded) {
step, value = rounded;
); }
}, },
get max() { get max() {
return max; return max;

View File

@@ -202,6 +202,16 @@ export function createVirtualizer<T>(
}); });
} }
// console.log('🎯 Virtual Items Calculation:', {
// scrollOffset,
// containerHeight,
// viewportEnd,
// startIdx,
// endIdx,
// withOverscan: { start, end },
// itemCount: end - start,
// });
return result; return result;
}); });
// Svelte Actions (The DOM Interface) // Svelte Actions (The DOM Interface)
@@ -225,32 +235,48 @@ export function createVirtualizer<T>(
return rect.top + window.scrollY; return rect.top + window.scrollY;
}; };
let cachedOffsetTop = getElementOffset(); let cachedOffsetTop = 0;
let rafId: number | null = null;
containerHeight = window.innerHeight; containerHeight = window.innerHeight;
const handleScroll = () => { const handleScroll = () => {
// Use cached offset for scroll calculations if (rafId !== null) return;
scrollOffset = Math.max(0, window.scrollY - cachedOffsetTop);
rafId = requestAnimationFrame(() => {
// Get current position of element relative to viewport
const rect = node.getBoundingClientRect();
// Calculate how much of the element has scrolled past the top of viewport
// When element.top is 0, element is at top of viewport
// When element.top is -100, element has scrolled up 100px past viewport top
const scrolledPastTop = Math.max(0, -rect.top);
scrollOffset = scrolledPastTop;
rafId = null;
});
// 🔍 DIAGNOSTIC
// console.log('📜 Scroll Event:', {
// windowScrollY: window.scrollY,
// elementRectTop: rect.top,
// scrolledPastTop,
// containerHeight
// });
}; };
const handleResize = () => { const handleResize = () => {
const oldHeight = containerHeight;
containerHeight = window.innerHeight; containerHeight = window.innerHeight;
cachedOffsetTop = getElementOffset();
// Recalculate offset on resize (layout may have shifted) handleScroll();
const newOffsetTop = getElementOffset();
if (Math.abs(newOffsetTop - cachedOffsetTop) > 0.5) {
cachedOffsetTop = newOffsetTop;
handleScroll(); // Recalculate scroll position
}
}; };
// Initial setup
requestAnimationFrame(() => {
cachedOffsetTop = getElementOffset();
handleScroll();
});
window.addEventListener('scroll', handleScroll, { passive: true }); window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
// Initial calculation
handleScroll();
return { return {
destroy() { destroy() {
window.removeEventListener('scroll', handleScroll); window.removeEventListener('scroll', handleScroll);
@@ -259,6 +285,10 @@ export function createVirtualizer<T>(
cancelAnimationFrame(frameId); cancelAnimationFrame(frameId);
frameId = null; frameId = null;
} }
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
elementRef = null; elementRef = null;
}, },
}; };
@@ -366,6 +396,12 @@ export function createVirtualizer<T>(
} }
return { return {
get scrollOffset() {
return scrollOffset;
},
get containerHeight() {
return containerHeight;
},
/** Computed array of visible items to render (reactive) */ /** Computed array of visible items to render (reactive) */
get items() { get items() {
return items; return items;

View File

@@ -32,4 +32,13 @@ export {
type LineData, type LineData,
} from './createCharacterComparison/createCharacterComparison.svelte'; } from './createCharacterComparison/createCharacterComparison.svelte';
export { createPersistentStore } from './createPersistentStore/createPersistentStore.svelte'; export {
createPersistentStore,
type PersistentStore,
} from './createPersistentStore/createPersistentStore.svelte';
export {
createResponsiveManager,
type ResponsiveManager,
responsiveManager,
} from './createResponsiveManager/createResponsiveManager.svelte';

View File

@@ -6,6 +6,7 @@ export {
createEntityStore, createEntityStore,
createFilter, createFilter,
createPersistentStore, createPersistentStore,
createResponsiveManager,
createTypographyControl, createTypographyControl,
createVirtualizer, createVirtualizer,
type Entity, type Entity,
@@ -13,7 +14,10 @@ export {
type Filter, type Filter,
type FilterModel, type FilterModel,
type LineData, type LineData,
type PersistentStore,
type Property, type Property,
type ResponsiveManager,
responsiveManager,
type TypographyControl, type TypographyControl,
type VirtualItem, type VirtualItem,
type Virtualizer, type Virtualizer,
@@ -23,3 +27,5 @@ export {
export { splitArray } from './utils'; export { splitArray } from './utils';
export { springySlideFade } from './transitions'; export { springySlideFade } from './transitions';
export { ResponsiveProvider } from './providers';

View File

@@ -0,0 +1,30 @@
<!--
Component: ResponsiveProvider
Provides a responsive manager to all children
-->
<script lang="ts">
import {
type ResponsiveManager,
createResponsiveManager,
} from '$shared/lib/helpers';
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
}
let { children }: Props = $props();
const responsive = createResponsiveManager();
// Initialize and cleanup
$effect(() => {
return responsive.init();
});
// Provide to all children
setContext('responsive', responsive);
</script>
{@render children()}

View File

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

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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}
/>

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="drawer-footer"
class={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...restProps}
>
{@render children?.()}
</div>

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="drawer-header"
class={cn('flex flex-col gap-1.5 p-4', className)}
{...restProps}
>
{@render children?.()}
</div>

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.NestedRoot {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />

View File

@@ -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}
/>

View File

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

View File

@@ -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}
/>

View File

@@ -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} />

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} />

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,
};

View File

@@ -54,7 +54,7 @@ const hasSelection = $derived(selectedCount > 0);
class="w-full bg-card transition-colors hover:bg-accent/5" class="w-full bg-card transition-colors hover:bg-accent/5"
> >
<!-- Trigger row: title, expand indicator, and optional count badge --> <!-- Trigger row: title, expand indicator, and optional count badge -->
<div class="flex items-center justify-between px-4 py-2"> <div class="flex items-center justify-between px-3 sm:px-4 py-2">
<CollapsibleTrigger <CollapsibleTrigger
class={buttonVariants({ class={buttonVariants({
variant: 'ghost', variant: 'ghost',
@@ -62,14 +62,14 @@ const hasSelection = $derived(selectedCount > 0);
class: 'flex-1 justify-between gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring', class: 'flex-1 justify-between gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring',
})} })}
> >
<h4 class="text-sm font-semibold">{displayedLabel}</h4> <h4 class="text-xs sm:text-sm font-semibold">{displayedLabel}</h4>
<!-- Badge only appears when items are selected to avoid clutter --> <!-- Badge only appears when items are selected to avoid clutter -->
{#if hasSelection} {#if hasSelection}
<Badge <Badge
variant="secondary" variant="secondary"
data-testid="badge" data-testid="badge"
class="mr-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums" class="mr-auto h-4 sm:h-5 min-w-4 sm:min-w-5 px-1 sm:px-1.5 text-[10px] sm:text-xs font-medium tabular-nums"
> >
{selectedCount} {selectedCount}
</Badge> </Badge>
@@ -81,7 +81,7 @@ const hasSelection = $derived(selectedCount > 0);
class="shrink-0 transition-transform duration-200 ease-out" class="shrink-0 transition-transform duration-200 ease-out"
style:transform={isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'} style:transform={isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'}
> >
<ChevronDownIcon class="h-4 w-4" /> <ChevronDownIcon class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</div> </div>
</CollapsibleTrigger> </CollapsibleTrigger>
</div> </div>

View File

@@ -26,7 +26,7 @@ import PlusIcon from '@lucide/svelte/icons/plus';
import type { ChangeEventHandler } from 'svelte/elements'; import type { ChangeEventHandler } from 'svelte/elements';
import IconButton from '../IconButton/IconButton.svelte'; import IconButton from '../IconButton/IconButton.svelte';
interface ComboControlProps { interface Props {
/** /**
* Text for increase button aria-label * Text for increase button aria-label
*/ */
@@ -43,6 +43,10 @@ interface ComboControlProps {
* Control instance * Control instance
*/ */
control: TypographyControl; control: TypographyControl;
/**
* Reduced amount of controls
*/
reduced?: boolean;
} }
const { const {
@@ -50,7 +54,8 @@ const {
decreaseLabel, decreaseLabel,
increaseLabel, increaseLabel,
controlLabel, controlLabel,
}: ComboControlProps = $props(); reduced = false,
}: Props = $props();
// Local state for the slider to prevent infinite loops // Local state for the slider to prevent infinite loops
// svelte-ignore state_referenced_locally - $state captures initial value, $effect syncs updates // svelte-ignore state_referenced_locally - $state captures initial value, $effect syncs updates
@@ -80,6 +85,7 @@ const handleSliderChange = (newValue: number) => {
<TooltipRoot> <TooltipRoot>
<ButtonGroupRoot class="bg-transparent border-none shadow-none"> <ButtonGroupRoot class="bg-transparent border-none shadow-none">
<TooltipTrigger class="flex items-center"> <TooltipTrigger class="flex items-center">
{#if !reduced}
<IconButton <IconButton
onclick={control.decrease} onclick={control.decrease}
disabled={control.isAtMin} disabled={control.isAtMin}
@@ -90,6 +96,7 @@ const handleSliderChange = (newValue: number) => {
<MinusIcon class={className} /> <MinusIcon class={className} />
{/snippet} {/snippet}
</IconButton> </IconButton>
{/if}
<PopoverRoot> <PopoverRoot>
<PopoverTrigger> <PopoverTrigger>
{#snippet child({ props })} {#snippet child({ props })}
@@ -127,6 +134,7 @@ const handleSliderChange = (newValue: number) => {
</PopoverContent> </PopoverContent>
</PopoverRoot> </PopoverRoot>
{#if !reduced}
<IconButton <IconButton
aria-label={increaseLabel} aria-label={increaseLabel}
onclick={control.increase} onclick={control.increase}
@@ -137,6 +145,7 @@ const handleSliderChange = (newValue: number) => {
<PlusIcon class={className} /> <PlusIcon class={className} />
{/snippet} {/snippet}
</IconButton> </IconButton>
{/if}
</TooltipTrigger> </TooltipTrigger>
</ButtonGroupRoot> </ButtonGroupRoot>
{#if controlLabel} {#if controlLabel}

View File

@@ -0,0 +1,43 @@
<script module>
import { createTypographyControl } from '$shared/lib';
import { defineMeta } from '@storybook/addon-svelte-csf';
import ComboControlV2 from './ComboControlV2.svelte';
const { Story } = defineMeta({
title: 'Shared/ComboControlV2',
component: ComboControlV2,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'ComboControl with input field and slider',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
argTypes: {
orientation: {
control: 'select',
options: ['horizontal', 'vertical'],
description: 'Orientation of the ComboControl',
defaultValue: 'vertical',
},
label: {
control: 'text',
description: 'Label for the ComboControl',
},
},
});
</script>
<script lang="ts">
const control = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
</script>
<Story name="Horizontal" args={{ orientation: 'horizontal', control }}>
<ComboControlV2 control={control} orientation="horizontal" />
</Story>
<Story name="Vertical" args={{ orientation: 'vertical', control, class: 'h-48' }}>
<ComboControlV2 control={control} orientation="vertical" />
</Story>

View File

@@ -4,69 +4,226 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { TypographyControl } from '$shared/lib'; import type { TypographyControl } from '$shared/lib';
import { Input } from '$shared/shadcn/ui/input'; import { Button } from '$shared/shadcn/ui/button';
import { Slider } from '$shared/shadcn/ui/slider'; import { Root as ButtonGroupRoot } from '$shared/shadcn/ui/button-group';
import {
Content as PopoverContent,
Root as PopoverRoot,
Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover';
import {
Content as TooltipContent,
Root as TooltipRoot,
Trigger as TooltipTrigger,
} from '$shared/shadcn/ui/tooltip';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte'; import { Input } from '$shared/ui';
import { Slider } from '$shared/ui';
import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus';
import {
type Orientation,
REGEXP_ONLY_DIGITS,
} from 'bits-ui';
import type { ChangeEventHandler } from 'svelte/elements'; import type { ChangeEventHandler } from 'svelte/elements';
import IconButton from '../IconButton/IconButton.svelte';
interface Props { interface Props {
/**
* Control instance
*/
control: TypographyControl; control: TypographyControl;
ref?: Snippet; /**
* Orientation
*/
orientation?: Orientation;
/**
* Label text
*/
label?: string;
/**
* CSS class
*/
class?: string;
/**
* Show scale flag
*/
showScale?: boolean;
/**
* Flag that change component appearance
* from the one with increase/decrease buttons and popover with input + slider
* to just input + slider
*/
reduced?: boolean;
/**
* Text for increase button aria-label
*/
increaseLabel?: string;
/**
* Text for decrease button aria-label
*/
decreaseLabel?: string;
/**
* Text for control button aria-label
*/
controlLabel?: string;
} }
let { let {
control, control,
ref = $bindable(), orientation = 'vertical',
label,
class: className,
showScale = true,
reduced = false,
increaseLabel = 'Increase',
decreaseLabel = 'Decrease',
controlLabel,
}: Props = $props(); }: Props = $props();
let sliderValue = $state(Number(control.value)); let inputValue = $state(String(control.value));
$effect(() => { $effect(() => {
sliderValue = Number(control.value); inputValue = String(control.value);
}); });
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => { const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
const parsedValue = parseFloat(event.currentTarget.value); const parsedValue = parseFloat(event.currentTarget.value);
if (!isNaN(parsedValue)) { if (!isNaN(parsedValue)) {
control.value = parsedValue; control.value = parsedValue;
inputValue = String(parsedValue);
} }
}; };
const handleSliderChange = (newValue: number) => { function calculateScale(index: number): number | string {
control.value = newValue; const calculate = () =>
}; orientation === 'horizontal'
? (control.min + (index * (control.max - control.min) / 4))
// Shared glass button class for consistency : (control.max - (index * (control.max - control.min) / 4));
// const glassBtnClass = cn( return Number.isInteger(control.step)
// 'border-none transition-all duration-200', ? Math.round(calculate())
// 'bg-white/10 hover:bg-white/40 active:scale-90', : (calculate()).toFixed(2);
// '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> </script>
<div class="flex flex-col items-center gap-4"> {#snippet ComboControl()}
<Input <div
value={control.value} class={cn(
onchange={handleInputChange} 'flex gap-4 sm:p-4 rounded-xl transition-all duration-300',
min={control.min} 'backdrop-blur-md',
max={control.max} orientation === 'horizontal' ? 'flex-row items-end w-full' : 'flex-col items-center h-full',
class="w-14 h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50" className,
/> )}
>
<div class={cn('relative', orientation === 'horizontal' ? 'w-full' : 'h-full')}>
{#if showScale}
<div
class={cn(
'absolute flex justify-between',
orientation === 'horizontal' ? 'flex-row w-full -top-5 px-0.5' : 'flex-col h-full -left-5 py-0.5',
)}
>
{#each Array(5) as _, i}
<div
class={cn(
'flex items-center gap-1.5',
orientation === 'horizontal' ? 'flex-col' : 'flex-row',
)}
>
<span class="font-mono text-[0.375rem] text-gray-400 tabular-nums">
{calculateScale(i)}
</span>
<div class={cn('bg-gray-300', orientation === 'horizontal' ? 'w-px h-1' : 'h-px w-1')}>
</div>
</div>
{/each}
</div>
{/if}
<Slider <Slider
class={cn(orientation === 'horizontal' ? 'w-full' : 'h-full')}
bind:value={control.value}
min={control.min} min={control.min}
max={control.max} max={control.max}
step={control.step} step={control.step}
value={sliderValue} {orientation}
onValueChange={handleSliderChange}
type="single"
orientation="vertical"
class="h-30"
/> />
</div> </div>
<Input
class="h-10 rounded-lg w-12 pl-1 pr-1 sm:pr-1 md:pr-1 sm:pl-1 md:pl-1 text-center"
value={inputValue}
onchange={handleInputChange}
min={control.min}
max={control.max}
step={control.step}
pattern={REGEXP_ONLY_DIGITS}
variant="ghost"
/>
{#if label}
<div class="flex items-center gap-2 opacity-70">
<div class="w-1 h-1 rounded-full bg-gray-900"></div>
<div class="w-px h-2 bg-gray-400/50"></div>
<span class="font-mono text-[8px] uppercase tracking-[0.2em] text-gray-500 font-medium">
{label}
</span>
</div>
{/if}
</div>
{/snippet}
{#if reduced}
{@render ComboControl()}
{:else}
<TooltipRoot>
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
<TooltipTrigger class="flex items-center">
<IconButton
onclick={control.decrease}
disabled={control.isAtMin}
aria-label={decreaseLabel}
rotation="counterclockwise"
>
{#snippet icon({ className })}
<MinusIcon class={className} />
{/snippet}
</IconButton>
<PopoverRoot>
<PopoverTrigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
class="hover:bg-white/50 hover:font-bold bg-white/20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
size="icon"
aria-label={controlLabel}
>
{control.value}
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-auto h-64 sm:px-1 py-0">
{@render ComboControl()}
</PopoverContent>
</PopoverRoot>
<IconButton
aria-label={increaseLabel}
onclick={control.increase}
disabled={control.isAtMax}
rotation="clockwise"
>
{#snippet icon({ className })}
<PlusIcon class={className} />
{/snippet}
</IconButton>
</TooltipTrigger>
</ButtonGroupRoot>
{#if controlLabel}
<TooltipContent>
{controlLabel}
</TooltipContent>
{/if}
</TooltipRoot>
{/if}

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; className?: string }]>;
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>
{@render content?.({ isOpen, className: cn('min-h-60 px-2 pt-4 pb-8', contentClassName) })}
</DrawerContent>
</DrawerRoot>

View File

@@ -173,7 +173,7 @@ $effect(() => {
<div <div
class={cn( 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 expanded
? 'bg-white/5 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)]', : ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',

View File

@@ -0,0 +1,31 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Footnote from './Footnote.svelte';
const { Story } = defineMeta({
title: 'Shared/Footnote',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Styles footnote text',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
});
</script>
<Story name="Default">
<Footnote>
Footnote
</Footnote>
</Story>
<Story name="With custom render">
<Footnote>
{#snippet render({ class: className })}
<span class={className}>Footnote</span>
{/snippet}
</Footnote>
</Story>

View File

@@ -0,0 +1,30 @@
<!--
Component: Footnote
Provides classes for styling footnotes
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
interface Props {
children?: Snippet;
class?: string;
/**
* Custom render function for full control
*/
render?: Snippet<[{ class: string }]>;
}
const { children, class: className, render }: Props = $props();
const baseClasses = 'font-mono text-[0.5625rem] sm:text-[0.625rem] uppercase tracking-[0.2em] text-gray-500 opacity-60';
const combinedClasses = cn(baseClasses, className);
</script>
{#if render}
{@render render({ class: combinedClasses })}
{:else if children}
<span class={combinedClasses}>
{@render children()}
</span>
{/if}

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>

View File

@@ -0,0 +1,61 @@
<!--
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;
variant?: 'default' | 'ghost';
};
let {
value = $bindable(''),
class: className,
variant = 'default',
...rest
}: Props = $props();
const isGhost = $derived(variant === 'ghost');
</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',
isGhost ? 'bg-transparent' : 'bg-white/80',
'border border-gray-300/50',
isGhost ? 'border-transparent' : 'border-gray-300/50',
isGhost ? 'shadow-none' : '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-4 sm:pl-6 pr-4 sm:pr-6',
'rounded-xl',
'transition-all duration-200',
'font-medium',
className,
)}
{...rest}
/>

View File

@@ -0,0 +1,21 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Logo from './Logo.svelte';
const { Story } = defineMeta({
title: 'Shared/Logo',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Projec Logo',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
});
</script>
<Story name="Default">
<Logo />
</Story>

View File

@@ -0,0 +1,19 @@
<!--
Component: Logo
Project logo with apropriate styles
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props {
class?: string;
}
const { class: className }: Props = $props();
const baseClasses =
'font-[Barlow] font-thin text-5xl sm:text-6xl md:text-7xl lg:text-8xl text-justify [text-align-last:justify] [text-justify:inter-character]';
</script>
<h2 class={cn(baseClasses, className)}>
GLYPHDIFF
</h2>

View File

@@ -24,18 +24,12 @@ const { Story } = defineMeta({
control: 'text', control: 'text',
description: 'Placeholder text for the input', description: 'Placeholder text for the input',
}, },
label: {
control: 'text',
description: 'Optional label displayed above the input',
},
}, },
}); });
</script> </script>
<script lang="ts"> <script lang="ts">
let defaultSearchValue = $state(''); let defaultSearchValue = $state('');
let withLabelValue = $state('');
let noChildrenValue = $state('');
</script> </script>
<Story <Story
@@ -45,26 +39,5 @@ let noChildrenValue = $state('');
placeholder: 'Type here...', placeholder: 'Type here...',
}} }}
> >
<SearchBar bind:value={defaultSearchValue} placeholder="Type here..."> </SearchBar> <SearchBar bind:value={defaultSearchValue} placeholder="Type here..." />
</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>
</Story> </Story>

View File

@@ -1,6 +1,7 @@
<!-- Component: SearchBar --> <!-- Component: SearchBar -->
<script lang="ts"> <script lang="ts">
import { Input } from '$shared/shadcn/ui/input'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Input } from '$shared/ui';
import AsteriskIcon from '@lucide/svelte/icons/asterisk'; import AsteriskIcon from '@lucide/svelte/icons/asterisk';
interface Props { interface Props {
@@ -20,10 +21,6 @@ interface Props {
* Placeholder text for the input * Placeholder text for the input
*/ */
placeholder?: string; placeholder?: string;
/**
* Optional label displayed above the input
*/
label?: string;
} }
let { let {
@@ -32,44 +29,11 @@ let {
class: className, class: className,
placeholder, placeholder,
}: Props = $props(); }: Props = $props();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
event.preventDefault();
}
}
</script> </script>
<div class="relative w-full"> <div class="relative w-full">
<div class="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10"> <div class="absolute left-4 sm:left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
<AsteriskIcon class="size-4 stroke-gray-400 stroke-[1.5]" /> <AsteriskIcon class="size-3 sm:size-4 stroke-gray-400 stroke-[1.5]" />
</div> </div>
<Input <Input {id} class={cn('pl-11 sm:pl-14', className)} bind:value={value} placeholder={placeholder} />
id={id}
placeholder={placeholder}
bind:value={value}
onkeydown={handleKeyDown}
class="
h-16 w-full 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-sm
placeholder:tracking-wide
pl-14 pr-6
rounded-xl
transition-all duration-200
font-medium
"
/>
</div> </div>

View File

@@ -11,6 +11,7 @@ import {
type FlyParams, type FlyParams,
fly, fly,
} from 'svelte/transition'; } from 'svelte/transition';
import { Footnote } from '..';
interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> { interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
/** /**
@@ -90,23 +91,30 @@ $effect(() => {
in:fly={flyParams} in:fly={flyParams}
out:fly={flyParams} out:fly={flyParams}
> >
<div class="flex flex-col gap-2" bind:this={titleContainer}> <div class="flex flex-col gap-2 sm:gap-3" bind:this={titleContainer}>
<div class="flex items-center gap-3 opacity-60"> <div class="flex items-center gap-2 sm:gap-3">
{#if icon} {#if icon}
{@render icon({ className: 'size-4 stroke-gray-900 stroke-1' })} {@render icon({ className: 'size-3 sm:size-4 stroke-gray-900 stroke-1 opacity-60' })}
<div class="w-px h-3 bg-gray-400/50"></div> <div class="w-px h-2.5 sm:h-3 bg-gray-300/60"></div>
{/if} {/if}
{#if description} {#if description}
{@render description({ className: 'font-mono text-[10px] uppercase tracking-[0.2em] text-gray-600' })} <Footnote>
{#snippet render({ class: className })}
{@render description({ className })}
{/snippet}
</Footnote>
{:else if typeof index === 'number'} {:else if typeof index === 'number'}
<span class="font-mono text-[10px] uppercase tracking-[0.2em] text-gray-600"> <Footnote>
Component_{String(index).padStart(3, '0')} Component_{String(index).padStart(3, '0')}
</span> </Footnote>
{/if} {/if}
</div> </div>
{#if title} {#if title}
{@render title({ className: 'text-5xl md:text-6xl font-semibold tracking-tighter text-gray-900 leading-[0.9]' })} {@render title({
className:
'text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tighter text-gray-900 leading-[0.9]',
})}
{/if} {/if}
</div> </div>

View File

@@ -0,0 +1,51 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Slider from './Slider.svelte';
const { Story } = defineMeta({
title: 'Shared/Slider',
component: Slider,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Styled bits-ui slider component for selecting a value within a range.',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
argTypes: {
value: {
control: 'number',
description: 'Current value (two-way bindable)',
},
min: {
control: 'number',
description: 'Minimum value',
},
max: {
control: 'number',
description: 'Maximum value',
},
step: {
control: 'number',
description: 'Step size for value increments',
},
},
});
</script>
<script lang="ts">
let minValue = 0;
let maxValue = 100;
let stepValue = 1;
let value = $state(50);
</script>
<Story name="Horizontal" args={{ orientation: 'horizontal', min: minValue, max: maxValue, step: stepValue, value }}>
<Slider bind:value min={minValue} max={maxValue} step={stepValue} />
</Story>
<Story name="Vertical" args={{ orientation: 'vertical', min: minValue, max: maxValue, step: stepValue, value }}>
<Slider bind:value min={minValue} max={maxValue} step={stepValue} orientation="vertical" />
</Story>

View File

@@ -0,0 +1,105 @@
<!--
Component: Slider
Styled bits-ui slider component with single value.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
Slider,
type SliderRootProps,
} from 'bits-ui';
type Props = Omit<SliderRootProps, 'type' | 'onValueChange' | 'onValueCommit'> & {
/**
* Slider value, numeric.
*/
value: number;
/**
* A callback function called when the value changes.
* @param newValue - number
*/
onValueChange?: (newValue: number) => void;
/**
* A callback function called when the user stops dragging the thumb and the value is committed.
* @param newValue - number
*/
onValueCommit?: (newValue: number) => void;
};
let { value = $bindable(), orientation = 'horizontal', class: className, ...rest }: Props = $props();
</script>
<Slider.Root
bind:value={value}
class={cn(
'relative flex h-full w-6 touch-none select-none items-center justify-center',
orientation === 'horizontal' ? 'w-48 h-6' : 'w-6 h-48',
className,
)}
type="single"
{orientation}
{...rest}
>
{#snippet children(props)}
<span
{...props}
class={cn('relative bg-gray-200 rounded-full', orientation === 'horizontal' ? 'w-full h-px' : 'h-full w-px')}
>
<!-- Filled range with NO transition -->
<Slider.Range
class={cn('absolute bg-gray-900 rounded-full', orientation === 'horizontal' ? 'h-full' : 'w-full')}
/>
<Slider.Thumb
index={0}
class={cn(
'group/thumb relative block',
orientation === 'horizontal' ? '-top-1 w-2 h-2.25' : '-left-1 h-2 w-2.25',
'rounded-sm',
'bg-gray-900',
// Glow shadow
'shadow-[0_0_6px_rgba(0,0,0,0.4)]',
// Smooth transitions only for size/position
'duration-200 ease-out',
orientation === 'horizontal' ? 'transition-[height,top,left,box-shadow]' : 'transition-[width,top,left,box-shadow]',
// Hover: bigger glow
'hover:shadow-[0_0_10px_rgba(0,0,0,0.5)]',
orientation === 'horizontal' ? 'hover:h-3 hover:-top-[5.5px]' : 'hover:w-3 hover:-left-[5.5px]',
// Active: smaller glow
'active:shadow-[0_0_4px_rgba(0,0,0,0.3)]',
orientation === 'horizontal' ? 'active:h-2.5 active:-top-[4.5px]' : 'active:w-2.5 active:-left-[4.5px]',
'focus:outline-none',
'cursor-grab active:cursor-grabbing',
)}
>
<!-- Soft glow on hover -->
<div
class="
absolute inset-0 rounded-sm
bg-white/20
opacity-0 group-hover/thumb:opacity-100
transition-opacity duration-200
"
>
</div>
<!-- Value label -->
<span
class={cn(
'absolute',
orientation === 'horizontal' ? '-top-8 left-1/2 -translate-x-1/2' : 'left-5 top-1/2 -translate-y-1/2',
'px-1.5 py-0.5 rounded-md',
'bg-gray-900/90 backdrop-blur-sm',
'font-mono text-[0.625rem] font-medium text-white ',
'opacity-0 group-hover/thumb:opacity-100',
'transition-all duration-300',
'pointer-events-none',
'shadow-sm',
)}
>
{value}
</span>
</Slider.Thumb>
</span>
{/snippet}
</Slider.Root>

View File

@@ -178,7 +178,7 @@ $effect(() => {
<div <div
use:virtualizer.measureElement use:virtualizer.measureElement
data-index={item.index} data-index={item.index}
class="absolute top-0 left-0 w-full" class="absolute top-0 left-0 w-full will-change-transform"
style:transform="translateY({item.start}px)" style:transform="translateY({item.start}px)"
> >
{#if item.index < items.length} {#if item.index < items.length}
@@ -210,7 +210,7 @@ $effect(() => {
<div <div
use:virtualizer.measureElement use:virtualizer.measureElement
data-index={item.index} data-index={item.index}
class="absolute top-0 left-0 w-full" class="absolute top-0 left-0 w-full will-change-transform"
style:transform="translateY({item.start}px)" style:transform="translateY({item.start}px)"
animate:flip={{ delay: 0, duration: 300, easing: quintOut }} animate:flip={{ delay: 0, duration: 300, easing: quintOut }}
> >

View File

@@ -1,12 +1,16 @@
export { default as CheckboxFilter } from './CheckboxFilter/CheckboxFilter.svelte'; export { default as CheckboxFilter } from './CheckboxFilter/CheckboxFilter.svelte';
export { default as ComboControl } from './ComboControl/ComboControl.svelte'; 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 ComboControlV2 } from './ComboControlV2/ComboControlV2.svelte';
export { default as ContentEditable } from './ContentEditable/ContentEditable.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 ExpandableWrapper } from './ExpandableWrapper/ExpandableWrapper.svelte';
export { default as Footnote } from './Footnote/Footnote.svelte';
export { default as IconButton } from './IconButton/IconButton.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 Loader } from './Loader/Loader.svelte';
export { default as Logo } from './Logo/Logo.svelte';
export { default as SearchBar } from './SearchBar/SearchBar.svelte'; export { default as SearchBar } from './SearchBar/SearchBar.svelte';
export { default as Section } from './Section/Section.svelte'; export { default as Section } from './Section/Section.svelte';
export { default as Skeleton } from './Skeleton/Skeleton.svelte'; export { default as Skeleton } from './Skeleton/Skeleton.svelte';
export { default as Slider } from './Slider/Slider.svelte';
export { default as VirtualList } from './VirtualList/VirtualList.svelte'; export { default as VirtualList } from './VirtualList/VirtualList.svelte';

View File

@@ -3,6 +3,10 @@ import {
fetchFontsByIds, fetchFontsByIds,
unifiedFontStore, unifiedFontStore,
} from '$entities/Font'; } from '$entities/Font';
import {
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
createTypographyControlManager,
} from '$features/SetupFont';
import { createPersistentStore } from '$shared/lib'; import { createPersistentStore } from '$shared/lib';
/** /**
@@ -30,6 +34,7 @@ class ComparisonStore {
#fontB = $state<UnifiedFont | undefined>(); #fontB = $state<UnifiedFont | undefined>();
#sampleText = $state('The quick brown fox jumps over the lazy dog'); #sampleText = $state('The quick brown fox jumps over the lazy dog');
#isRestoring = $state(true); #isRestoring = $state(true);
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
constructor() { constructor() {
this.restoreFromStorage(); this.restoreFromStorage();
@@ -102,6 +107,9 @@ class ComparisonStore {
} }
// --- Getters & Setters --- // --- Getters & Setters ---
get typography() {
return this.#typography;
}
get fontA() { get fontA() {
return this.#fontA; return this.#fontA;
@@ -149,6 +157,13 @@ class ComparisonStore {
this.restoreFromStorage(); this.restoreFromStorage();
} }
} }
resetAll() {
this.#fontA = undefined;
this.#fontB = undefined;
storage.clear();
this.#typography.reset();
}
} }
export const comparisonStore = new ComparisonStore(); export const comparisonStore = new ComparisonStore();

View File

@@ -14,14 +14,17 @@ import {
createCharacterComparison, createCharacterComparison,
createTypographyControl, createTypographyControl,
} from '$shared/lib'; } from '$shared/lib';
import type { LineData } from '$shared/lib'; import type {
LineData,
ResponsiveManager,
} from '$shared/lib';
import { Loader } from '$shared/ui'; import { Loader } from '$shared/ui';
import { comparisonStore } from '$widgets/ComparisonSlider/model'; import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { getContext } from 'svelte';
import { Spring } from 'svelte/motion'; import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import CharacterSlot from './components/CharacterSlot.svelte'; import CharacterSlot from './components/CharacterSlot.svelte';
import ControlsWrapper from './components/ControlsWrapper.svelte'; import Controls from './components/Controls.svelte';
import Labels from './components/Labels.svelte';
import SliderLine from './components/SliderLine.svelte'; import SliderLine from './components/SliderLine.svelte';
// Pair of fonts to compare // Pair of fonts to compare
@@ -30,31 +33,13 @@ const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady); const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
let container: HTMLElement | undefined = $state(); let container = $state<HTMLElement>();
let controlsWrapperElement = $state<HTMLDivElement | null>(null); let typographyControls = $state<HTMLDivElement | null>(null);
let measureCanvas: HTMLCanvasElement | undefined = $state(); let measureCanvas = $state<HTMLCanvasElement>();
let isDragging = $state(false); let isDragging = $state(false);
const typography = $derived(comparisonStore.typography);
const weightControl = createTypographyControl({ const responsive = getContext<ResponsiveManager>('responsive');
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. * Encapsulated helper for text splitting, measuring, and character proximity calculations.
@@ -64,8 +49,8 @@ const charComparison = createCharacterComparison(
() => comparisonStore.text, () => comparisonStore.text,
() => fontA, () => fontA,
() => fontB, () => fontB,
() => weightControl.value, () => typography.weight,
() => sizeControl.value, () => typography.renderedSize,
); );
let lineElements = $state<(HTMLElement | undefined)[]>([]); let lineElements = $state<(HTMLElement | undefined)[]>([]);
@@ -88,8 +73,8 @@ function handleMove(e: PointerEvent) {
function startDragging(e: PointerEvent) { function startDragging(e: PointerEvent) {
if ( if (
e.target === controlsWrapperElement e.target === typographyControls
|| controlsWrapperElement?.contains(e.target as Node) || typographyControls?.contains(e.target as Node)
) { ) {
e.stopPropagation(); e.stopPropagation();
return; return;
@@ -99,6 +84,30 @@ function startDragging(e: PointerEvent) {
handleMove(e); 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(() => { $effect(() => {
if (isDragging) { if (isDragging) {
window.addEventListener('pointermove', handleMove); window.addEventListener('pointermove', handleMove);
@@ -115,9 +124,9 @@ $effect(() => {
$effect(() => { $effect(() => {
// React on text and typography settings changes // React on text and typography settings changes
const _text = comparisonStore.text; const _text = comparisonStore.text;
const _weight = weightControl.value; const _weight = typography.weight;
const _size = sizeControl.value; const _size = typography.renderedSize;
const _height = heightControl.value; const _height = typography.height;
if (container && measureCanvas && fontA && fontB) { if (container && measureCanvas && fontA && fontB) {
// Using rAF to ensure DOM is ready/stabilized // Using rAF to ensure DOM is ready/stabilized
@@ -143,8 +152,8 @@ $effect(() => {
<div <div
bind:this={lineElements[index]} bind:this={lineElements[index]}
class="relative flex w-full justify-center items-center whitespace-nowrap" class="relative flex w-full justify-center items-center whitespace-nowrap"
style:height={`${heightControl.value}em`} style:height={`${typography.height}em`}
style:line-height={`${heightControl.value}em`} style:line-height={`${typography.height}em`}
> >
{#each line.text.split('') as char, charIndex} {#each line.text.split('') as char, charIndex}
{@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)} {@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)}
@@ -158,8 +167,8 @@ $effect(() => {
{char} {char}
{proximity} {proximity}
{isPast} {isPast}
weight={weightControl.value} weight={typography.weight}
size={sizeControl.value} size={typography.renderedSize}
fontAName={fontA.name} fontAName={fontA.name}
fontBName={fontB.name} fontBName={fontB.name}
/> />
@@ -180,14 +189,14 @@ $effect(() => {
aria-label="Font comparison slider" aria-label="Font comparison slider"
onpointerdown={startDragging} onpointerdown={startDragging}
class=" class="
group relative w-full py-16 px-24 sm:py-24 sm:px-24 overflow-hidden 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-[2.5rem] rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center select-none touch-none cursor-ew-resize min-h-72 sm:min-h-96 flex flex-col justify-center
backdrop-blur-lg bg-gradient-to-br from-gray-100/70 via-white/50 to-gray-100/60 backdrop-blur-lg bg-linear-to-br from-gray-100/70 via-white/50 to-gray-100/60
border border-gray-300/40 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)] 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-[2.5rem] before:p-[1px] before:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-px
before:bg-gradient-to-br before:from-black/5 before:via-black/2 before:to-transparent before:bg-linear-to-br before:from-black/5 before:via-black/2 before:to-transparent
before:-z-10 before:blur-sm before:-z-10 before:blur-sm
" "
> >
@@ -197,8 +206,8 @@ $effect(() => {
{:else} {:else}
<div <div
class=" class="
relative flex flex-col items-center gap-4 relative flex flex-col items-center gap-3 sm:gap-4
text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.15] text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold leading-[1.15]
z-10 pointer-events-none text-center z-10 pointer-events-none text-center
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)] drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
" "
@@ -209,7 +218,7 @@ $effect(() => {
{#each charComparison.lines as line, lineIndex} {#each charComparison.lines as line, lineIndex}
<div <div
class="relative w-full whitespace-nowrap" class="relative w-full whitespace-nowrap"
style:height={`${heightControl.value}em`} style:height={`${typography.height}em`}
style:display="flex" style:display="flex"
style:align-items="center" style:align-items="center"
style:justify-content="center" style:justify-content="center"
@@ -222,19 +231,6 @@ $effect(() => {
<SliderLine {sliderPos} {isDragging} /> <SliderLine {sliderPos} {isDragging} />
{/if} {/if}
</div> </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 --> <!-- Since there're slider controls inside we put them outside the main one -->
<ControlsWrapper <Controls {sliderPos} {isDragging} {typographyControls} {container} />
bind:wrapper={controlsWrapperElement}
{sliderPos}
{isDragging}
bind:text={comparisonStore.text}
containerWidth={container?.clientWidth}
{weightControl}
{sizeControl}
{heightControl}
/>
{/if}
</div> </div>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
Drawer,
IconButton,
} from '$shared/ui';
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
import { getContext } from 'svelte';
import { comparisonStore } from '../../../model';
import SelectComparedFonts from './SelectComparedFonts.svelte';
import 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, className })}
<div class={cn(className, 'flex flex-col gap-6')}>
<SelectComparedFonts {sliderPos} />
<TypographyControls
{sliderPos}
{isDragging}
isActive={isOpen}
bind:wrapper={typographyControls}
containerWidth={container?.clientWidth}
staticPosition
/>
</div>
{/snippet}
</Drawer>
{:else}
{#if !isLoading}
<div class="absolute top-3 sm:top-6 left-3 sm:left-6 z-50">
<TypographyControls
{sliderPos}
{isDragging}
bind:wrapper={typographyControls}
containerWidth={container?.clientWidth}
/>
</div>
{/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}

View File

@@ -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-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="The quick brown fox..."
/>
</div>
{/snippet}
{#snippet hiddenContent()}
<div class="flex justify-between items-center-safe">
<ComboControlV2 control={weightControl} />
<ComboControlV2 control={sizeControl} />
<ComboControlV2 control={heightControl} />
</div>
{/snippet}
</ExpandableWrapper>
</div>

View File

@@ -1,157 +0,0 @@
<!--
Component: Labels
Displays labels for font selection in the comparison slider.
-->
<script lang="ts" generics="T extends UnifiedFont">
import {
FontVirtualList,
type UnifiedFont,
unifiedFontStore,
} from '$entities/Font';
import FontApplicator from '$entities/Font/ui/FontApplicator/FontApplicator.svelte';
import {
Content as SelectContent,
Item as SelectItem,
Root as SelectRoot,
Trigger as SelectTrigger,
} from '$shared/shadcn/ui/select';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { fade } from 'svelte/transition';
interface Props<T> {
/**
* First font to compare
*/
fontA: T;
/**
* Second font to compare
*/
fontB: T;
/**
* Position of the slider
*/
sliderPos: number;
weight: number;
}
let { fontA, fontB, sliderPos, weight }: Props<T> = $props();
const fontList = $derived(unifiedFontStore.fonts);
function selectFontA(font: UnifiedFont) {
if (!font) return;
comparisonStore.fontA = font;
}
function selectFontB(font: UnifiedFont) {
if (!font) return;
comparisonStore.fontB = font;
}
</script>
{#snippet fontSelector(
name: string,
id: string,
url: string,
fonts: UnifiedFont[],
selectFont: (font: UnifiedFont) => void,
align: 'start' | 'end',
)}
<div
class="z-50 pointer-events-auto"
onpointerdown={(e => e.stopPropagation())}
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300, delay: 300 }}
>
<SelectRoot type="single" disabled={!fontList.length}>
<SelectTrigger
class={cn(
'w-44 sm:w-52 h-9 border border-gray-300/40 bg-white/60 backdrop-blur-sm',
'px-3 rounded-lg transition-all flex items-center justify-between gap-2',
'font-mono text-[11px] tracking-tight font-medium text-gray-900',
'hover:bg-white/80 hover:border-gray-400/60 hover:shadow-sm',
)}
>
<div class="text-left flex-1 min-w-0">
<FontApplicator {name} {id} {url}>
{name}
</FontApplicator>
</div>
</SelectTrigger>
<SelectContent
class={cn(
'bg-white/95 backdrop-blur-xl border border-gray-300/50 shadow-xl',
'w-52 max-h-[280px] overflow-hidden rounded-lg',
)}
side="top"
{align}
sideOffset={8}
size="small"
>
<div class="p-1.5">
<FontVirtualList items={fonts} {weight}>
{#snippet children({ item: font })}
{@const handleClick = () => selectFont(font)}
<SelectItem
value={font.id}
class="data-[highlighted]:bg-gray-100 font-mono text-[11px] px-3 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>
</SelectItem>
{/snippet}
</FontVirtualList>
</div>
</SelectContent>
</SelectRoot>
</div>
{/snippet}
<div class="absolute bottom-8 inset-x-6 sm:inset-x-12 flex justify-between items-end pointer-events-none z-20">
<div
class="flex flex-col gap-2 transition-all duration-500 items-start"
style:opacity={sliderPos < 20 ? 0 : 1}
style:transform="translateY({sliderPos < 20 ? '8px' : '0px'})"
>
<div class="flex items-center gap-2.5 px-1">
<div class="w-1.5 h-1.5 rounded-full bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.6)]"></div>
<div class="w-px h-2.5 bg-gray-300/60"></div>
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
ch_01
</span>
</div>
{@render fontSelector(
fontB.name,
fontB.id,
fontB.styles.regular!,
fontList,
selectFontB,
'start',
)}
</div>
<div
class="flex flex-col items-end text-right gap-2 transition-all duration-500"
style:opacity={sliderPos > 80 ? 0 : 1}
style:transform="translateY({sliderPos > 80 ? '8px' : '0px'})"
>
<div class="flex items-center gap-2.5 px-1">
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
ch_02
</span>
<div class="w-px 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',
)}
</div>
</div>

View File

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

View File

@@ -18,14 +18,14 @@ interface Props {
let { sliderPos, isDragging }: Props = $props(); let { sliderPos, isDragging }: Props = $props();
</script> </script>
<div <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}%" style:left="{sliderPos}%"
> >
<!-- We use part of lucide cursor svg icon as a handle --> <!-- We use part of lucide cursor svg icon as a handle -->
<svg <svg
class={cn( class={cn(
'transition-all relative duration-300 text-black/80 drop-shadow-sm', '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" viewBox="0 0 24 12"
fill="none" fill="none"
@@ -41,12 +41,12 @@ let { sliderPos, isDragging }: Props = $props();
<div <div
class={cn( class={cn(
'relative h-full rounded-sm transition-all duration-500', '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 // 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_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)]', 'shadow-[0_10px_30px_-10px_rgba(0,0,0,0.2),inset_0_1px_1px_rgba(255,255,255,0.4)]',
'rounded-full', 'rounded-full',
isDragging ? 'w-32' : 'w-16', isDragging ? 'w-16 sm:w-32' : 'w-12 sm:w-16',
)} )}
> >
</div> </div>
@@ -55,7 +55,7 @@ let { sliderPos, isDragging }: Props = $props();
<svg <svg
class={cn( class={cn(
'transition-all relative duration-500 text-black/80 drop-shadow-sm', '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" viewBox="0 0 24 12"
fill="none" fill="none"

View File

@@ -0,0 +1,186 @@
<!--
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 { type Orientation } from 'bits-ui';
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>
{#snippet InputComponent(className: string)}
<Input
class={className}
bind:value={comparisonStore.text}
disabled={isDragging}
onfocusin={handleInputFocus}
placeholder="The quick brown fox..."
/>
{/snippet}
{#snippet Controls(className: string, orientation: Orientation)}
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
<div class={className}>
<ComboControlV2 control={typography.weightControl} {orientation} reduced />
<ComboControlV2 control={typography.sizeControl} {orientation} reduced />
<ComboControlV2 control={typography.heightControl} {orientation} reduced />
</div>
{/if}
{/snippet}
<div
class="z-50 will-change-transform"
style:transform="
translateX({xSpring.current}px)
rotateZ({rotateSpring.current}deg)
"
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300, delay: 300 }}
>
{#if staticPosition}
<div class="flex flex-col gap-6">
{@render InputComponent?.('p-6')}
{@render Controls?.('flex flex-col justify-between items-center-safe gap-6', 'horizontal')}
</div>
{:else}
<ExpandableWrapper
bind:element={wrapper}
bind:expanded={isActive}
disabled={isDragging}
aria-label="Font controls"
rotation={side === 'right' ? 'counterclockwise' : 'clockwise'}
class={cn(
'transition-opacity flex items-top gap-1.5',
panelWidth === 0 ? 'opacity-0' : 'opacity-100',
)}
containerClassName={cn(!isActive && 'p-2 sm:p-0')}
>
{#snippet badge()}
<div
class={cn(
'animate-nudge relative transition-all',
side === 'left' ? 'order-2' : 'order-0',
isActive ? 'opacity-0' : 'opacity-100',
isDragging && 'opacity-80 grayscale-[0.2]',
)}
>
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
</div>
{/snippet}
{#snippet visibleContent()}
{@render InputComponent(cn(
'pl-1 sm:pl-3 pr-1 sm:pr-3',
'h-6 sm:h-8 md:h-10',
'rounded-lg',
isActive
? 'h-7 sm:h-8 text-[0.825rem]'
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm sm:text-base font-medium focus-visible:ring-0 text-slate-900/50',
))}
{/snippet}
{#snippet hiddenContent()}
{@render Controls?.('flex flex-row justify-between items-center-safe gap-2 sm:gap-0 h-64', 'vertical')}
{/snippet}
</ExpandableWrapper>
{/if}
</div>

View File

@@ -13,6 +13,7 @@ import {
import { springySlideFade } from '$shared/lib'; import { springySlideFade } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { import {
Footnote,
IconButton, IconButton,
SearchBar, SearchBar,
} from '$shared/ui'; } from '$shared/ui';
@@ -71,7 +72,6 @@ function toggleFilters() {
id="font-search" id="font-search"
class="w-full" class="w-full"
placeholder="search_typefaces..." placeholder="search_typefaces..."
label="query_input"
bind:value={filterManager.queryValue} bind:value={filterManager.queryValue}
/> />
@@ -101,25 +101,25 @@ function toggleFilters() {
> >
<div <div
class=" class="
p-4 rounded-xl p-3 sm:p-4 md:p-5 rounded-xl
backdrop-blur-md bg-white/80 backdrop-blur-md bg-white/80
border border-gray-300/50 border border-gray-300/50
shadow-[0_1px_3px_rgba(0,0,0,0.04)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]
" "
> >
<div class="flex items-center gap-2.5 mb-4 opacity-70"> <div class="flex items-center gap-2 sm:gap-2.5 mb-3 sm:mb-4">
<div class="w-1 h-1 rounded-full bg-gray-900"></div> <div class="w-1 h-1 rounded-full bg-gray-900 opacity-70"></div>
<div class="w-px h-2.5 bg-gray-400/50"></div> <div class="w-px h-2.5 bg-gray-300/60"></div>
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium"> <Footnote>
filter_params filter_params
</span> </Footnote>
</div> </div>
<div class="grid gap-3 grid-cols-[repeat(auto-fit,minmax(8em,14em))]"> <div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<Filters /> <Filters />
</div> </div>
<div class="mt-4 pt-4 border-t border-gray-300/40"> <div class="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-300/40">
<FilterControls class="m-auto w-fit" /> <FilterControls class="m-auto w-fit" />
</div> </div>
</div> </div>

View File

@@ -1,7 +1,8 @@
<!-- <!--
Component: SampleList Component: SampleList
Renders a list of fonts in a virtualized list to improve performance. Renders a list of fonts in a virtualized list to improve performance.
Includes pagination with auto-loading when scrolling near the bottom. - Includes pagination with auto-loading when scrolling near the bottom.
- Provides a typography menu for font setup.
--> -->
<script lang="ts"> <script lang="ts">
import { import {
@@ -10,9 +11,19 @@ import {
unifiedFontStore, unifiedFontStore,
} from '$entities/Font'; } from '$entities/Font';
import { FontSampler } from '$features/DisplayFont'; import { FontSampler } from '$features/DisplayFont';
import { controlManager } from '$features/SetupFont'; import {
TypographyMenu,
controlManager,
} from '$features/SetupFont';
let text = $state('The quick brown fox jumps over the lazy dog...'); let text = $state('The quick brown fox jumps over the lazy dog...');
let wrapper = $state<HTMLDivElement | null>(null);
// Binds to the actual window height
let innerHeight = $state(0);
// Is the component above the middle of the viewport?
let isAboveMiddle = $state(false);
const isLoading = $derived(unifiedFontStore.isFetching || unifiedFontStore.isLoading);
/** /**
* Load more fonts by moving to the next page * Load more fonts by moving to the next page
@@ -51,18 +62,32 @@ const displayRange = $derived.by(() => {
return `Showing ${loadedCount} of ${total} fonts`; return `Showing ${loadedCount} of ${total} fonts`;
}); });
const isLoading = $derived(unifiedFontStore.isFetching || unifiedFontStore.isLoading); function checkPosition() {
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const viewportMiddle = innerHeight / 2;
isAboveMiddle = rect.top < viewportMiddle;
}
</script> </script>
<FontVirtualList <svelte:window
bind:innerHeight
onscroll={checkPosition}
onresize={checkPosition}
/>
<div bind:this={wrapper}>
<FontVirtualList
items={unifiedFontStore.fonts} items={unifiedFontStore.fonts}
total={unifiedFontStore.pagination.total} total={unifiedFontStore.pagination.total}
onNearBottom={handleNearBottom} onNearBottom={handleNearBottom}
itemHeight={280} itemHeight={220}
useWindowScroll={true} useWindowScroll={true}
weight={controlManager.weight} weight={controlManager.weight}
{isLoading} {isLoading}
> >
{#snippet children({ {#snippet children({
item: font, item: font,
isFullyVisible, isFullyVisible,
@@ -74,4 +99,9 @@ const isLoading = $derived(unifiedFontStore.isFetching || unifiedFontStore.isLoa
<FontSampler {font} bind:text {index} /> <FontSampler {font} bind:text {index} />
</FontListItem> </FontListItem>
{/snippet} {/snippet}
</FontVirtualList> </FontVirtualList>
{#if isAboveMiddle}
<TypographyMenu class="fixed bottom-4 sm:bottom-5 right-4 sm:left-1/2 sm:right-[unset] sm:-translate-x-1/2" />
{/if}
</div>

View File

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

View File

@@ -1,41 +0,0 @@
<!--
Component: TypographyMenu
Provides a menu for selecting and configuring typography settings
-->
<script lang="ts">
import { SetupFontMenu } from '$features/SetupFont';
import {
Content as ItemContent,
Root as ItemRoot,
} from '$shared/shadcn/ui/item';
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-auto fixed bottom-5 inset-x-0 max-screen z-10 flex justify-center"
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>

View File

@@ -1,3 +1,2 @@
export { ComparisonSlider } from './ComparisonSlider'; export { ComparisonSlider } from './ComparisonSlider';
export { FontSearch } from './FontSearch'; export { FontSearch } from './FontSearch';
export { TypographyMenu } from './TypographySettings';

View File

@@ -2470,6 +2470,7 @@ __metadata:
tailwindcss: "npm:^4.1.18" tailwindcss: "npm:^4.1.18"
tw-animate-css: "npm:^1.4.0" tw-animate-css: "npm:^1.4.0"
typescript: "npm:^5.9.3" typescript: "npm:^5.9.3"
vaul-svelte: "npm:^1.0.0-next.7"
vite: "npm:^7.2.6" vite: "npm:^7.2.6"
vitest: "npm:^4.0.16" vitest: "npm:^4.0.16"
vitest-browser-svelte: "npm:^2.0.1" vitest-browser-svelte: "npm:^2.0.1"
@@ -3625,6 +3626,17 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "runed@npm:^0.35.1":
version: 0.35.1 version: 0.35.1
resolution: "runed@npm:0.35.1" resolution: "runed@npm:0.35.1"
@@ -3908,6 +3920,19 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "svelte2tsx@npm:^0.7.44, svelte2tsx@npm:~0.7.46":
version: 0.7.46 version: 0.7.46
resolution: "svelte2tsx@npm:0.7.46" resolution: "svelte2tsx@npm:0.7.46"
@@ -4232,6 +4257,18 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "vite@npm:^6.0.0 || ^7.0.0, vite@npm:^7.2.6":
version: 7.3.0 version: 7.3.0
resolution: "vite@npm:7.3.0" resolution: "vite@npm:7.3.0"