From 2c579a3336850847dd5110dfca00ff1687a41077 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 23 Apr 2026 09:38:30 +0300 Subject: [PATCH 01/11] feat(shared): add cn utility for tailwind-aware class merging --- src/shared/lib/index.ts | 1 + src/shared/lib/utils/cn.test.ts | 30 ++++++++++++++++++++++++++++++ src/shared/lib/utils/cn.ts | 13 +++++++++++++ src/shared/lib/utils/index.ts | 1 + 4 files changed, 45 insertions(+) create mode 100644 src/shared/lib/utils/cn.test.ts create mode 100644 src/shared/lib/utils/cn.ts diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 33c077c..42fccfb 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -39,6 +39,7 @@ export { export { buildQueryString, clampNumber, + cn, debounce, getDecimalPlaces, roundToStepPrecision, diff --git a/src/shared/lib/utils/cn.test.ts b/src/shared/lib/utils/cn.test.ts new file mode 100644 index 0000000..34475b0 --- /dev/null +++ b/src/shared/lib/utils/cn.test.ts @@ -0,0 +1,30 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import { cn } from './cn'; + +describe('cn utility', () => { + it('should merge classes with clsx', () => { + expect(cn('class1', 'class2')).toBe('class1 class2'); + expect(cn('class1', { class2: true, class3: false })).toBe('class1 class2'); + }); + + it('should resolve tailwind specificity conflicts', () => { + // text-neutral-400 vs text-brand (text-brand should win) + expect(cn('text-neutral-400', 'text-brand')).toBe('text-brand'); + + // p-4 vs p-2 + expect(cn('p-4', 'p-2')).toBe('p-2'); + + // dark mode classes should be handled correctly too + expect(cn('text-neutral-400 dark:text-neutral-400', 'text-brand dark:text-brand')).toBe( + 'text-brand dark:text-brand', + ); + }); + + it('should handle undefined and null inputs', () => { + expect(cn('class1', undefined, null, 'class2')).toBe('class1 class2'); + }); +}); diff --git a/src/shared/lib/utils/cn.ts b/src/shared/lib/utils/cn.ts new file mode 100644 index 0000000..1e1842f --- /dev/null +++ b/src/shared/lib/utils/cn.ts @@ -0,0 +1,13 @@ +import { + type ClassValue, + clsx, +} from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +/** + * Utility for merging Tailwind classes with clsx and tailwind-merge. + * This resolves specificity conflicts between Tailwind classes. + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/shared/lib/utils/index.ts b/src/shared/lib/utils/index.ts index b4ae529..21eef79 100644 --- a/src/shared/lib/utils/index.ts +++ b/src/shared/lib/utils/index.ts @@ -15,6 +15,7 @@ export { type QueryParamValue, } from './buildQueryString/buildQueryString'; export { clampNumber } from './clampNumber/clampNumber'; +export { cn } from './cn'; export { debounce } from './debounce/debounce'; export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces'; export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth'; From dbcc1caeb0d6c867af55daf78ebec2660c52ab3e Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 23 Apr 2026 09:41:31 +0300 Subject: [PATCH 02/11] feat(Footer): change the footer styles and layout to avoid overlapping with the TypographyMenu --- src/widgets/Footer/ui/Footer.svelte | 52 +++++++++++++++++------------ 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/src/widgets/Footer/ui/Footer.svelte b/src/widgets/Footer/ui/Footer.svelte index a8b1f6f..2a57fd7 100644 --- a/src/widgets/Footer/ui/Footer.svelte +++ b/src/widgets/Footer/ui/Footer.svelte @@ -6,31 +6,41 @@ -{#if responsive?.isDesktop || responsive?.isDesktopLarge} -
- -
- -
+
+ +
+
+ + GlyphDiff © 2025 — {currentYear} + +
- -
-
- - GlyphDiff © 2025 — {currentYear} - -
-
-{/if} + +
+ +
+
From ada484e2e07e4177f858f4fa2054d0830c3f2a81 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 23 Apr 2026 09:42:33 +0300 Subject: [PATCH 03/11] feat(FooterLink): tweak the styles --- src/shared/ui/FooterLink/FooterLink.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/ui/FooterLink/FooterLink.svelte b/src/shared/ui/FooterLink/FooterLink.svelte index 3b6a8e9..80cba85 100644 --- a/src/shared/ui/FooterLink/FooterLink.svelte +++ b/src/shared/ui/FooterLink/FooterLink.svelte @@ -43,6 +43,6 @@ let { {text} From cffebf05e311c082d20ac6784248f43d7fa7529e Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 23 Apr 2026 09:42:59 +0300 Subject: [PATCH 04/11] feat(SliderArea): tweak the styles --- src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte index e76ceb2..07b93bf 100644 --- a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte +++ b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte @@ -271,7 +271,7 @@ const scaleClass = $derived( Date: Thu, 23 Apr 2026 09:48:32 +0300 Subject: [PATCH 05/11] feat: replace clsx with cn util --- src/app/ui/Layout.svelte | 4 ++-- .../ui/FontApplicator/FontApplicator.svelte | 4 ++-- .../ui/FiltersControl/FilterControls.svelte | 6 +++--- .../ui/TypographyMenu/TypographyMenu.svelte | 8 +++---- src/shared/lib/storybook/MockIcon.svelte | 4 ++-- src/shared/ui/Badge/Badge.svelte | 4 ++-- src/shared/ui/Button/Button.svelte | 21 +++++++++---------- src/shared/ui/Button/ButtonGroup.svelte | 4 ++-- .../ui/ComboControl/ComboControl.svelte | 8 +++---- .../ui/ControlGroup/ControlGroup.svelte | 4 ++-- src/shared/ui/Divider/Divider.svelte | 4 ++-- src/shared/ui/FilterGroup/FilterGroup.svelte | 4 ++-- src/shared/ui/FooterLink/FooterLink.svelte | 4 ++-- src/shared/ui/Footnote/Footnote.svelte | 6 +++--- src/shared/ui/Input/Input.svelte | 10 ++++----- src/shared/ui/Label/Label.svelte | 4 ++-- src/shared/ui/Logo/Logo.svelte | 4 ++-- .../ui/PerspectivePlan/PerspectivePlan.svelte | 4 ++-- .../SectionHeader/SectionHeader.svelte | 4 ++-- .../SectionSeparator/SectionSeparator.svelte | 4 ++-- .../SidebarContainer/SidebarContainer.svelte | 4 ++-- src/shared/ui/Skeleton/Skeleton.svelte | 4 ++-- src/shared/ui/Stat/Stat.svelte | 4 ++-- src/shared/ui/Stat/StatGroup.svelte | 4 ++-- src/shared/ui/TechText/TechText.svelte | 4 ++-- src/shared/ui/VirtualList/VirtualList.svelte | 6 +++--- .../ui/Character/Character.svelte | 4 ++-- .../ComparisonView/ui/Header/Header.svelte | 4 ++-- .../ComparisonView/ui/Sidebar/Sidebar.svelte | 4 ++-- .../ui/SliderArea/SliderArea.svelte | 8 +++---- .../ComparisonView/ui/Thumb/Thumb.svelte | 6 +++--- .../FontSearchSection.svelte | 4 ++-- src/widgets/Footer/ui/Footer.svelte | 8 +++---- .../SampleListSection.svelte | 4 ++-- 34 files changed, 91 insertions(+), 92 deletions(-) diff --git a/src/app/ui/Layout.svelte b/src/app/ui/Layout.svelte index 6788f85..651482f 100644 --- a/src/app/ui/Layout.svelte +++ b/src/app/ui/Layout.svelte @@ -6,8 +6,8 @@ import { themeManager } from '$features/ChangeAppTheme'; import G from '$shared/assets/G.svg'; import { ResponsiveProvider } from '$shared/lib'; +import { cn } from '$shared/lib'; import { Footer } from '$widgets/Footer'; -import clsx from 'clsx'; import { type Snippet, @@ -73,7 +73,7 @@ onDestroy(() => themeManager.destroy());
{#snippet icon()} diff --git a/src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte b/src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte index 0bd8555..1e1cba0 100644 --- a/src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte +++ b/src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte @@ -11,6 +11,7 @@ import { MULTIPLIER_S, } from '$entities/Font'; import type { ResponsiveManager } from '$shared/lib'; +import { cn } from '$shared/lib'; import { Button, ComboControl, @@ -20,7 +21,6 @@ import { import Settings2Icon from '@lucide/svelte/icons/settings-2'; import XIcon from '@lucide/svelte/icons/x'; import { Popover } from 'bits-ui'; -import clsx from 'clsx'; import { getContext } from 'svelte'; import { cubicOut } from 'svelte/easing'; import { fly } from 'svelte/transition'; @@ -88,7 +88,7 @@ $effect(() => { side="top" align="end" sideOffset={8} - class={clsx( + class={cn( 'z-50 w-72', 'bg-surface dark:bg-dark-card', 'border border-subtle', @@ -142,11 +142,11 @@ $effect(() => { {:else}
{#if Icon} - {@const __iconClass__ = clsx('size-4', className)} + {@const __iconClass__ = cn('size-4', className)}
-
+
{label}
diff --git a/src/shared/ui/Divider/Divider.svelte b/src/shared/ui/Divider/Divider.svelte index 1edd45d..74baa67 100644 --- a/src/shared/ui/Divider/Divider.svelte +++ b/src/shared/ui/Divider/Divider.svelte @@ -3,7 +3,7 @@ 1px separator line, horizontal or vertical. -->
-
-
+
+
{#if leftIcon}
@@ -147,7 +147,7 @@ const inputClasses = $derived(clsx( {#if helperText} -
+

{title}

diff --git a/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte b/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte index 2ad7f74..a43341b 100644 --- a/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte +++ b/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte @@ -5,7 +5,7 @@ -->
-
+
{#if pulse} diff --git a/src/shared/ui/Section/SectionSeparator/SectionSeparator.svelte b/src/shared/ui/Section/SectionSeparator/SectionSeparator.svelte index 5a98005..264a981 100644 --- a/src/shared/ui/Section/SectionSeparator/SectionSeparator.svelte +++ b/src/shared/ui/Section/SectionSeparator/SectionSeparator.svelte @@ -3,7 +3,7 @@ A horizontal separator line used to visually separate sections within a page. --> -
+
diff --git a/src/shared/ui/SidebarContainer/SidebarContainer.svelte b/src/shared/ui/SidebarContainer/SidebarContainer.svelte index bb4dd7d..3bfb2ec 100644 --- a/src/shared/ui/SidebarContainer/SidebarContainer.svelte +++ b/src/shared/ui/SidebarContainer/SidebarContainer.svelte @@ -4,7 +4,7 @@ -->
-
+
diff --git a/src/shared/ui/Stat/StatGroup.svelte b/src/shared/ui/Stat/StatGroup.svelte index 78e729b..f80c91d 100644 --- a/src/shared/ui/Stat/StatGroup.svelte +++ b/src/shared/ui/Stat/StatGroup.svelte @@ -3,8 +3,8 @@ Renders multiple Stat components in a row with auto-separators. --> -
+
{#each stats as stat, i} element for technical values, measurements, identifiers. -->
-
+
diff --git a/src/widgets/SampleList/ui/SampleListSection/SampleListSection.svelte b/src/widgets/SampleList/ui/SampleListSection/SampleListSection.svelte index cb4f1dc..03b9ced 100644 --- a/src/widgets/SampleList/ui/SampleListSection/SampleListSection.svelte +++ b/src/widgets/SampleList/ui/SampleListSection/SampleListSection.svelte @@ -6,11 +6,11 @@ import { NavigationWrapper } from '$entities/Breadcrumb'; import { fontStore } from '$entities/Font'; import type { ResponsiveManager } from '$shared/lib'; +import { cn } from '$shared/lib'; import { Label, Section, } from '$shared/ui'; -import clsx from 'clsx'; import { getContext } from 'svelte'; import { layoutManager } from '../../model'; import LayoutSwitch from '../LayoutSwitch/LayoutSwitch.svelte'; @@ -50,7 +50,7 @@ const responsive = getContext('responsive'); {/snippet} {#snippet content({ className })} -
+
{/snippet} From 652dfa5c90f65c1bc678199de51351a7335939b5 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 23 Apr 2026 10:08:44 +0300 Subject: [PATCH 06/11] feat: brand colored text selection --- src/app/styles/app.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/styles/app.css b/src/app/styles/app.css index 1840ab1..b00d02a 100644 --- a/src/app/styles/app.css +++ b/src/app/styles/app.css @@ -219,6 +219,11 @@ @apply border-border outline-ring/50; } + ::selection { + background-color: var(--color-brand); + color: var(--swiss-white); + } + body { @apply bg-background text-foreground; font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif; From 4eafb96d355ae327be96895f8854476e256b09aa Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 23 Apr 2026 12:45:13 +0300 Subject: [PATCH 07/11] feat(ComparisonView): replace window resize listener with ResiseObserver on the container to catch the container size change on sidebar open/close --- .../ui/SliderArea/SliderArea.svelte | 60 +++++++++---------- 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte index bb93baa..3b03a87 100644 --- a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte +++ b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte @@ -61,6 +61,26 @@ const comparisonEngine = new CharacterComparisonEngine(); let layoutResult = $state>({ lines: [], totalHeight: 0 }); +// Track container width changes (window resize, sidebar toggle, etc.) +$effect(() => { + if (!container) { + return; + } + + const observer = new ResizeObserver(entries => { + for (const entry of entries) { + // Use borderBoxSize if available, fallback to contentRect + const width = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; + if (width > 0) { + containerWidth = width; + } + } + }); + + observer.observe(container); + return () => observer.disconnect(); +}); + const sliderSpring = new Spring(50, { stiffness: 0.2, damping: 0.7, @@ -124,25 +144,25 @@ $effect(() => { } }); +// Layout effect — depends on content, settings AND containerWidth $effect(() => { const _text = comparisonStore.text; const _weight = typography.weight; const _size = typography.renderedSize; const _height = typography.height; const _spacing = typography.spacing; + const _width = containerWidth; + const _isMobile = isMobile; - if (container && fontA && fontB) { + if (container && fontA && fontB && _width > 0) { // PRETEXT API strings: "weight sizepx family" const fontAStr = getPretextFontString(_weight, _size, fontA.name); const fontBStr = getPretextFontString(_weight, _size, fontB.name); - // Use offsetWidth to avoid transform scaling issues - const width = container.offsetWidth; - const padding = isMobile ? 48 : 96; - const availableWidth = width - padding; + const padding = _isMobile ? 48 : 96; + const availableWidth = Math.max(0, _width - padding); const lineHeight = _size * _height; - containerWidth = width; layoutResult = comparisonEngine.layout( _text, fontAStr, @@ -155,30 +175,6 @@ $effect(() => { } }); -$effect(() => { - if (typeof window === 'undefined') { - return; - } - const handleResize = () => { - if (container && fontA && fontB) { - const width = container.offsetWidth; - const padding = isMobile ? 48 : 96; - containerWidth = width; - layoutResult = comparisonEngine.layout( - comparisonStore.text, - getPretextFontString(typography.weight, typography.renderedSize, fontA.name), - getPretextFontString(typography.weight, typography.renderedSize, fontB.name), - width - padding, - typography.renderedSize * typography.height, - typography.spacing, - typography.renderedSize, - ); - } - }; - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); -}); - // Dynamic backgroundSize based on isMobile — can't express this in Tailwind. // Color is set to currentColor so it respects dark mode via text color. const gridStyle = $derived( @@ -273,8 +269,8 @@ const scaleClass = $derived( class={cn( 'absolute z-10', responsive.isMobileOrTablet - ? 'bottom-4 right-4 -translate-1/2' - : 'bottom-5 left-1/2 right-[unset] -translate-x-1/2', + ? 'bottom-0 right-0 -translate-1/2' + : 'bottom-2.5 left-1/2 -translate-x-1/2', )} />
From 877719f106166604186b365c518b6b840f0b1dbd Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 23 Apr 2026 13:05:22 +0300 Subject: [PATCH 08/11] feat(Link): create reusable Link ui component --- src/shared/ui/Link/Link.stories.svelte | 96 ++++++++++++++++++++++++++ src/shared/ui/Link/Link.svelte | 45 ++++++++++++ src/shared/ui/Link/Link.svelte.test.ts | 87 +++++++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 src/shared/ui/Link/Link.stories.svelte create mode 100644 src/shared/ui/Link/Link.svelte create mode 100644 src/shared/ui/Link/Link.svelte.test.ts diff --git a/src/shared/ui/Link/Link.stories.svelte b/src/shared/ui/Link/Link.stories.svelte new file mode 100644 index 0000000..6883e20 --- /dev/null +++ b/src/shared/ui/Link/Link.stories.svelte @@ -0,0 +1,96 @@ + + + + {#snippet template(args: ComponentProps)} + + Google Fonts + + {/snippet} + + + + {#snippet template(args: ComponentProps)} + + Google Fonts + {#snippet icon()} + + {/snippet} + + {/snippet} + + + + {#snippet template()} +
+ + Google Fonts + {#snippet icon()} + + {/snippet} + + + Fontshare + {#snippet icon()} + + {/snippet} + + + GitHub + {#snippet icon()} + + {/snippet} + +
+ {/snippet} +
diff --git a/src/shared/ui/Link/Link.svelte b/src/shared/ui/Link/Link.svelte new file mode 100644 index 0000000..653caab --- /dev/null +++ b/src/shared/ui/Link/Link.svelte @@ -0,0 +1,45 @@ + + + + + {@render children?.()} + {@render icon?.()} + diff --git a/src/shared/ui/Link/Link.svelte.test.ts b/src/shared/ui/Link/Link.svelte.test.ts new file mode 100644 index 0000000..3cffcdf --- /dev/null +++ b/src/shared/ui/Link/Link.svelte.test.ts @@ -0,0 +1,87 @@ +import { + render, + screen, +} from '@testing-library/svelte'; +import { createRawSnippet } from 'svelte'; +import Link from './Link.svelte'; + +/** + * Helper to create a plain text snippet + */ +function textSnippet(text: string) { + return createRawSnippet(() => ({ + render: () => `${text}`, + })); +} + +/** + * Helper to create an icon snippet + */ +function iconSnippet() { + return createRawSnippet(() => ({ + render: () => ``, + })); +} + +describe('Link', () => { + const defaultProps = { + href: 'https://fonts.google.com', + }; + + describe('Rendering', () => { + it('renders text content via children snippet', () => { + render(Link, { + props: { + ...defaultProps, + children: textSnippet('Google Fonts'), + }, + }); + expect(screen.getByText('Google Fonts')).toBeInTheDocument(); + }); + + it('renders as an anchor element with correct href', () => { + render(Link, { props: defaultProps }); + const link = screen.getByRole('link'); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', 'https://fonts.google.com'); + }); + + it('renders the icon when provided via snippet', () => { + const { container } = render(Link, { + props: { + ...defaultProps, + children: textSnippet('Google Fonts'), + icon: iconSnippet(), + }, + }); + const icon = container.querySelector('svg'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveClass('lucide-arrow-up-right'); + }); + }); + + describe('Attributes', () => { + it('applies custom CSS classes', () => { + render(Link, { + props: { + ...defaultProps, + class: 'custom-class', + }, + }); + expect(screen.getByRole('link')).toHaveClass('custom-class'); + }); + + it('spreads additional anchor attributes', () => { + render(Link, { + props: { + ...defaultProps, + target: '_blank', + rel: 'noopener', + }, + }); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener'); + }); + }); +}); From 2ae24912f7c6740215cc1718f62ad7aa67b15ce4 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 23 Apr 2026 13:06:17 +0300 Subject: [PATCH 09/11] feat(Footer): tweak the footer position --- src/widgets/Footer/ui/Footer.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/Footer/ui/Footer.svelte b/src/widgets/Footer/ui/Footer.svelte index 2a634da..d36a0f9 100644 --- a/src/widgets/Footer/ui/Footer.svelte +++ b/src/widgets/Footer/ui/Footer.svelte @@ -17,7 +17,7 @@ const currentYear = new Date().getFullYear();