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; 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)} { + 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'; diff --git a/src/shared/ui/Badge/Badge.svelte b/src/shared/ui/Badge/Badge.svelte index 3e81110..9c46dd3 100644 --- a/src/shared/ui/Badge/Badge.svelte +++ b/src/shared/ui/Badge/Badge.svelte @@ -3,11 +3,11 @@ Pill badge with border and optional status dot. -->
-
+
{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. -->
- - - {#snippet template(args: ComponentProps)} - - {/snippet} - - - - {#snippet template()} -
- - - -
- {/snippet} -
diff --git a/src/shared/ui/Footnote/Footnote.svelte b/src/shared/ui/Footnote/Footnote.svelte index 84193ec..cf69ca8 100644 --- a/src/shared/ui/Footnote/Footnote.svelte +++ b/src/shared/ui/Footnote/Footnote.svelte @@ -3,7 +3,7 @@ Provides classes for styling footnotes --> -
-
+
+
{#if leftIcon}
@@ -147,7 +147,7 @@ const inputClasses = $derived(clsx( {#if helperText} +import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right'; +import { defineMeta } from '@storybook/addon-svelte-csf'; +import type { ComponentProps } from 'svelte'; +import Link from './Link.svelte'; + +const { Story } = defineMeta({ + title: 'Shared/Link', + component: Link, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'Styled link component based on the footer link design. Supports optional icon snippet and standard anchor attributes.', + }, + story: { inline: false }, + }, + layout: 'centered', + }, + argTypes: { + href: { + control: 'text', + description: 'Link URL', + }, + }, +}); + + + + {#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/FooterLink/FooterLink.svelte b/src/shared/ui/Link/Link.svelte similarity index 55% rename from src/shared/ui/FooterLink/FooterLink.svelte rename to src/shared/ui/Link/Link.svelte index 3b6a8e9..653caab 100644 --- a/src/shared/ui/FooterLink/FooterLink.svelte +++ b/src/shared/ui/Link/Link.svelte @@ -1,21 +1,22 @@ - {text} - + {@render children?.()} + {@render icon?.()} diff --git a/src/shared/ui/FooterLink/FooterLink.svelte.test.ts b/src/shared/ui/Link/Link.svelte.test.ts similarity index 56% rename from src/shared/ui/FooterLink/FooterLink.svelte.test.ts rename to src/shared/ui/Link/Link.svelte.test.ts index 66435a7..3cffcdf 100644 --- a/src/shared/ui/FooterLink/FooterLink.svelte.test.ts +++ b/src/shared/ui/Link/Link.svelte.test.ts @@ -2,29 +2,58 @@ import { render, screen, } from '@testing-library/svelte'; -import FooterLink from './FooterLink.svelte'; +import { createRawSnippet } from 'svelte'; +import Link from './Link.svelte'; -describe('FooterLink', () => { +/** + * 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 = { - text: 'Google Fonts', href: 'https://fonts.google.com', }; describe('Rendering', () => { - it('renders text content', () => { - render(FooterLink, { props: defaultProps }); + 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(FooterLink, { props: defaultProps }); + render(Link, { props: defaultProps }); const link = screen.getByRole('link'); expect(link).toBeInTheDocument(); expect(link).toHaveAttribute('href', 'https://fonts.google.com'); }); - it('renders the arrow icon', () => { - const { container } = render(FooterLink, { props: defaultProps }); + 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'); @@ -33,7 +62,7 @@ describe('FooterLink', () => { describe('Attributes', () => { it('applies custom CSS classes', () => { - render(FooterLink, { + render(Link, { props: { ...defaultProps, class: 'custom-class', @@ -43,7 +72,7 @@ describe('FooterLink', () => { }); it('spreads additional anchor attributes', () => { - render(FooterLink, { + render(Link, { props: { ...defaultProps, target: '_blank', diff --git a/src/shared/ui/Logo/Logo.svelte b/src/shared/ui/Logo/Logo.svelte index dee6821..7edb647 100644 --- a/src/shared/ui/Logo/Logo.svelte +++ b/src/shared/ui/Logo/Logo.svelte @@ -3,8 +3,8 @@ Project logo with apropriate styles --> -
+

{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. -->
>({ 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( @@ -198,10 +194,10 @@ const scaleClass = $derived( Outer flex container — fills parent. The paper div inside scales down when the sidebar opens on desktop. --> -
+
diff --git a/src/widgets/ComparisonView/ui/Thumb/Thumb.svelte b/src/widgets/ComparisonView/ui/Thumb/Thumb.svelte index d89dc33..d7f665a 100644 --- a/src/widgets/ComparisonView/ui/Thumb/Thumb.svelte +++ b/src/widgets/ComparisonView/ui/Thumb/Thumb.svelte @@ -4,7 +4,7 @@ 1px red vertical rule with square handles at top and bottom. --> - -{#if responsive?.isDesktop || responsive?.isDesktopLarge} -
- -
- -
- - -
-
- - GlyphDiff © 2025 — {currentYear} - -
-
-{/if} diff --git a/src/widgets/Footer/ui/Footer.stories.svelte b/src/widgets/Footer/ui/Footer/Footer.stories.svelte similarity index 100% rename from src/widgets/Footer/ui/Footer.stories.svelte rename to src/widgets/Footer/ui/Footer/Footer.stories.svelte diff --git a/src/widgets/Footer/ui/Footer/Footer.svelte b/src/widgets/Footer/ui/Footer/Footer.svelte new file mode 100644 index 0000000..bb12a42 --- /dev/null +++ b/src/widgets/Footer/ui/Footer/Footer.svelte @@ -0,0 +1,44 @@ + + + +
+ + {#if isVertical} +
+
+ + GlyphDiff © 2025 — {currentYear} + +
+ {/if} + + +
+ +
+
diff --git a/src/widgets/Footer/ui/Footer.svelte.test.ts b/src/widgets/Footer/ui/Footer/Footer.svelte.test.ts similarity index 100% rename from src/widgets/Footer/ui/Footer.svelte.test.ts rename to src/widgets/Footer/ui/Footer/Footer.svelte.test.ts diff --git a/src/widgets/Footer/ui/FooterLink/FooterLink.svelte b/src/widgets/Footer/ui/FooterLink/FooterLink.svelte new file mode 100644 index 0000000..942125b --- /dev/null +++ b/src/widgets/Footer/ui/FooterLink/FooterLink.svelte @@ -0,0 +1,55 @@ + + + + + {text} + {#snippet icon()} + + {/snippet} + 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}