-
-
-
-
-
-
- {font.provider}
-
-
- {font.category}
-
-
-
- {font.name}
-
-
-
-
-
-
-
+
+ {@render children?.(font)}
diff --git a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
index 65102db..b26a909 100644
--- a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
+++ b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
@@ -3,24 +3,40 @@
- Renders a virtualized list of fonts
- Handles font registration with the manager
-->
-
@@ -28,6 +44,7 @@ function handleInternalVisibleChange(visibleItems: T[]) {
{items}
{...rest}
onVisibleItemsChange={handleInternalVisibleChange}
+ onNearBottom={handleNearBottom}
>
{#snippet children(scope)}
{@render children(scope)}
diff --git a/src/features/DisplayFont/index.ts b/src/features/DisplayFont/index.ts
index 4fb9052..a15fd38 100644
--- a/src/features/DisplayFont/index.ts
+++ b/src/features/DisplayFont/index.ts
@@ -1 +1 @@
-export { FontDisplay } from './ui';
+export { FontSampler } from './ui';
diff --git a/src/features/DisplayFont/model/index.ts b/src/features/DisplayFont/model/index.ts
deleted file mode 100644
index 5099f73..0000000
--- a/src/features/DisplayFont/model/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { displayedFontsStore } from './store';
diff --git a/src/features/DisplayFont/model/store/displayedFontsStore.svelte.ts b/src/features/DisplayFont/model/store/displayedFontsStore.svelte.ts
deleted file mode 100644
index 723cec6..0000000
--- a/src/features/DisplayFont/model/store/displayedFontsStore.svelte.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { selectedFontsStore } from '$entities/Font';
-
-/**
- * Store for displayed font samples
- * - Handles shown text
- * - Stores selected fonts for display
- */
-export class DisplayedFontsStore {
- #sampleText = $state('The quick brown fox jumps over the lazy dog');
-
- #displayedFonts = $derived.by(() => {
- return selectedFontsStore.all;
- });
-
- get fonts() {
- return this.#displayedFonts;
- }
-
- get text() {
- return this.#sampleText;
- }
-
- set text(text: string) {
- this.#sampleText = text;
- }
-}
-
-export const displayedFontsStore = new DisplayedFontsStore();
diff --git a/src/features/DisplayFont/model/store/index.ts b/src/features/DisplayFont/model/store/index.ts
deleted file mode 100644
index 43bb021..0000000
--- a/src/features/DisplayFont/model/store/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { displayedFontsStore } from './displayedFontsStore.svelte';
diff --git a/src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte b/src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte
deleted file mode 100644
index 915078f..0000000
--- a/src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- {#each displayedFontsStore.fonts as font (font.id)}
-
- {/each}
-
diff --git a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte
index 872e11c..453c23f 100644
--- a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte
+++ b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte
@@ -6,8 +6,14 @@
import {
FontApplicator,
type UnifiedFont,
+ selectedFontsStore,
} from '$entities/Font';
-import { ContentEditable } from '$shared/ui';
+import { controlManager } from '$features/SetupFont';
+import {
+ ContentEditable,
+ IconButton,
+} from '$shared/ui';
+import XIcon from '@lucide/svelte/icons/x';
interface Props {
/**
@@ -18,6 +24,10 @@ interface Props {
* Text to display
*/
text: string;
+ /**
+ * Index of the font sampler
+ */
+ index?: number;
/**
* Font settings
*/
@@ -29,18 +39,80 @@ interface Props {
let {
font,
text = $bindable(),
+ index = 0,
...restProps
}: Props = $props();
+
+const fontWeight = $derived(controlManager.weight);
+const fontSize = $derived(controlManager.size);
+const lineHeight = $derived(controlManager.height);
+const letterSpacing = $derived(controlManager.spacing);
+
+function removeSample() {
+ selectedFontsStore.removeOne(font.id);
+}
-
-
-
+
+
+
+ typeface_{String(index).padStart(3, '0')}
+
+
+
+ {font.name}
+
+
+
+
+ {#snippet icon({ className })}
+
+ {/snippet}
+
+
+
+
+
+
+
+
+
+
+
+
+ SZ:{fontSize}PX
+
+
+
+ WGT:{fontWeight}
+
+
+
+ LH:{lineHeight?.toFixed(2)}
+
+
+
+ LTR:{letterSpacing}
+
+
diff --git a/src/features/DisplayFont/ui/index.ts b/src/features/DisplayFont/ui/index.ts
index cf3cfc7..b055bdf 100644
--- a/src/features/DisplayFont/ui/index.ts
+++ b/src/features/DisplayFont/ui/index.ts
@@ -1,3 +1,3 @@
-import FontDisplay from './FontDisplay/FontDisplay.svelte';
+import FontSampler from './FontSampler/FontSampler.svelte';
-export { FontDisplay };
+export { FontSampler };
diff --git a/src/features/GetFonts/index.ts b/src/features/GetFonts/index.ts
index 9f557d6..9f63121 100644
--- a/src/features/GetFonts/index.ts
+++ b/src/features/GetFonts/index.ts
@@ -15,5 +15,4 @@ export { filterManager } from './model/state/manager.svelte';
export {
FilterControls,
Filters,
- FontSearch,
} from './ui';
diff --git a/src/features/GetFonts/lib/mapper/mapManagerToParams.ts b/src/features/GetFonts/lib/mapper/mapManagerToParams.ts
index 637a052..8375a10 100644
--- a/src/features/GetFonts/lib/mapper/mapManagerToParams.ts
+++ b/src/features/GetFonts/lib/mapper/mapManagerToParams.ts
@@ -1,18 +1,54 @@
-import type { FontshareParams } from '$entities/Font';
+import type { ProxyFontsParams } from '$entities/Font/api';
import type { FilterManager } from '../filterManager/filterManager.svelte';
/**
- * Maps filter manager to fontshare params.
+ * Maps filter manager to proxy API parameters.
*
- * @param manager - Filter manager instance.
- * @returns - Partial fontshare params.
+ * Transforms UI filter state into proxy API query parameters.
+ * Handles conversion from filter groups to API-specific parameters.
+ *
+ * @param manager - Filter manager instance with reactive state
+ * @returns - Partial proxy API parameters ready for API call
+ *
+ * @example
+ * ```ts
+ * // Example filter manager state:
+ * // {
+ * // queryValue: 'roboto',
+ * // providers: ['google'],
+ * // categories: ['sans-serif'],
+ * // subsets: ['latin']
+ * // }
+ *
+ * const params = mapManagerToParams(manager);
+ * // Returns: { provider: 'google', category: 'sans-serif', subset: 'latin', q: 'roboto' }
+ * ```
*/
-export function mapManagerToParams(manager: FilterManager): Partial
{
+export function mapManagerToParams(manager: FilterManager): Partial {
+ const providers = manager.getGroup('providers')?.instance.selectedProperties.map(p => p.value);
+ const categories = manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value);
+ const subsets = manager.getGroup('subsets')?.instance.selectedProperties.map(p => p.value);
+
return {
- q: manager.debouncedQueryValue,
- // Map groups to specific API keys
- categories: manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value)
- ?? [],
- tags: manager.getGroup('tags')?.instance.selectedProperties.map(p => p.value) ?? [],
+ // Search query (debounced)
+ q: manager.debouncedQueryValue || undefined,
+
+ // Provider filter (single value - proxy API doesn't support array)
+ // Use first provider if multiple selected, or undefined if none/all selected
+ provider: providers && providers.length === 1
+ ? (providers[0] as 'google' | 'fontshare')
+ : undefined,
+
+ // Category filter (single value - proxy API doesn't support array)
+ // Use first category if multiple selected, or undefined if none/all selected
+ category: categories && categories.length === 1
+ ? (categories[0] as ProxyFontsParams['category'])
+ : undefined,
+
+ // Subset filter (single value - proxy API doesn't support array)
+ // Use first subset if multiple selected, or undefined if none/all selected
+ subset: subsets && subsets.length === 1
+ ? (subsets[0] as ProxyFontsParams['subset'])
+ : undefined,
};
}
diff --git a/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte b/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte
index 2b74696..d50e84c 100644
--- a/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte
+++ b/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte
@@ -5,15 +5,42 @@
-->
-
+
+
Reset
diff --git a/src/features/GetFonts/ui/FontSearch/FontSearch.svelte b/src/features/GetFonts/ui/FontSearch/FontSearch.svelte
deleted file mode 100644
index c792eed..0000000
--- a/src/features/GetFonts/ui/FontSearch/FontSearch.svelte
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
-
diff --git a/src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte b/src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte
deleted file mode 100644
index 3c24e8c..0000000
--- a/src/features/GetFonts/ui/SuggestedFonts/SuggestedFonts.svelte
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
- {#snippet children({ item: font })}
-
- {/snippet}
-
diff --git a/src/features/GetFonts/ui/index.ts b/src/features/GetFonts/ui/index.ts
index c5bc080..b2d2dd9 100644
--- a/src/features/GetFonts/ui/index.ts
+++ b/src/features/GetFonts/ui/index.ts
@@ -1,9 +1,7 @@
import Filters from './Filters/Filters.svelte';
import FilterControls from './FiltersControl/FilterControls.svelte';
-import FontSearch from './FontSearch/FontSearch.svelte';
export {
FilterControls,
Filters,
- FontSearch,
};
diff --git a/src/features/SetupFont/index.ts b/src/features/SetupFont/index.ts
index bc8a71a..7d53b11 100644
--- a/src/features/SetupFont/index.ts
+++ b/src/features/SetupFont/index.ts
@@ -4,6 +4,7 @@ export {
controlManager,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
+ DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
diff --git a/src/features/SetupFont/lib/controlManager/controlManager.svelte.ts b/src/features/SetupFont/lib/controlManager/controlManager.svelte.ts
index 3307c4c..134e71e 100644
--- a/src/features/SetupFont/lib/controlManager/controlManager.svelte.ts
+++ b/src/features/SetupFont/lib/controlManager/controlManager.svelte.ts
@@ -1,7 +1,53 @@
import {
type ControlModel,
+ type TypographyControl,
createTypographyControl,
} from '$shared/lib';
+import { SvelteMap } from 'svelte/reactivity';
+
+export interface Control {
+ id: string;
+ increaseLabel?: string;
+ decreaseLabel?: string;
+ controlLabel?: string;
+ instance: TypographyControl;
+}
+
+export class TypographyControlManager {
+ #controls = new SvelteMap
();
+
+ constructor(configs: ControlModel[]) {
+ configs.forEach(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => {
+ this.#controls.set(id, {
+ id,
+ increaseLabel,
+ decreaseLabel,
+ controlLabel,
+ instance: createTypographyControl(config),
+ });
+ });
+ }
+
+ get controls() {
+ return this.#controls.values();
+ }
+
+ get weight() {
+ return this.#controls.get('font_weight')?.instance.value ?? 400;
+ }
+
+ get size() {
+ return this.#controls.get('font_size')?.instance.value;
+ }
+
+ get height() {
+ return this.#controls.get('line_height')?.instance.value;
+ }
+
+ get spacing() {
+ return this.#controls.get('letter_spacing')?.instance.value;
+ }
+}
/**
* Creates a typography control manager that handles a collection of typography controls.
@@ -10,19 +56,5 @@ import {
* @returns - Typography control manager instance.
*/
export function createTypographyControlManager(configs: ControlModel[]) {
- const controls = $state(
- configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({
- id,
- increaseLabel,
- decreaseLabel,
- controlLabel,
- instance: createTypographyControl(config),
- })),
- );
-
- return {
- get controls() {
- return controls;
- },
- };
+ return new TypographyControlManager(configs);
}
diff --git a/src/features/SetupFont/model/const/const.ts b/src/features/SetupFont/model/const/const.ts
index 21bf5b7..97d60a0 100644
--- a/src/features/SetupFont/model/const/const.ts
+++ b/src/features/SetupFont/model/const/const.ts
@@ -1,7 +1,7 @@
/**
* Font size constants
*/
-export const DEFAULT_FONT_SIZE = 16;
+export const DEFAULT_FONT_SIZE = 48;
export const MIN_FONT_SIZE = 8;
export const MAX_FONT_SIZE = 100;
export const FONT_SIZE_STEP = 1;
@@ -21,3 +21,11 @@ export const DEFAULT_LINE_HEIGHT = 1.5;
export const MIN_LINE_HEIGHT = 1;
export const MAX_LINE_HEIGHT = 2;
export const LINE_HEIGHT_STEP = 0.05;
+
+/**
+ * Letter spacing constants
+ */
+export const DEFAULT_LETTER_SPACING = 0;
+export const MIN_LETTER_SPACING = -0.1;
+export const MAX_LETTER_SPACING = 0.5;
+export const LETTER_SPACING_STEP = 0.01;
diff --git a/src/features/SetupFont/model/index.ts b/src/features/SetupFont/model/index.ts
index 8f7451c..22411a9 100644
--- a/src/features/SetupFont/model/index.ts
+++ b/src/features/SetupFont/model/index.ts
@@ -1,6 +1,7 @@
export {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
+ DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
diff --git a/src/features/SetupFont/model/state/manager.svelte.ts b/src/features/SetupFont/model/state/manager.svelte.ts
index f39823e..7b05a49 100644
--- a/src/features/SetupFont/model/state/manager.svelte.ts
+++ b/src/features/SetupFont/model/state/manager.svelte.ts
@@ -3,15 +3,19 @@ import { createTypographyControlManager } from '../../lib';
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
+ DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
+ LETTER_SPACING_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
+ MAX_LETTER_SPACING,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
+ MIN_LETTER_SPACING,
MIN_LINE_HEIGHT,
} from '../const/const';
@@ -49,6 +53,17 @@ const controlData: ControlModel[] = [
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);
diff --git a/src/features/SetupFont/ui/SetupFontMenu.svelte b/src/features/SetupFont/ui/SetupFontMenu.svelte
index 5941585..5384878 100644
--- a/src/features/SetupFont/ui/SetupFontMenu.svelte
+++ b/src/features/SetupFont/ui/SetupFontMenu.svelte
@@ -3,18 +3,19 @@
Contains controls for setting up font properties.
-->
-
-
-
-
+
+
{#each controlManager.controls as control (control.id)}
-
+
{/each}
diff --git a/src/routes/Page.svelte b/src/routes/Page.svelte
index 8f077bd..c064d08 100644
--- a/src/routes/Page.svelte
+++ b/src/routes/Page.svelte
@@ -1,12 +1,94 @@
+
+
+
-
-
+
+
+ {#snippet icon({ className })}
+
+ {/snippet}
+ {#snippet title({ className })}
+
+ Optical Comparator
+
+ {/snippet}
+
+
+
+
+ {#snippet icon({ className })}
+
+ {/snippet}
+ {#snippet title({ className })}
+
+ Query Module
+
+ {/snippet}
+
+
+
+
+ {#snippet icon({ className })}
+
+ {/snippet}
+ {#snippet title({ className })}
+
+ Sample Set
+
+ {/snippet}
+
+
+
+
diff --git a/src/shared/api/api.ts b/src/shared/api/api.ts
index 1b440d6..a786e4c 100644
--- a/src/shared/api/api.ts
+++ b/src/shared/api/api.ts
@@ -56,6 +56,5 @@ export const api = {
body: JSON.stringify(body),
}),
- delete:
(url: string, options?: RequestInit) =>
- request(url, { ...options, method: 'DELETE' }),
+ delete: (url: string, options?: RequestInit) => request(url, { ...options, method: 'DELETE' }),
};
diff --git a/src/shared/lib/accessibility/motion.svelte.ts b/src/shared/lib/accessibility/motion.svelte.ts
deleted file mode 100644
index 4dceb77..0000000
--- a/src/shared/lib/accessibility/motion.svelte.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-// Check if we are in a browser environment
-const isBrowser = typeof window !== 'undefined';
-
-// A class to manage motion preference and provide a single instance for use everywhere
-class MotionPreference {
- // Reactive state
- #reduced = $state(false);
-
- constructor() {
- if (isBrowser) {
- const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
-
- // Set initial value immediately
- this.#reduced = mediaQuery.matches;
-
- // Simple listener that updates the reactive state
- const handleChange = (e: MediaQueryListEvent) => {
- this.#reduced = e.matches;
- };
-
- mediaQuery.addEventListener('change', handleChange);
- }
- }
-
- // Getter allows us to use 'motion.reduced' reactively in components
- get reduced() {
- return this.#reduced;
- }
-}
-
-// Export a single instance to be used everywhere
-export const motion = new MotionPreference();
diff --git a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts
new file mode 100644
index 0000000..2867dda
--- /dev/null
+++ b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts
@@ -0,0 +1,257 @@
+/**
+ * Interface representing a line of text with its measured width.
+ */
+export interface LineData {
+ text: string;
+ width: number;
+}
+
+/**
+ * Creates a helper for splitting text into lines and calculating character proximity.
+ * This is used by the ComparisonSlider (TestTen) to render morphing text.
+ *
+ * @param text - The text to split and measure
+ * @param fontA - The first font definition
+ * @param fontB - The second font definition
+ * @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState)
+ */
+export function createCharacterComparison<
+ T extends { name: string; id: string } | undefined = undefined,
+>(
+ text: () => string,
+ fontA: () => T,
+ fontB: () => T,
+ weight: () => number,
+ size: () => number,
+) {
+ let lines = $state([]);
+ let containerWidth = $state(0);
+
+ function fontDefined(font: T | undefined): font is T {
+ return font !== undefined;
+ }
+
+ /**
+ * Measures text width using a canvas context.
+ * @param ctx - Canvas rendering context
+ * @param text - Text string to measure
+ * @param fontFamily - Font family name
+ * @param fontSize - Font size in pixels
+ * @param fontWeight - Font weight
+ */
+ function measureText(
+ ctx: CanvasRenderingContext2D,
+ text: string,
+ fontSize: number,
+ fontWeight: number,
+ fontFamily?: string,
+ ): number {
+ if (!fontFamily) return 0;
+ ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
+ return ctx.measureText(text).width;
+ }
+
+ /**
+ * Determines the appropriate font size based on window width.
+ * Matches the Tailwind breakpoints used in the component.
+ */
+ function getFontSize() {
+ if (typeof window === 'undefined') {
+ return 64;
+ }
+ return window.innerWidth >= 1024
+ ? 112
+ : window.innerWidth >= 768
+ ? 96
+ : window.innerWidth >= 640
+ ? 80
+ : 64;
+ }
+
+ /**
+ * Breaks the text into lines based on the container width and measure canvas.
+ * Populates the `lines` state.
+ *
+ * @param container - The container element to measure width from
+ * @param measureCanvas - The canvas element used for text measurement
+ */
+
+ function breakIntoLines(
+ container: HTMLElement | undefined,
+ measureCanvas: HTMLCanvasElement | undefined,
+ ) {
+ if (!container || !measureCanvas || !fontA() || !fontB()) return;
+
+ const rect = container.getBoundingClientRect();
+ containerWidth = rect.width;
+
+ // Padding considerations - matches the container padding
+ const padding = window.innerWidth < 640 ? 48 : 96;
+ const availableWidth = rect.width - padding;
+ const ctx = measureCanvas.getContext('2d');
+ if (!ctx) return;
+
+ const controlledFontSize = size();
+ const fontSize = getFontSize();
+ const currentWeight = weight(); // Get current weight
+ const words = text().split(' ');
+ const newLines: LineData[] = [];
+ let currentLineWords: string[] = [];
+
+ function pushLine(words: string[]) {
+ if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
+ return;
+ }
+ const lineText = words.join(' ');
+ // Measure both fonts at the CURRENT weight
+ const widthA = measureText(
+ ctx!,
+ lineText,
+ Math.min(fontSize, controlledFontSize),
+ currentWeight,
+ fontA()?.name,
+ );
+ const widthB = measureText(
+ ctx!,
+ lineText,
+ Math.min(fontSize, controlledFontSize),
+ currentWeight,
+ fontB()?.name,
+ );
+ const maxWidth = Math.max(widthA, widthB);
+ newLines.push({ text: lineText, width: maxWidth });
+ }
+
+ for (const word of words) {
+ const testLine = currentLineWords.length > 0
+ ? currentLineWords.join(' ') + ' ' + word
+ : word;
+ // Measure with both fonts and use the wider one to prevent layout shifts
+ const widthA = measureText(
+ ctx,
+ testLine,
+ Math.min(fontSize, controlledFontSize),
+ currentWeight,
+ fontA()?.name,
+ );
+ const widthB = measureText(
+ ctx,
+ testLine,
+ Math.min(fontSize, controlledFontSize),
+ currentWeight,
+ fontB()?.name,
+ );
+ const maxWidth = Math.max(widthA, widthB);
+ const isContainerOverflown = maxWidth > availableWidth;
+
+ if (isContainerOverflown) {
+ if (currentLineWords.length > 0) {
+ pushLine(currentLineWords);
+ currentLineWords = [];
+ }
+
+ let remainingWord = word;
+ while (remainingWord.length > 0) {
+ let low = 1;
+ let high = remainingWord.length;
+ let bestBreak = 1;
+
+ // Binary Search to find the maximum characters that fit
+ while (low <= high) {
+ const mid = Math.floor((low + high) / 2);
+ const testFragment = remainingWord.slice(0, mid);
+
+ const wA = measureText(
+ ctx,
+ testFragment,
+ fontSize,
+ currentWeight,
+ fontA()?.name,
+ );
+ const wB = measureText(
+ ctx,
+ testFragment,
+ fontSize,
+ currentWeight,
+ fontB()?.name,
+ );
+
+ if (Math.max(wA, wB) <= availableWidth) {
+ bestBreak = mid;
+ low = mid + 1;
+ } else {
+ high = mid - 1;
+ }
+ }
+
+ pushLine([remainingWord.slice(0, bestBreak)]);
+ remainingWord = remainingWord.slice(bestBreak);
+ }
+ } else if (maxWidth > availableWidth && currentLineWords.length > 0) {
+ pushLine(currentLineWords);
+ currentLineWords = [word];
+ } else {
+ currentLineWords.push(word);
+ }
+ }
+
+ if (currentLineWords.length > 0) {
+ pushLine(currentLineWords);
+ }
+ lines = newLines;
+ }
+
+ /**
+ * precise calculation of character state based on global slider position.
+ *
+ * @param charIndex - Index of the character in the line
+ * @param sliderPos - Current slider position (0-100)
+ * @param lineElement - The line element
+ * @param container - The container element
+ * @returns Object containing proximity (0-1) and isPast (boolean)
+ */
+ function getCharState(
+ charIndex: number,
+ sliderPos: number,
+ lineElement?: HTMLElement,
+ container?: HTMLElement,
+ ) {
+ if (!containerWidth || !container) {
+ return {
+ proximity: 0,
+ isPast: false,
+ };
+ }
+ const charElement = lineElement?.children[charIndex] as HTMLElement;
+
+ if (!charElement) {
+ return { proximity: 0, isPast: false };
+ }
+
+ // Get the actual bounding box of the character
+ const charRect = charElement.getBoundingClientRect();
+ const containerRect = container.getBoundingClientRect();
+
+ // Calculate character center relative to container
+ const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
+ const charGlobalPercent = (charCenter / containerWidth) * 100;
+
+ const distance = Math.abs(sliderPos - charGlobalPercent);
+ const range = 5;
+ const proximity = Math.max(0, 1 - distance / range);
+ const isPast = sliderPos > charGlobalPercent;
+
+ return { proximity, isPast };
+ }
+
+ return {
+ get lines() {
+ return lines;
+ },
+ get containerWidth() {
+ return containerWidth;
+ },
+ breakIntoLines,
+ getCharState,
+ };
+}
diff --git a/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts b/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts
index 14e2c30..46c6f00 100644
--- a/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts
+++ b/src/shared/lib/helpers/createEntityStore/createEntityStore.svelte.ts
@@ -46,9 +46,6 @@ export class EntityStore {
updateOne(id: string, changes: Partial) {
const entity = this.#entities.get(id);
if (entity) {
- // In Svelte 5, updating the object property directly is reactive
- // if the object itself was made reactive, but here we replace
- // the reference to ensure top-level map triggers.
this.#entities.set(id, { ...entity, ...changes });
}
}
diff --git a/src/shared/lib/helpers/createPersistentStore/createPersistentStore.svelte.ts b/src/shared/lib/helpers/createPersistentStore/createPersistentStore.svelte.ts
new file mode 100644
index 0000000..cfb37ad
--- /dev/null
+++ b/src/shared/lib/helpers/createPersistentStore/createPersistentStore.svelte.ts
@@ -0,0 +1,51 @@
+/**
+ * Reusable persistent storage utility using Svelte 5 runes
+ *
+ * Automatically syncs state with localStorage.
+ */
+export function createPersistentStore(key: string, defaultValue: T) {
+ // Initialize from storage or default
+ const loadFromStorage = (): T => {
+ if (typeof window === 'undefined') {
+ return defaultValue;
+ }
+ try {
+ const item = localStorage.getItem(key);
+ return item ? JSON.parse(item) : defaultValue;
+ } catch (error) {
+ console.warn(`[createPersistentStore] Error loading ${key}:`, error);
+ return defaultValue;
+ }
+ };
+
+ let value = $state(loadFromStorage());
+
+ // Sync to storage whenever value changes
+ $effect.root(() => {
+ $effect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch (error) {
+ console.warn(`[createPersistentStore] Error saving ${key}:`, error);
+ }
+ });
+ });
+
+ return {
+ get value() {
+ return value;
+ },
+ set value(v: T) {
+ value = v;
+ },
+ clear() {
+ if (typeof window !== 'undefined') {
+ localStorage.removeItem(key);
+ }
+ value = defaultValue;
+ },
+ };
+}
diff --git a/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts b/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts
index 1ba9476..bec0f1d 100644
--- a/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts
+++ b/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts
@@ -30,15 +30,15 @@ export interface ControlModel extends ControlDataModel {
/**
* Area label for increase button
*/
- increaseLabel: string;
+ increaseLabel?: string;
/**
* Area label for decrease button
*/
- decreaseLabel: string;
+ decreaseLabel?: string;
/**
* Control area label
*/
- controlLabel: string;
+ controlLabel?: string;
}
export function createTypographyControl(
diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts
index af9e7ba..aeb67a7 100644
--- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts
+++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts
@@ -4,16 +4,38 @@
* Used to render visible items with absolute positioning based on computed offsets.
*/
export interface VirtualItem {
- /** Index of the item in the data array */
+ /**
+ * Index of the item in the data array
+ */
index: number;
- /** Offset from the top of the list in pixels */
+ /**
+ * Offset from the top of the list in pixels
+ */
start: number;
- /** Height/size of the item in pixels */
+ /**
+ * Height/size of the item in pixels
+ */
size: number;
- /** End position in pixels (start + size) */
+ /**
+ * End position in pixels (start + size)
+ */
end: number;
- /** Unique key for the item (for Svelte's {#each} keying) */
+ /**
+ * Unique key for the item (for Svelte's {#each} keying)
+ */
key: string | number;
+ /**
+ * Whether the item is currently fully visible in the viewport
+ */
+ isFullyVisible: boolean;
+ /**
+ * Whether the item is currently partially visible in the viewport
+ */
+ isPartiallyVisible: boolean;
+ /**
+ * Proximity of the item to the center of the viewport
+ */
+ proximity: number;
}
/**
@@ -41,6 +63,11 @@ export interface VirtualizerOptions {
* Can be useful for handling sticky headers or other UI elements.
*/
scrollMargin?: number;
+ /**
+ * Whether to use the window as the scroll container.
+ * @default false
+ */
+ useWindowScroll?: boolean;
}
/**
@@ -88,6 +115,7 @@ export function createVirtualizer(
let containerHeight = $state(0);
let measuredSizes = $state>({});
let elementRef: HTMLElement | null = null;
+ let elementOffsetTop = 0;
// By wrapping the getter in $derived, we track everything inside it
const options = $derived(optionsGetter());
@@ -136,6 +164,8 @@ export function createVirtualizer(
let endIdx = startIdx;
const viewportEnd = scrollOffset + containerHeight;
+ const viewportCenter = scrollOffset + (containerHeight / 2);
+
while (endIdx < count && offsets[endIdx] < viewportEnd) {
endIdx++;
}
@@ -144,13 +174,31 @@ export function createVirtualizer(
const end = Math.min(count, endIdx + overscan);
const result: VirtualItem[] = [];
+
for (let i = start; i < end; i++) {
+ const itemStart = offsets[i];
+ const itemSize = measuredSizes[i] ?? options.estimateSize(i);
+ const itemEnd = itemStart + itemSize;
+
+ // Visibility check: Does the item overlap the viewport?
+ const isPartiallyVisible = itemStart < viewportEnd && itemEnd > scrollOffset;
+ const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
+
+ // Proximity calculation: 1.0 at center, 0.0 at edges
+ const itemCenter = itemStart + (itemSize / 2);
+ const distanceToCenter = Math.abs(viewportCenter - itemCenter);
+ const maxDistance = containerHeight / 2;
+ const proximity = Math.max(0, 1 - (distanceToCenter / maxDistance));
+
result.push({
index: i,
- start: offsets[i],
- size: measuredSizes[i] ?? options.estimateSize(i),
- end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)),
+ start: itemStart,
+ size: itemSize,
+ end: itemEnd,
key: options.getItemKey?.(i) ?? i,
+ isPartiallyVisible,
+ isFullyVisible,
+ proximity,
});
}
@@ -168,26 +216,74 @@ export function createVirtualizer(
*/
function container(node: HTMLElement) {
elementRef = node;
- containerHeight = node.offsetHeight;
+ const { useWindowScroll } = optionsGetter();
- const handleScroll = () => {
- scrollOffset = node.scrollTop;
- };
+ if (useWindowScroll) {
+ // Calculate initial offset ONCE
+ const getElementOffset = () => {
+ const rect = node.getBoundingClientRect();
+ return rect.top + window.scrollY;
+ };
- const resizeObserver = new ResizeObserver(([entry]) => {
- if (entry) containerHeight = entry.contentRect.height;
- });
+ let cachedOffsetTop = getElementOffset();
+ containerHeight = window.innerHeight;
- node.addEventListener('scroll', handleScroll, { passive: true });
- resizeObserver.observe(node);
+ const handleScroll = () => {
+ // Use cached offset for scroll calculations
+ scrollOffset = Math.max(0, window.scrollY - cachedOffsetTop);
+ };
- return {
- destroy() {
- node.removeEventListener('scroll', handleScroll);
- resizeObserver.disconnect();
- elementRef = null;
- },
- };
+ const handleResize = () => {
+ const oldHeight = containerHeight;
+ containerHeight = window.innerHeight;
+
+ // Recalculate offset on resize (layout may have shifted)
+ const newOffsetTop = getElementOffset();
+ if (Math.abs(newOffsetTop - cachedOffsetTop) > 0.5) {
+ cachedOffsetTop = newOffsetTop;
+ handleScroll(); // Recalculate scroll position
+ }
+ };
+
+ window.addEventListener('scroll', handleScroll, { passive: true });
+ window.addEventListener('resize', handleResize);
+
+ // Initial calculation
+ handleScroll();
+
+ return {
+ destroy() {
+ window.removeEventListener('scroll', handleScroll);
+ window.removeEventListener('resize', handleResize);
+ if (frameId !== null) {
+ cancelAnimationFrame(frameId);
+ frameId = null;
+ }
+ elementRef = null;
+ },
+ };
+ } else {
+ containerHeight = node.offsetHeight;
+
+ const handleScroll = () => {
+ scrollOffset = node.scrollTop;
+ };
+
+ const resizeObserver = new ResizeObserver(([entry]) => {
+ if (entry) containerHeight = entry.contentRect.height;
+ });
+
+ node.addEventListener('scroll', handleScroll, { passive: true });
+ resizeObserver.observe(node);
+
+ return {
+ destroy() {
+ node.removeEventListener('scroll', handleScroll);
+ resizeObserver.disconnect();
+ elementRef = null;
+ },
+ };
+ }
}
let measurementBuffer: Record = {};
@@ -207,21 +303,23 @@ export function createVirtualizer(
const index = parseInt(node.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
- if (!isNaN(index) && measuredSizes[index] !== height) {
- // 1. Stuff the measurement into a temporary buffer
- measurementBuffer[index] = height;
+ if (!isNaN(index)) {
+ const oldHeight = measuredSizes[index];
+ // Only update if the height difference is significant (> 0.5px)
+ // This prevents "jitter" from focus rings or sub-pixel border changes
+ if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
+ // Stuff the measurement into a temporary buffer
+ measurementBuffer[index] = height;
- // 2. Schedule a single update for the next animation frame
- if (frameId === null) {
- frameId = requestAnimationFrame(() => {
- // 3. Update the state once for all collected measurements
- // We use spread to trigger a single fine-grained update
- measuredSizes = { ...measuredSizes, ...measurementBuffer };
-
- // 4. Reset the buffer
- measurementBuffer = {};
- frameId = null;
- });
+ // Schedule a single update for the next animation frame
+ if (frameId === null) {
+ frameId = requestAnimationFrame(() => {
+ measuredSizes = { ...measuredSizes, ...measurementBuffer };
+ // Reset the buffer
+ measurementBuffer = {};
+ frameId = null;
+ });
+ }
}
}
});
@@ -249,11 +347,22 @@ export function createVirtualizer(
const itemStart = offsets[index];
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
let target = itemStart;
+ const { useWindowScroll } = optionsGetter();
- if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
- if (align === 'end') target = itemStart - containerHeight + itemSize;
+ if (useWindowScroll) {
+ if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2;
+ if (align === 'end') target = itemStart - window.innerHeight + itemSize;
- elementRef.scrollTo({ top: target, behavior: 'smooth' });
+ // Add container offset to target to get absolute document position
+ const absoluteTarget = target + elementOffsetTop;
+
+ window.scrollTo({ top: absoluteTarget, behavior: 'smooth' });
+ } else {
+ if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
+ if (align === 'end') target = itemStart - containerHeight + itemSize;
+
+ elementRef.scrollTo({ top: target, behavior: 'smooth' });
+ }
}
return {
diff --git a/src/shared/lib/helpers/index.ts b/src/shared/lib/helpers/index.ts
index 62db226..f325d4f 100644
--- a/src/shared/lib/helpers/index.ts
+++ b/src/shared/lib/helpers/index.ts
@@ -26,3 +26,10 @@ export {
type Entity,
type EntityStore,
} from './createEntityStore/createEntityStore.svelte';
+
+export {
+ createCharacterComparison,
+ type LineData,
+} from './createCharacterComparison/createCharacterComparison.svelte';
+
+export { createPersistentStore } from './createPersistentStore/createPersistentStore.svelte';
diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts
index fea0978..7cde5c5 100644
--- a/src/shared/lib/index.ts
+++ b/src/shared/lib/index.ts
@@ -1,15 +1,18 @@
export {
type ControlDataModel,
type ControlModel,
+ createCharacterComparison,
createDebouncedState,
createEntityStore,
createFilter,
+ createPersistentStore,
createTypographyControl,
createVirtualizer,
type Entity,
type EntityStore,
type Filter,
type FilterModel,
+ type LineData,
type Property,
type TypographyControl,
type VirtualItem,
@@ -17,5 +20,6 @@ export {
type VirtualizerOptions,
} from './helpers';
-export { motion } from './accessibility/motion.svelte';
export { splitArray } from './utils';
+
+export { springySlideFade } from './transitions';
diff --git a/src/shared/lib/transitions/index.ts b/src/shared/lib/transitions/index.ts
new file mode 100644
index 0000000..2264a9d
--- /dev/null
+++ b/src/shared/lib/transitions/index.ts
@@ -0,0 +1 @@
+export { springySlideFade } from './springySlideFade/springySlideFade';
diff --git a/src/shared/lib/transitions/springySlideFade/springySlideFade.ts b/src/shared/lib/transitions/springySlideFade/springySlideFade.ts
new file mode 100644
index 0000000..a32d8a0
--- /dev/null
+++ b/src/shared/lib/transitions/springySlideFade/springySlideFade.ts
@@ -0,0 +1,60 @@
+import type {
+ SlideParams,
+ TransitionConfig,
+} from 'svelte/transition';
+
+function elasticOut(t: number) {
+ return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1;
+}
+
+function gentleSpring(t: number) {
+ return 1 - Math.pow(1 - t, 3) * Math.cos(t * Math.PI * 2);
+}
+
+/**
+ * Svelte slide transition function for custom slide+fade
+ * @param node - The element to apply the transition to
+ * @param params - Transition parameters
+ * @returns Transition configuration
+ */
+export function springySlideFade(
+ node: HTMLElement,
+ params: SlideParams = {},
+): TransitionConfig {
+ const { duration = 400 } = params;
+ const height = node.scrollHeight;
+
+ // Check if the browser is Firefox to work around specific rendering issues
+ const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');
+
+ return {
+ duration,
+ // We use 'tick' for the most precise control over the
+ // coordination with the elements below.
+ css: t => {
+ // Use elastic easing
+ const eased = gentleSpring(t);
+
+ return `
+ height: ${eased * height}px;
+ opacity: ${t};
+ transform: translateY(${(1 - t) * -10}px);
+ transform-origin: top;
+ overflow: hidden;
+ contain: size layout style;
+ will-change: max-height, opacity, transform;
+ backface-visibility: hidden;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ ${
+ isFirefox
+ ? `
+ perspective: 1000px;
+ isolation: isolate;
+ `
+ : ''
+ }
+ `;
+ },
+ };
+}
diff --git a/src/shared/shadcn/ui/badge/badge.svelte b/src/shared/shadcn/ui/badge/badge.svelte
index 523a922..2caaee6 100644
--- a/src/shared/shadcn/ui/badge/badge.svelte
+++ b/src/shared/shadcn/ui/badge/badge.svelte
@@ -9,10 +9,8 @@ export const badgeVariants = tv({
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
variants: {
variant: {
- default:
- 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
- secondary:
- 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
+ default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
+ secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
destructive:
'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
diff --git a/src/shared/shadcn/ui/scroll-area/index.ts b/src/shared/shadcn/ui/scroll-area/index.ts
new file mode 100644
index 0000000..2d8d691
--- /dev/null
+++ b/src/shared/shadcn/ui/scroll-area/index.ts
@@ -0,0 +1,10 @@
+import Scrollbar from './scroll-area-scrollbar.svelte';
+import Root from './scroll-area.svelte';
+
+export {
+ Root,
+ // ,
+ Root as ScrollArea,
+ Scrollbar,
+ Scrollbar as ScrollAreaScrollbar,
+};
diff --git a/src/shared/shadcn/ui/scroll-area/scroll-area-scrollbar.svelte b/src/shared/shadcn/ui/scroll-area/scroll-area-scrollbar.svelte
new file mode 100644
index 0000000..6dc1737
--- /dev/null
+++ b/src/shared/shadcn/ui/scroll-area/scroll-area-scrollbar.svelte
@@ -0,0 +1,34 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/src/shared/shadcn/ui/scroll-area/scroll-area.svelte b/src/shared/shadcn/ui/scroll-area/scroll-area.svelte
new file mode 100644
index 0000000..45f86c4
--- /dev/null
+++ b/src/shared/shadcn/ui/scroll-area/scroll-area.svelte
@@ -0,0 +1,46 @@
+
+
+
+
+ {@render children?.()}
+
+ {#if orientation === 'vertical' || orientation === 'both'}
+
+ {/if}
+ {#if orientation === 'horizontal' || orientation === 'both'}
+
+ {/if}
+
+
diff --git a/src/shared/shadcn/ui/select/index.ts b/src/shared/shadcn/ui/select/index.ts
new file mode 100644
index 0000000..8c303c3
--- /dev/null
+++ b/src/shared/shadcn/ui/select/index.ts
@@ -0,0 +1,37 @@
+import Content from './select-content.svelte';
+import GroupHeading from './select-group-heading.svelte';
+import Group from './select-group.svelte';
+import Item from './select-item.svelte';
+import Label from './select-label.svelte';
+import Portal from './select-portal.svelte';
+import ScrollDownButton from './select-scroll-down-button.svelte';
+import ScrollUpButton from './select-scroll-up-button.svelte';
+import Separator from './select-separator.svelte';
+import Trigger from './select-trigger.svelte';
+import Root from './select.svelte';
+
+export {
+ Content,
+ Content as SelectContent,
+ Group,
+ Group as SelectGroup,
+ GroupHeading,
+ GroupHeading as SelectGroupHeading,
+ Item,
+ Item as SelectItem,
+ Label,
+ Label as SelectLabel,
+ Portal,
+ Portal as SelectPortal,
+ Root,
+ //
+ Root as Select,
+ ScrollDownButton,
+ ScrollDownButton as SelectScrollDownButton,
+ ScrollUpButton,
+ ScrollUpButton as SelectScrollUpButton,
+ Separator,
+ Separator as SelectSeparator,
+ Trigger,
+ Trigger as SelectTrigger,
+};
diff --git a/src/shared/shadcn/ui/select/select-content.svelte b/src/shared/shadcn/ui/select/select-content.svelte
new file mode 100644
index 0000000..572f197
--- /dev/null
+++ b/src/shared/shadcn/ui/select/select-content.svelte
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+ {@render children?.()}
+
+
+
+
diff --git a/src/shared/shadcn/ui/select/select-group-heading.svelte b/src/shared/shadcn/ui/select/select-group-heading.svelte
new file mode 100644
index 0000000..4e8f720
--- /dev/null
+++ b/src/shared/shadcn/ui/select/select-group-heading.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/shared/shadcn/ui/select/select-group.svelte b/src/shared/shadcn/ui/select/select-group.svelte
new file mode 100644
index 0000000..8e0e694
--- /dev/null
+++ b/src/shared/shadcn/ui/select/select-group.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/shared/shadcn/ui/select/select-item.svelte b/src/shared/shadcn/ui/select/select-item.svelte
new file mode 100644
index 0000000..e375e45
--- /dev/null
+++ b/src/shared/shadcn/ui/select/select-item.svelte
@@ -0,0 +1,41 @@
+
+
+
+ {#snippet children({ selected, highlighted })}
+
+ {#if selected}
+
+ {/if}
+
+ {#if childrenProp}
+ {@render childrenProp({ selected, highlighted })}
+ {:else}
+ {label || value}
+ {/if}
+ {/snippet}
+
diff --git a/src/shared/shadcn/ui/select/select-label.svelte b/src/shared/shadcn/ui/select/select-label.svelte
new file mode 100644
index 0000000..301930d
--- /dev/null
+++ b/src/shared/shadcn/ui/select/select-label.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/shared/shadcn/ui/select/select-portal.svelte b/src/shared/shadcn/ui/select/select-portal.svelte
new file mode 100644
index 0000000..c4fc326
--- /dev/null
+++ b/src/shared/shadcn/ui/select/select-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/shared/shadcn/ui/select/select-scroll-down-button.svelte b/src/shared/shadcn/ui/select/select-scroll-down-button.svelte
new file mode 100644
index 0000000..bdb96f5
--- /dev/null
+++ b/src/shared/shadcn/ui/select/select-scroll-down-button.svelte
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/src/shared/shadcn/ui/select/select-scroll-up-button.svelte b/src/shared/shadcn/ui/select/select-scroll-up-button.svelte
new file mode 100644
index 0000000..b28fbc9
--- /dev/null
+++ b/src/shared/shadcn/ui/select/select-scroll-up-button.svelte
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/src/shared/shadcn/ui/select/select-separator.svelte b/src/shared/shadcn/ui/select/select-separator.svelte
new file mode 100644
index 0000000..a570547
--- /dev/null
+++ b/src/shared/shadcn/ui/select/select-separator.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/src/shared/shadcn/ui/select/select-trigger.svelte b/src/shared/shadcn/ui/select/select-trigger.svelte
new file mode 100644
index 0000000..b9b280f
--- /dev/null
+++ b/src/shared/shadcn/ui/select/select-trigger.svelte
@@ -0,0 +1,32 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/src/shared/shadcn/ui/select/select.svelte b/src/shared/shadcn/ui/select/select.svelte
new file mode 100644
index 0000000..8eca78b
--- /dev/null
+++ b/src/shared/shadcn/ui/select/select.svelte
@@ -0,0 +1,11 @@
+
+
+
diff --git a/src/shared/shadcn/ui/sidebar/sidebar-provider.svelte b/src/shared/shadcn/ui/sidebar/sidebar-provider.svelte
index 5d9e4e5..4bb4d4c 100644
--- a/src/shared/shadcn/ui/sidebar/sidebar-provider.svelte
+++ b/src/shared/shadcn/ui/sidebar/sidebar-provider.svelte
@@ -33,8 +33,7 @@ const sidebar = setSidebar({
onOpenChange(value);
// This sets the cookie to keep the sidebar state.
- document.cookie =
- `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
});
diff --git a/src/shared/ui/CheckboxFilter/CheckboxFilter.svelte b/src/shared/ui/CheckboxFilter/CheckboxFilter.svelte
index 996dc7b..5ab3e1a 100644
--- a/src/shared/ui/CheckboxFilter/CheckboxFilter.svelte
+++ b/src/shared/ui/CheckboxFilter/CheckboxFilter.svelte
@@ -8,8 +8,10 @@
- Local transition prevents animation when component first renders
-->
-
-
-
-
-
-
- {#snippet child({ props })}
-
- {control.value}
-
- {/snippet}
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ {#snippet icon({ className })}
+
+ {/snippet}
+
+
+
+ {#snippet child({ props })}
+
+ {control.value}
+
+ {/snippet}
+
+
+
+
+
+
+
+
+
+
+ {#snippet icon({ className })}
+
+ {/snippet}
+
+
+
+ {#if controlLabel}
+
+ {controlLabel}
+
+ {/if}
+
diff --git a/src/shared/ui/ComboControlV2/ComboControlV2.svelte b/src/shared/ui/ComboControlV2/ComboControlV2.svelte
new file mode 100644
index 0000000..7a8f54d
--- /dev/null
+++ b/src/shared/ui/ComboControlV2/ComboControlV2.svelte
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
diff --git a/src/shared/ui/ContentEditable/ContentEditable.svelte b/src/shared/ui/ContentEditable/ContentEditable.svelte
index 0c9bc0b..8b45acb 100644
--- a/src/shared/ui/ContentEditable/ContentEditable.svelte
+++ b/src/shared/ui/ContentEditable/ContentEditable.svelte
@@ -5,14 +5,20 @@
+
+
+{/* @ts-ignore */ null}
+
+ {#snippet children(args)}
+
+
+
+ {/snippet}
+
+
+{/* @ts-ignore */ null}
+
+ {#snippet children(args)}
+
+
+
+ {/snippet}
+
+
+{/* @ts-ignore */ null}
+
+ {#snippet children(args)}
+
+
+
+ {/snippet}
+
diff --git a/src/shared/ui/ExpandableWrapper/ExpandableWrapper.svelte b/src/shared/ui/ExpandableWrapper/ExpandableWrapper.svelte
new file mode 100644
index 0000000..c405e82
--- /dev/null
+++ b/src/shared/ui/ExpandableWrapper/ExpandableWrapper.svelte
@@ -0,0 +1,195 @@
+
+
+
+
+ {@render badge?.({ expanded, disabled })}
+
+
+ {@render visibleContent?.({ expanded, disabled })}
+
+ {#if expanded}
+
+ {@render hiddenContent?.({ expanded, disabled })}
+
+ {/if}
+
+
diff --git a/src/shared/ui/IconButton/IconButton.svelte b/src/shared/ui/IconButton/IconButton.svelte
new file mode 100644
index 0000000..6f7a6e2
--- /dev/null
+++ b/src/shared/ui/IconButton/IconButton.svelte
@@ -0,0 +1,50 @@
+
+
+
+
+ {@render icon({
+ className: cn(
+ 'size-4 transition-all duration-200 stroke-[1.5] stroke-gray-500 group-hover:stroke-gray-900 group-hover:scale-110 group-hover:stroke-3 group-active:scale-90 group-disabled:stroke-transparent',
+ rotation === 'clockwise' ? 'group-active:rotate-6' : 'group-active:-rotate-6',
+ ),
+})}
+
diff --git a/src/shared/ui/SearchBar/SearchBar.stories.svelte b/src/shared/ui/SearchBar/SearchBar.stories.svelte
index f86dad9..6316bde 100644
--- a/src/shared/ui/SearchBar/SearchBar.stories.svelte
+++ b/src/shared/ui/SearchBar/SearchBar.stories.svelte
@@ -45,11 +45,7 @@ let noChildrenValue = $state('');
placeholder: 'Type here...',
}}
>
-
- Here will be the search result
-
- Popover closes only when the user clicks outside the search bar or presses the Escape key.
-
+
-
-
-
+
-
-
- Start typing to see results
-
-
+
diff --git a/src/shared/ui/SearchBar/SearchBar.svelte b/src/shared/ui/SearchBar/SearchBar.svelte
index d1e339f..7615891 100644
--- a/src/shared/ui/SearchBar/SearchBar.svelte
+++ b/src/shared/ui/SearchBar/SearchBar.svelte
@@ -1,90 +1,75 @@
-
+
-
-
- {#snippet child({ props })}
- {@const { onclick, ...rest } = props}
-
- {#if label}
- {label}
- {/if}
-
-
- {/snippet}
-
-
- e.preventDefault()}
- onInteractOutside={(e => {
- if (e.target === triggerRef) {
- e.preventDefault();
- }
- })}
- class="w-(--bits-popover-anchor-width) min-w-(--bits-popover-anchor-width)"
- >
- {@render children?.({ id: contentId })}
-
-
+
diff --git a/src/shared/ui/Section/Section.svelte b/src/shared/ui/Section/Section.svelte
new file mode 100644
index 0000000..e5b0c5f
--- /dev/null
+++ b/src/shared/ui/Section/Section.svelte
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+ {#if icon}
+ {@render icon({ className: 'size-4 stroke-gray-900 stroke-1' })}
+
+ {/if}
+ {#if typeof index === 'number'}
+
+ Component_{String(index).padStart(3, '0')}
+
+ {/if}
+
+
+ {#if title}
+ {@render title({ className: 'text-5xl md:text-6xl font-semibold tracking-tighter text-gray-900 leading-[0.9]' })}
+ {/if}
+
+
+ {@render children?.()}
+
diff --git a/src/shared/ui/VirtualList/VirtualList.svelte b/src/shared/ui/VirtualList/VirtualList.svelte
index cbc0ea1..d4e2f05 100644
--- a/src/shared/ui/VirtualList/VirtualList.svelte
+++ b/src/shared/ui/VirtualList/VirtualList.svelte
@@ -6,11 +6,15 @@
- Keyboard navigation (ArrowUp/Down, Home, End)
- Fixed or dynamic item heights
- ARIA listbox/option pattern with single tab stop
+ - Custom shadcn ScrollArea scrollbar
-->
-
-
-
-
- {#each virtualizer.items as item (item.key)}
-
- {@render children({ item: items[item.index], index: item.index })}
+{#if useWindowScroll}
+
+
+ {#each virtualizer.items as item (item.key)}
+
+ {#if item.index < items.length}
+ {@render children({
+ // TODO: Fix indenation rule for this case
+ item: items[item.index],
+ index: item.index,
+ isFullyVisible: item.isFullyVisible,
+ isPartiallyVisible: item.isPartiallyVisible,
+ proximity: item.proximity,
+})}
+ {/if}
+
+ {/each}
- {/each}
-
+
+{:else}
+
+
+ {#each virtualizer.items as item (item.key)}
+
+ {#if item.index < items.length}
+ {@render children({
+ // TODO: Fix indenation rule for this case
+ item: items[item.index],
+ index: item.index,
+ isFullyVisible: item.isFullyVisible,
+ isPartiallyVisible: item.isPartiallyVisible,
+ proximity: item.proximity,
+})}
+ {/if}
+
+ {/each}
+
+
+{/if}
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts
index 307dcc6..a8429cb 100644
--- a/src/shared/ui/index.ts
+++ b/src/shared/ui/index.ts
@@ -6,14 +6,22 @@
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
import ComboControl from './ComboControl/ComboControl.svelte';
+import ComboControlV2 from './ComboControlV2/ComboControlV2.svelte';
import ContentEditable from './ContentEditable/ContentEditable.svelte';
+import ExpandableWrapper from './ExpandableWrapper/ExpandableWrapper.svelte';
+import IconButton from './IconButton/IconButton.svelte';
import SearchBar from './SearchBar/SearchBar.svelte';
+import Section from './Section/Section.svelte';
import VirtualList from './VirtualList/VirtualList.svelte';
export {
CheckboxFilter,
ComboControl,
+ ComboControlV2,
ContentEditable,
+ ExpandableWrapper,
+ IconButton,
SearchBar,
+ Section,
VirtualList,
};
diff --git a/src/widgets/ComparisonSlider/index.ts b/src/widgets/ComparisonSlider/index.ts
new file mode 100644
index 0000000..b34444e
--- /dev/null
+++ b/src/widgets/ComparisonSlider/index.ts
@@ -0,0 +1,2 @@
+export * from './model';
+export { ComparisonSlider } from './ui';
diff --git a/src/widgets/ComparisonSlider/model/index.ts b/src/widgets/ComparisonSlider/model/index.ts
new file mode 100644
index 0000000..993fd73
--- /dev/null
+++ b/src/widgets/ComparisonSlider/model/index.ts
@@ -0,0 +1 @@
+export { comparisonStore } from './stores/comparisonStore.svelte';
diff --git a/src/widgets/ComparisonSlider/model/stores/comparisonStore.svelte.ts b/src/widgets/ComparisonSlider/model/stores/comparisonStore.svelte.ts
new file mode 100644
index 0000000..503900f
--- /dev/null
+++ b/src/widgets/ComparisonSlider/model/stores/comparisonStore.svelte.ts
@@ -0,0 +1,150 @@
+import {
+ type UnifiedFont,
+ fetchFontsByIds,
+ unifiedFontStore,
+} from '$entities/Font';
+import { createPersistentStore } from '$shared/lib';
+
+/**
+ * Storage schema for comparison state
+ */
+interface ComparisonState {
+ fontAId: string | null;
+ fontBId: string | null;
+}
+
+// Persistent storage for selected comparison fonts
+const storage = createPersistentStore
('glyphdiff:comparison', {
+ fontAId: null,
+ fontBId: null,
+});
+
+/**
+ * Store for managing font comparison state
+ * - Persists selection to localStorage
+ * - Handles font fetching on initialization
+ * - Manages sample text
+ */
+class ComparisonStore {
+ #fontA = $state();
+ #fontB = $state();
+ #sampleText = $state('The quick brown fox jumps over the lazy dog');
+ #isRestoring = $state(true);
+
+ constructor() {
+ this.restoreFromStorage();
+
+ // Reactively set defaults if we aren't restoring and have no selection
+ $effect.root(() => {
+ $effect(() => {
+ // Wait until we are done checking storage
+ if (this.#isRestoring) {
+ return;
+ }
+
+ // If we already have a selection, do nothing
+ if (this.#fontA && this.#fontB) {
+ return;
+ }
+
+ // Check if fonts are available to set as defaults
+ const fonts = unifiedFontStore.fonts;
+ if (fonts.length >= 2) {
+ // Only set if we really have nothing (fallback)
+ if (!this.#fontA) this.#fontA = fonts[0];
+ if (!this.#fontB) this.#fontB = fonts[fonts.length - 1];
+
+ // Sync defaults to storage so they persist if the user leaves
+ this.updateStorage();
+ }
+ });
+ });
+ }
+
+ /**
+ * Restore state from persistent storage
+ */
+ async restoreFromStorage() {
+ this.#isRestoring = true;
+ const { fontAId, fontBId } = storage.value;
+
+ if (fontAId && fontBId) {
+ try {
+ // Batch fetch the saved fonts
+ const fonts = await fetchFontsByIds([fontAId, fontBId]);
+ const loadedFontA = fonts.find((f: UnifiedFont) => f.id === fontAId);
+ const loadedFontB = fonts.find((f: UnifiedFont) => f.id === fontBId);
+
+ if (loadedFontA && loadedFontB) {
+ this.#fontA = loadedFontA;
+ this.#fontB = loadedFontB;
+ }
+ } catch (error) {
+ console.warn('[ComparisonStore] Failed to restore fonts:', error);
+ }
+ }
+
+ // Mark restoration as complete (whether success or fail)
+ this.#isRestoring = false;
+ }
+
+ /**
+ * Update storage with current state
+ */
+ private updateStorage() {
+ // Don't save if we are currently restoring (avoid race)
+ if (this.#isRestoring) return;
+
+ storage.value = {
+ fontAId: this.#fontA?.id ?? null,
+ fontBId: this.#fontB?.id ?? null,
+ };
+ }
+
+ // --- Getters & Setters ---
+
+ get fontA() {
+ return this.#fontA;
+ }
+
+ set fontA(font: UnifiedFont | undefined) {
+ this.#fontA = font;
+ this.updateStorage();
+ }
+
+ get fontB() {
+ return this.#fontB;
+ }
+
+ set fontB(font: UnifiedFont | undefined) {
+ this.#fontB = font;
+ this.updateStorage();
+ }
+
+ get text() {
+ return this.#sampleText;
+ }
+
+ set text(value: string) {
+ this.#sampleText = value;
+ }
+
+ /**
+ * Check if both fonts are selected
+ */
+ get isReady() {
+ return !!this.#fontA && !!this.#fontB;
+ }
+
+ /**
+ * Public initializer (optional, as constructor starts it)
+ * Kept for compatibility if manual re-init is needed
+ */
+ initialize() {
+ if (!this.#isRestoring && !this.#fontA && !this.#fontB) {
+ this.restoreFromStorage();
+ }
+ }
+}
+
+export const comparisonStore = new ComparisonStore();
diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte
new file mode 100644
index 0000000..2120233
--- /dev/null
+++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte
@@ -0,0 +1,230 @@
+
+
+
+{#snippet renderLine(line: LineData, index: number)}
+
+ {#each line.text.split('') as char, charIndex}
+ {@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)}
+
+ {#if fontA && fontB}
+
+ {/if}
+ {/each}
+
+{/snippet}
+
+{#if fontA && fontB}
+
+
+
+
+
+
+
+ {#each charComparison.lines as line, lineIndex}
+
+ {@render renderLine(line, lineIndex)}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+{/if}
diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/FontShifter.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/FontShifter.svelte
new file mode 100644
index 0000000..03135fd
--- /dev/null
+++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/FontShifter.svelte
@@ -0,0 +1,52 @@
+
+
+
+ {#each chars as char, i}
+
+ {char}
+
+ {/each}
+
+
+
diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/CharacterSlot.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/CharacterSlot.svelte
new file mode 100644
index 0000000..a1e9b67
--- /dev/null
+++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/CharacterSlot.svelte
@@ -0,0 +1,77 @@
+
+
+
+ 0.5 ? '0 0 15px rgba(99,102,241,0.3)' : 'none'}
+ style:will-change={proximity > 0 ? 'transform, font-family, color' : 'auto'}
+>
+ {char === ' ' ? '\u00A0' : char}
+
+
+
diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/ControlsWrapper.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/ControlsWrapper.svelte
new file mode 100644
index 0000000..c23106b
--- /dev/null
+++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/ControlsWrapper.svelte
@@ -0,0 +1,174 @@
+
+
+
+
diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/Labels.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/Labels.svelte
new file mode 100644
index 0000000..2475288
--- /dev/null
+++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/Labels.svelte
@@ -0,0 +1,154 @@
+
+
+
+{#snippet fontSelector(
+ name: string,
+ id: string,
+ url: string,
+ fonts: UnifiedFont[],
+ selectFont: (font: UnifiedFont) => void,
+ align: 'start' | 'end',
+)}
+ e.stopPropagation())}
+ >
+
+
+
+
+ {name}
+
+
+
+
+
+
+ {#snippet children({ item: font })}
+ {@const handleClick = () => selectFont(font)}
+
+
+ {font.name}
+
+
+ {/snippet}
+
+
+
+
+
+{/snippet}
+
+
+
+
+ {@render fontSelector(
+ fontB.name,
+ fontB.id,
+ fontB.styles.regular!,
+ fontList,
+ selectFontB,
+ 'start',
+)}
+
+
+
80 ? 0 : 1}
+ style:transform="translateY({sliderPos > 80 ? '8px' : '0px'})"
+ >
+
+ {@render fontSelector(
+ fontA.name,
+ fontA.id,
+ fontA.styles.regular!,
+ fontList,
+ selectFontA,
+ 'end',
+)}
+
+
diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/SliderLine.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/SliderLine.svelte
new file mode 100644
index 0000000..fbb2e52
--- /dev/null
+++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/SliderLine.svelte
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/widgets/ComparisonSlider/ui/index.ts b/src/widgets/ComparisonSlider/ui/index.ts
new file mode 100644
index 0000000..ccad21a
--- /dev/null
+++ b/src/widgets/ComparisonSlider/ui/index.ts
@@ -0,0 +1,3 @@
+import ComparisonSlider from './ComparisonSlider/ComparisonSlider.svelte';
+
+export { ComparisonSlider };
diff --git a/src/widgets/FiltersSidebar/index.ts b/src/widgets/FiltersSidebar/index.ts
deleted file mode 100644
index 895591f..0000000
--- a/src/widgets/FiltersSidebar/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import FiltersSidebar from './ui/FiltersSidebar.svelte';
-
-export { FiltersSidebar };
diff --git a/src/widgets/FiltersSidebar/ui/FiltersSidebar.svelte b/src/widgets/FiltersSidebar/ui/FiltersSidebar.svelte
deleted file mode 100644
index 6ac8070..0000000
--- a/src/widgets/FiltersSidebar/ui/FiltersSidebar.svelte
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/widgets/FontSearch/index.ts b/src/widgets/FontSearch/index.ts
new file mode 100644
index 0000000..e9369b4
--- /dev/null
+++ b/src/widgets/FontSearch/index.ts
@@ -0,0 +1 @@
+export { FontSearch } from './ui';
diff --git a/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte b/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte
new file mode 100644
index 0000000..e8596c7
--- /dev/null
+++ b/src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {#snippet icon({ className })}
+
+ {/snippet}
+
+
+
+
+
+
+ {#if showFilters}
+
+
+
+
+
+
+ filter_params
+
+
+
+
+
+
+
+
+
+
+
+
+ {/if}
+
diff --git a/src/widgets/FontSearch/ui/index.ts b/src/widgets/FontSearch/ui/index.ts
new file mode 100644
index 0000000..e71451a
--- /dev/null
+++ b/src/widgets/FontSearch/ui/index.ts
@@ -0,0 +1,3 @@
+import FontSearch from './FontSearch/FontSearch.svelte';
+
+export { FontSearch };
diff --git a/src/widgets/SampleList/index.ts b/src/widgets/SampleList/index.ts
new file mode 100644
index 0000000..fac592d
--- /dev/null
+++ b/src/widgets/SampleList/index.ts
@@ -0,0 +1 @@
+export { SampleList } from './ui';
diff --git a/src/widgets/SampleList/ui/SampleList/SampleList.svelte b/src/widgets/SampleList/ui/SampleList/SampleList.svelte
new file mode 100644
index 0000000..112f3fb
--- /dev/null
+++ b/src/widgets/SampleList/ui/SampleList/SampleList.svelte
@@ -0,0 +1,72 @@
+
+
+
+{#if unifiedFontStore.isFetching || unifiedFontStore.isLoading}
+ (Loading...)
+{/if}
+
+
+ {#snippet children({ item: font, isFullyVisible, isPartiallyVisible, proximity, index })}
+
+
+
+ {/snippet}
+
diff --git a/src/widgets/SampleList/ui/index.ts b/src/widgets/SampleList/ui/index.ts
new file mode 100644
index 0000000..d73a19d
--- /dev/null
+++ b/src/widgets/SampleList/ui/index.ts
@@ -0,0 +1,3 @@
+import SampleList from './SampleList/SampleList.svelte';
+
+export { SampleList };
diff --git a/src/widgets/TypographySettings/ui/TypographyMenu.svelte b/src/widgets/TypographySettings/ui/TypographyMenu.svelte
index 2b4f4b7..8c2733f 100644
--- a/src/widgets/TypographySettings/ui/TypographyMenu.svelte
+++ b/src/widgets/TypographySettings/ui/TypographyMenu.svelte
@@ -1,17 +1,41 @@
+
-
-
-
+
+
+
-
diff --git a/src/widgets/index.ts b/src/widgets/index.ts
new file mode 100644
index 0000000..0abb6ac
--- /dev/null
+++ b/src/widgets/index.ts
@@ -0,0 +1,3 @@
+export { ComparisonSlider } from './ComparisonSlider';
+export { FontSearch } from './FontSearch';
+export { TypographyMenu } from './TypographySettings';