feat(shared): add cn utility for tailwind-aware class merging #38

Merged
ilia merged 11 commits from feature/minor-improvements into main 2026-04-23 12:11:03 +00:00
48 changed files with 419 additions and 242 deletions
+5
View File
@@ -219,6 +219,11 @@
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
::selection {
background-color: var(--color-brand);
color: var(--swiss-white);
}
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif; font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
+2 -2
View File
@@ -6,8 +6,8 @@
import { themeManager } from '$features/ChangeAppTheme'; import { themeManager } from '$features/ChangeAppTheme';
import G from '$shared/assets/G.svg'; import G from '$shared/assets/G.svg';
import { ResponsiveProvider } from '$shared/lib'; import { ResponsiveProvider } from '$shared/lib';
import { cn } from '$shared/lib';
import { Footer } from '$widgets/Footer'; import { Footer } from '$widgets/Footer';
import clsx from 'clsx';
import { import {
type Snippet, type Snippet,
@@ -73,7 +73,7 @@ onDestroy(() => themeManager.destroy());
<ResponsiveProvider> <ResponsiveProvider>
<div <div
id="app-root" id="app-root"
class={clsx( class={cn(
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg relative', 'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg relative',
theme === 'dark' ? 'dark' : '', theme === 'dark' ? 'dark' : '',
)} )}
@@ -4,7 +4,7 @@
Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided. Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
--> -->
<script lang="ts"> <script lang="ts">
import clsx from 'clsx'; import { cn } from '$shared/lib';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { import {
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
@@ -61,7 +61,7 @@ const shouldReveal = $derived(status === 'loaded' || status === 'error');
{:else} {:else}
<div <div
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'} style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
class={clsx(className)} class={cn(className)}
> >
{@render children?.()} {@render children?.()}
</div> </div>
@@ -6,10 +6,10 @@
<script lang="ts"> <script lang="ts">
import { fontStore } from '$entities/Font'; import { fontStore } from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import { Button } from '$shared/ui'; import { Button } from '$shared/ui';
import { Label } from '$shared/ui'; import { Label } from '$shared/ui';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import clsx from 'clsx';
import { import {
getContext, getContext,
untrack, untrack,
@@ -45,7 +45,7 @@ function handleReset() {
</script> </script>
<div <div
class={clsx( class={cn(
'flex flex-col md:flex-row justify-between items-start md:items-center', 'flex flex-col md:flex-row justify-between items-start md:items-center',
'gap-1 md:gap-6', 'gap-1 md:gap-6',
'pt-6 mt-6 md:pt-8 md:mt-8', 'pt-6 mt-6 md:pt-8 md:mt-8',
@@ -77,7 +77,7 @@ function handleReset() {
variant="ghost" variant="ghost"
size={isMobileOrTabletPortrait ? 'xs' : 'sm'} size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
onclick={handleReset} onclick={handleReset}
class={clsx('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')} class={cn('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
iconPosition="left" iconPosition="left"
> >
{#snippet icon()} {#snippet icon()}
@@ -11,6 +11,7 @@ import {
MULTIPLIER_S, MULTIPLIER_S,
} from '$entities/Font'; } from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import { import {
Button, Button,
ComboControl, ComboControl,
@@ -20,7 +21,6 @@ import {
import Settings2Icon from '@lucide/svelte/icons/settings-2'; import Settings2Icon from '@lucide/svelte/icons/settings-2';
import XIcon from '@lucide/svelte/icons/x'; import XIcon from '@lucide/svelte/icons/x';
import { Popover } from 'bits-ui'; import { Popover } from 'bits-ui';
import clsx from 'clsx';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
@@ -88,7 +88,7 @@ $effect(() => {
side="top" side="top"
align="end" align="end"
sideOffset={8} sideOffset={8}
class={clsx( class={cn(
'z-50 w-72', 'z-50 w-72',
'bg-surface dark:bg-dark-card', 'bg-surface dark:bg-dark-card',
'border border-subtle', 'border border-subtle',
@@ -142,11 +142,11 @@ $effect(() => {
</Popover.Root> </Popover.Root>
{:else} {:else}
<div <div
class={clsx('w-full md:w-auto', className)} class={cn('w-full md:w-auto', className)}
transition:fly={{ y: 100, duration: 200, easing: cubicOut }} transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
> >
<div <div
class={clsx( class={cn(
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2', 'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl', 'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
'border border-subtle', 'border border-subtle',
+1
View File
@@ -39,6 +39,7 @@ export {
export { export {
buildQueryString, buildQueryString,
clampNumber, clampNumber,
cn,
debounce, debounce,
getDecimalPlaces, getDecimalPlaces,
roundToStepPrecision, roundToStepPrecision,
+2 -2
View File
@@ -7,7 +7,7 @@
correctly via the HTML element's class attribute. correctly via the HTML element's class attribute.
--> -->
<script lang="ts"> <script lang="ts">
import clsx from 'clsx'; import { cn } from '$shared/lib';
import type { import type {
Component, Component,
Snippet, Snippet,
@@ -32,7 +32,7 @@ let { icon: Icon, class: className, attrs = {} }: Props = $props();
</script> </script>
{#if Icon} {#if Icon}
{@const __iconClass__ = clsx('size-4', className)} {@const __iconClass__ = cn('size-4', className)}
<!-- Render icon component dynamically with class prop --> <!-- Render icon component dynamically with class prop -->
<Icon <Icon
class={__iconClass__} class={__iconClass__}
+30
View File
@@ -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');
});
});
+13
View File
@@ -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));
}
+1
View File
@@ -15,6 +15,7 @@ export {
type QueryParamValue, type QueryParamValue,
} from './buildQueryString/buildQueryString'; } from './buildQueryString/buildQueryString';
export { clampNumber } from './clampNumber/clampNumber'; export { clampNumber } from './clampNumber/clampNumber';
export { cn } from './cn';
export { debounce } from './debounce/debounce'; export { debounce } from './debounce/debounce';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces'; export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth'; export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
+2 -2
View File
@@ -3,11 +3,11 @@
Pill badge with border and optional status dot. Pill badge with border and optional status dot.
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/lib';
import { import {
type LabelSize, type LabelSize,
labelSizeConfig, labelSizeConfig,
} from '$shared/ui/Label/config'; } from '$shared/ui/Label/config';
import clsx from 'clsx';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
@@ -64,7 +64,7 @@ let {
</script> </script>
<span <span
class={clsx( class={cn(
'inline-flex items-center gap-1 px-2 py-0.5 border rounded-full', 'inline-flex items-center gap-1 px-2 py-0.5 border rounded-full',
'font-mono uppercase tracking-wide', 'font-mono uppercase tracking-wide',
labelSizeConfig[size], labelSizeConfig[size],
+10 -11
View File
@@ -3,7 +3,7 @@
design-system button. Uppercase, zero border-radius, Space Grotesk. design-system button. Uppercase, zero border-radius, Space Grotesk.
--> -->
<script lang="ts"> <script lang="ts">
import clsx from 'clsx'; import { cn } from '$shared/lib';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements'; import type { HTMLButtonAttributes } from 'svelte/elements';
import type { import type {
@@ -71,7 +71,7 @@ let {
const isIconOnly = $derived(!!icon && !children); const isIconOnly = $derived(!!icon && !children);
const variantStyles: Record<ButtonVariant, string> = { const variantStyles: Record<ButtonVariant, string> = {
primary: clsx( primary: cn(
'bg-swiss-red text-white', 'bg-swiss-red text-white',
'hover:bg-swiss-red/90', 'hover:bg-swiss-red/90',
'active:bg-swiss-red/80', 'active:bg-swiss-red/80',
@@ -87,7 +87,7 @@ const variantStyles: Record<ButtonVariant, string> = {
'disabled:cursor-not-allowed', 'disabled:cursor-not-allowed',
'disabled:transform-none', 'disabled:transform-none',
), ),
secondary: clsx( secondary: cn(
'bg-surface dark:bg-paper', 'bg-surface dark:bg-paper',
'text-swiss-black dark:text-neutral-200', 'text-swiss-black dark:text-neutral-200',
'border border-black/10 dark:border-white/10', 'border border-black/10 dark:border-white/10',
@@ -98,7 +98,7 @@ const variantStyles: Record<ButtonVariant, string> = {
'disabled:text-neutral-400 dark:disabled:text-neutral-600', 'disabled:text-neutral-400 dark:disabled:text-neutral-600',
'disabled:cursor-not-allowed', 'disabled:cursor-not-allowed',
), ),
outline: clsx( outline: cn(
'bg-transparent', 'bg-transparent',
'text-swiss-black dark:text-neutral-200', 'text-swiss-black dark:text-neutral-200',
'border border-black/20 dark:border-white/20', 'border border-black/20 dark:border-white/20',
@@ -109,7 +109,7 @@ const variantStyles: Record<ButtonVariant, string> = {
'disabled:text-neutral-400 dark:disabled:text-neutral-600', 'disabled:text-neutral-400 dark:disabled:text-neutral-600',
'disabled:cursor-not-allowed', 'disabled:cursor-not-allowed',
), ),
ghost: clsx( ghost: cn(
'bg-transparent', 'bg-transparent',
'text-secondary', 'text-secondary',
'border border-transparent', 'border border-transparent',
@@ -119,7 +119,7 @@ const variantStyles: Record<ButtonVariant, string> = {
'disabled:text-neutral-400 dark:disabled:text-neutral-600', 'disabled:text-neutral-400 dark:disabled:text-neutral-600',
'disabled:cursor-not-allowed', 'disabled:cursor-not-allowed',
), ),
icon: clsx( icon: cn(
'bg-surface dark:bg-dark-bg', 'bg-surface dark:bg-dark-bg',
'text-secondary', 'text-secondary',
'border border-transparent', 'border border-transparent',
@@ -130,8 +130,8 @@ const variantStyles: Record<ButtonVariant, string> = {
'disabled:text-neutral-400 dark:disabled:text-neutral-600', 'disabled:text-neutral-400 dark:disabled:text-neutral-600',
'disabled:cursor-not-allowed', 'disabled:cursor-not-allowed',
), ),
tertiary: clsx( tertiary: cn(
// Font override — must come after base in clsx() to win via tailwind-merge // Font override — must come after base in cn() to win via tailwind-merge
'font-secondary font-medium normal-case tracking-normal', 'font-secondary font-medium normal-case tracking-normal',
// Inactive state // Inactive state
'bg-transparent', 'bg-transparent',
@@ -168,14 +168,13 @@ const iconSizeStyles: Record<ButtonSize, string> = {
const activeStyles: Partial<Record<ButtonVariant, string>> = { const activeStyles: Partial<Record<ButtonVariant, string>> = {
secondary: 'bg-paper dark:bg-paper shadow-sm border-black/20 dark:border-white/20', secondary: 'bg-paper dark:bg-paper shadow-sm border-black/20 dark:border-white/20',
tertiary: tertiary: 'bg-paper dark:bg-dark-card border-black/10 dark:border-white/10 shadow-sm text-brand dark:text-brand',
'bg-paper dark:bg-dark-card border-black/10 dark:border-white/10 shadow-sm text-neutral-900 dark:text-neutral-100',
ghost: 'bg-transparent dark:bg-transparent text-brand dark:text-brand', ghost: 'bg-transparent dark:bg-transparent text-brand dark:text-brand',
outline: 'bg-surface dark:bg-paper border-brand', outline: 'bg-surface dark:bg-paper border-brand',
icon: 'bg-paper dark:bg-paper text-brand border-subtle', icon: 'bg-paper dark:bg-paper text-brand border-subtle',
}; };
const classes = $derived(clsx( const classes = $derived(cn(
// Base // Base
'inline-flex items-center justify-center', 'inline-flex items-center justify-center',
'font-primary font-bold tracking-tight uppercase', 'font-primary font-bold tracking-tight uppercase',
+2 -2
View File
@@ -4,7 +4,7 @@
Use for segmented controls, view toggles, or any mutually exclusive button set. Use for segmented controls, view toggles, or any mutually exclusive button set.
--> -->
<script lang="ts"> <script lang="ts">
import clsx from 'clsx'; import { cn } from '$shared/lib';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
@@ -23,7 +23,7 @@ let { children, class: className, ...rest }: Props = $props();
</script> </script>
<div <div
class={clsx( class={cn(
'flex items-center gap-1 p-1', 'flex items-center gap-1 p-1',
'bg-surface dark:bg-dark-bg', 'bg-surface dark:bg-dark-bg',
'border border-subtle', 'border border-subtle',
@@ -5,12 +5,12 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { TypographyControl } from '$shared/lib'; import type { TypographyControl } from '$shared/lib';
import { cn } from '$shared/lib';
import { Slider } from '$shared/ui'; import { Slider } from '$shared/ui';
import { Button } from '$shared/ui/Button'; import { Button } from '$shared/ui/Button';
import MinusIcon from '@lucide/svelte/icons/minus'; import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import { Popover } from 'bits-ui'; import { Popover } from 'bits-ui';
import clsx from 'clsx';
import TechText from '../TechText/TechText.svelte'; import TechText from '../TechText/TechText.svelte';
interface Props { interface Props {
@@ -78,7 +78,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
--> -->
{#if reduced} {#if reduced}
<div <div
class={clsx( class={cn(
'flex gap-4 items-end w-full', 'flex gap-4 items-end w-full',
className, className,
)} )}
@@ -98,7 +98,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
<!-- ── FULL MODE ──────────────────────────────────────────────────────────────── --> <!-- ── FULL MODE ──────────────────────────────────────────────────────────────── -->
{:else} {:else}
<div class={clsx('flex items-center px-1 relative', className)}> <div class={cn('flex items-center px-1 relative', className)}>
<!-- Decrease button --> <!-- Decrease button -->
<Button <Button
variant="icon" variant="icon"
@@ -119,7 +119,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
{#snippet child({ props })} {#snippet child({ props })}
<button <button
{...props} {...props}
class={clsx( class={cn(
'flex flex-col items-center justify-center w-14 py-1', 'flex flex-col items-center justify-center w-14 py-1',
'select-none rounded-none transition-all duration-150', 'select-none rounded-none transition-all duration-150',
'border border-transparent', 'border border-transparent',
@@ -3,7 +3,7 @@
Labeled container for form controls Labeled container for form controls
--> -->
<script lang="ts"> <script lang="ts">
import clsx from 'clsx'; import { cn } from '$shared/lib';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
@@ -24,7 +24,7 @@ interface Props {
const { label, children, class: className }: Props = $props(); const { label, children, class: className }: Props = $props();
</script> </script>
<div class={clsx('flex flex-col gap-3 py-6 border-b border-subtle last:border-0', className)}> <div class={cn('flex flex-col gap-3 py-6 border-b border-subtle last:border-0', className)}>
<div class="flex justify-between items-center text-xs font-primary font-bold tracking-tight text-neutral-900 dark:text-neutral-100 uppercase leading-none"> <div class="flex justify-between items-center text-xs font-primary font-bold tracking-tight text-neutral-900 dark:text-neutral-100 uppercase leading-none">
{label} {label}
</div> </div>
+2 -2
View File
@@ -3,7 +3,7 @@
1px separator line, horizontal or vertical. 1px separator line, horizontal or vertical.
--> -->
<script lang="ts"> <script lang="ts">
import clsx from 'clsx'; import { cn } from '$shared/lib';
interface Props { interface Props {
/** /**
@@ -24,7 +24,7 @@ let {
</script> </script>
<div <div
class={clsx( class={cn(
'bg-black/10 dark:bg-white/10', 'bg-black/10 dark:bg-white/10',
orientation === 'horizontal' ? 'w-full h-px' : 'w-px h-full', orientation === 'horizontal' ? 'w-full h-px' : 'w-px h-full',
className, className,
+2 -2
View File
@@ -4,11 +4,11 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { Filter } from '$shared/lib'; import type { Filter } from '$shared/lib';
import { cn } from '$shared/lib';
import { Button } from '$shared/ui'; import { Button } from '$shared/ui';
import { Label } from '$shared/ui'; import { Label } from '$shared/ui';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up'; import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis'; import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import clsx from 'clsx';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { import {
draw, draw,
@@ -68,7 +68,7 @@ $effect(() => {
</svg> </svg>
{/snippet} {/snippet}
<div class={clsx('flex flex-col', className)}> <div class={cn('flex flex-col', className)}>
<Label <Label
variant="default" variant="default"
size="sm" size="sm"
@@ -1,53 +0,0 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import type { ComponentProps } from 'svelte';
import FooterLink from './FooterLink.svelte';
const { Story } = defineMeta({
title: 'Shared/FooterLink',
component: FooterLink,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Standard footer link with arrow icon and hover effects.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
text: {
control: 'text',
description: 'Link text',
},
href: {
control: 'text',
description: 'Link URL',
},
},
});
</script>
<Story
name="Default"
args={{
text: 'Google Fonts',
href: 'https://fonts.google.com',
target: '_blank',
}}
>
{#snippet template(args: ComponentProps<typeof FooterLink>)}
<FooterLink {...args} />
{/snippet}
</Story>
<Story name="Multiple Links">
{#snippet template()}
<div class="flex gap-4 p-8 bg-neutral-100 dark:bg-neutral-900 rounded-lg">
<FooterLink text="Google Fonts" href="https://fonts.google.com" target="_blank" />
<FooterLink text="Fontshare" href="https://www.fontshare.com" target="_blank" />
<FooterLink text="GitHub" href="https://github.com" target="_blank" />
</div>
{/snippet}
</Story>
+3 -3
View File
@@ -3,7 +3,7 @@
Provides classes for styling footnotes Provides classes for styling footnotes
--> -->
<script lang="ts"> <script lang="ts">
import clsx from 'clsx'; import { cn } from '$shared/lib';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
@@ -26,14 +26,14 @@ const { children, class: className, render }: Props = $props();
{#if render} {#if render}
{@render render({ {@render render({
class: clsx( class: cn(
'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft', 'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft',
className, className,
), ),
})} })}
{:else if children} {:else if children}
<span <span
class={clsx( class={cn(
'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft', 'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft',
className, className,
)} )}
+5 -5
View File
@@ -3,8 +3,8 @@
design-system input. Zero border-radius, Space Grotesk, precise states. design-system input. Zero border-radius, Space Grotesk, precise states.
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/lib';
import XIcon from '@lucide/svelte/icons/x'; import XIcon from '@lucide/svelte/icons/x';
import clsx from 'clsx';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import type { HTMLInputAttributes } from 'svelte/elements'; import type { HTMLInputAttributes } from 'svelte/elements';
@@ -90,7 +90,7 @@ const hasRightSlot = $derived(!!rightIcon || showClearButton);
const cfg = $derived(inputSizeConfig[size]); const cfg = $derived(inputSizeConfig[size]);
const styles = $derived(inputVariantConfig[variant]); const styles = $derived(inputVariantConfig[variant]);
const inputClasses = $derived(clsx( const inputClasses = $derived(cn(
'font-primary rounded-none outline-none transition-all duration-200', 'font-primary rounded-none outline-none transition-all duration-200',
'text-neutral-900 dark:text-neutral-100', 'text-neutral-900 dark:text-neutral-100',
'placeholder:text-neutral-400 dark:placeholder:text-neutral-600', 'placeholder:text-neutral-400 dark:placeholder:text-neutral-600',
@@ -107,8 +107,8 @@ const inputClasses = $derived(clsx(
)); ));
</script> </script>
<div class={clsx('flex flex-col gap-1', fullWidth && 'w-full')}> <div class={cn('flex flex-col gap-1', fullWidth && 'w-full')}>
<div class={clsx('relative group', fullWidth && 'w-full')}> <div class={cn('relative group', fullWidth && 'w-full')}>
<!-- Left icon slot --> <!-- Left icon slot -->
{#if leftIcon} {#if leftIcon}
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 dark:text-neutral-600 pointer-events-none z-10 flex items-center"> <div class="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 dark:text-neutral-600 pointer-events-none z-10 flex items-center">
@@ -147,7 +147,7 @@ const inputClasses = $derived(clsx(
<!-- Helper / error text --> <!-- Helper / error text -->
{#if helperText} {#if helperText}
<span <span
class={clsx( class={cn(
'text-2xs font-mono tracking-wide px-1', 'text-2xs font-mono tracking-wide px-1',
error ? 'text-brand ' : 'text-secondary', error ? 'text-brand ' : 'text-secondary',
)} )}
+2 -2
View File
@@ -3,7 +3,7 @@
Inline monospace label. The base primitive for all micrographic text. Inline monospace label. The base primitive for all micrographic text.
--> -->
<script lang="ts"> <script lang="ts">
import clsx from 'clsx'; import { cn } from '$shared/lib';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { import {
type LabelFont, type LabelFont,
@@ -72,7 +72,7 @@ let {
</script> </script>
<span <span
class={clsx( class={cn(
'font-mono tracking-widest leading-none', 'font-mono tracking-widest leading-none',
'inline-flex items-center gap-1.5', 'inline-flex items-center gap-1.5',
font === 'primary' && 'font-primary tracking-tight', font === 'primary' && 'font-primary tracking-tight',
+96
View File
@@ -0,0 +1,96 @@
<script module lang="ts">
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',
},
},
});
</script>
<Story
name="Default"
args={{
href: 'https://fonts.google.com',
target: '_blank',
}}
>
{#snippet template(args: ComponentProps<typeof Link>)}
<Link {...args}>
<span>Google Fonts</span>
</Link>
{/snippet}
</Story>
<Story
name="With Icon"
args={{
href: 'https://fonts.google.com',
target: '_blank',
}}
>
{#snippet template(args: ComponentProps<typeof Link>)}
<Link {...args}>
<span>Google Fonts</span>
{#snippet icon()}
<ArrowUpRightIcon
size={10}
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
/>
{/snippet}
</Link>
{/snippet}
</Story>
<Story name="Multiple Links">
{#snippet template()}
<div class="flex gap-4 p-8 bg-neutral-100 dark:bg-neutral-900 rounded-lg">
<Link href="https://fonts.google.com" target="_blank">
<span>Google Fonts</span>
{#snippet icon()}
<ArrowUpRightIcon
size={10}
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
/>
{/snippet}
</Link>
<Link href="https://www.fontshare.com" target="_blank">
<span>Fontshare</span>
{#snippet icon()}
<ArrowUpRightIcon
size={10}
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
/>
{/snippet}
</Link>
<Link href="https://github.com" target="_blank">
<span>GitHub</span>
{#snippet icon()}
<ArrowUpRightIcon
size={10}
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
/>
{/snippet}
</Link>
</div>
{/snippet}
</Story>
@@ -1,21 +1,22 @@
<!-- <!--
Component: FooterLink Component: Link
Standard footer link with arrow icon and hover effects. A styled link component based on the footer link design.
Supports optional icon snippet and standard anchor attributes.
--> -->
<script lang="ts"> <script lang="ts">
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right'; import { cn } from '$shared/lib';
import clsx from 'clsx'; import type { Snippet } from 'svelte';
import type { HTMLAnchorAttributes } from 'svelte/elements'; import type { HTMLAnchorAttributes } from 'svelte/elements';
interface Props extends HTMLAnchorAttributes { interface Props extends HTMLAnchorAttributes {
/** /**
* Link text * Link content
*/ */
text: string; children?: Snippet;
/** /**
* Link URL * Optional icon snippet
*/ */
href: string; icon?: Snippet;
/** /**
* CSS classes * CSS classes
*/ */
@@ -23,16 +24,15 @@ interface Props extends HTMLAnchorAttributes {
} }
let { let {
text, children,
href, icon,
class: className, class: className,
...rest ...rest
}: Props = $props(); }: Props = $props();
</script> </script>
<a <a
{href} class={cn(
class={clsx(
'group inline-flex items-center gap-1 text-2xs font-mono uppercase tracking-wider-mono', 'group inline-flex items-center gap-1 text-2xs font-mono uppercase tracking-wider-mono',
'text-neutral-400 hover:text-brand transition-colors', 'text-neutral-400 hover:text-brand transition-colors',
'bg-surface/80 dark:bg-dark-bg/80 backdrop-blur-sm px-2 py-1 pointer-events-auto', 'bg-surface/80 dark:bg-dark-bg/80 backdrop-blur-sm px-2 py-1 pointer-events-auto',
@@ -40,9 +40,6 @@ let {
)} )}
{...rest} {...rest}
> >
<span>{text}</span> {@render children?.()}
<ArrowUpRightIcon {@render icon?.()}
size={10}
class="opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
/>
</a> </a>
@@ -2,29 +2,58 @@ import {
render, render,
screen, screen,
} from '@testing-library/svelte'; } 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: () => `<span>${text}</span>`,
}));
}
/**
* Helper to create an icon snippet
*/
function iconSnippet() {
return createRawSnippet(() => ({
render: () => `<svg class="lucide-arrow-up-right"></svg>`,
}));
}
describe('Link', () => {
const defaultProps = { const defaultProps = {
text: 'Google Fonts',
href: 'https://fonts.google.com', href: 'https://fonts.google.com',
}; };
describe('Rendering', () => { describe('Rendering', () => {
it('renders text content', () => { it('renders text content via children snippet', () => {
render(FooterLink, { props: defaultProps }); render(Link, {
props: {
...defaultProps,
children: textSnippet('Google Fonts'),
},
});
expect(screen.getByText('Google Fonts')).toBeInTheDocument(); expect(screen.getByText('Google Fonts')).toBeInTheDocument();
}); });
it('renders as an anchor element with correct href', () => { it('renders as an anchor element with correct href', () => {
render(FooterLink, { props: defaultProps }); render(Link, { props: defaultProps });
const link = screen.getByRole('link'); const link = screen.getByRole('link');
expect(link).toBeInTheDocument(); expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://fonts.google.com'); expect(link).toHaveAttribute('href', 'https://fonts.google.com');
}); });
it('renders the arrow icon', () => { it('renders the icon when provided via snippet', () => {
const { container } = render(FooterLink, { props: defaultProps }); const { container } = render(Link, {
props: {
...defaultProps,
children: textSnippet('Google Fonts'),
icon: iconSnippet(),
},
});
const icon = container.querySelector('svg'); const icon = container.querySelector('svg');
expect(icon).toBeInTheDocument(); expect(icon).toBeInTheDocument();
expect(icon).toHaveClass('lucide-arrow-up-right'); expect(icon).toHaveClass('lucide-arrow-up-right');
@@ -33,7 +62,7 @@ describe('FooterLink', () => {
describe('Attributes', () => { describe('Attributes', () => {
it('applies custom CSS classes', () => { it('applies custom CSS classes', () => {
render(FooterLink, { render(Link, {
props: { props: {
...defaultProps, ...defaultProps,
class: 'custom-class', class: 'custom-class',
@@ -43,7 +72,7 @@ describe('FooterLink', () => {
}); });
it('spreads additional anchor attributes', () => { it('spreads additional anchor attributes', () => {
render(FooterLink, { render(Link, {
props: { props: {
...defaultProps, ...defaultProps,
target: '_blank', target: '_blank',
+2 -2
View File
@@ -3,8 +3,8 @@
Project logo with apropriate styles Project logo with apropriate styles
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/lib';
import { Badge } from '$shared/ui'; import { Badge } from '$shared/ui';
import clsx from 'clsx';
interface Props { interface Props {
/** /**
@@ -18,7 +18,7 @@ const { class: className }: Props = $props();
const title = 'GLYPHDIFF'; const title = 'GLYPHDIFF';
</script> </script>
<div class={clsx('flex items-center gap-2 md:gap-3 select-none', className)}> <div class={cn('flex items-center gap-2 md:gap-3 select-none', className)}>
<h1 class="font-logo font-extrabold text-base md:text-xl tracking-tight text-swiss-black dark:text-neutral-200"> <h1 class="font-logo font-extrabold text-base md:text-xl tracking-tight text-swiss-black dark:text-neutral-200">
{title} {title}
</h1> </h1>
@@ -5,7 +5,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { PerspectiveManager } from '$shared/lib'; import type { PerspectiveManager } from '$shared/lib';
import clsx from 'clsx'; import { cn } from '$shared/lib';
import { type Snippet } from 'svelte'; import { type Snippet } from 'svelte';
interface Props { interface Props {
@@ -73,7 +73,7 @@ const isVisible = $derived(manager.isFront);
</script> </script>
<div <div
class={clsx('will-change-transform', className)} class={cn('will-change-transform', className)}
style:transform-style="preserve-3d" style:transform-style="preserve-3d"
style:transform={style?.transform} style:transform={style?.transform}
style:filter={style?.filter} style:filter={style?.filter}
@@ -3,8 +3,8 @@
Numbered section heading with optional subtitle and pulse dot. Numbered section heading with optional subtitle and pulse dot.
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/lib';
import { Label } from '$shared/ui'; import { Label } from '$shared/ui';
import clsx from 'clsx';
interface Props { interface Props {
/** /**
@@ -41,7 +41,7 @@ let {
const indexStr = $derived(String(index).padStart(2, '0')); const indexStr = $derived(String(index).padStart(2, '0'));
</script> </script>
<div class={clsx('flex items-center gap-3 md:gap-4 mb-2', className)}> <div class={cn('flex items-center gap-3 md:gap-4 mb-2', className)}>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if pulse} {#if pulse}
<span class="w-1.5 h-1.5 bg-brand rounded-full animate-pulse"></span> <span class="w-1.5 h-1.5 bg-brand rounded-full animate-pulse"></span>
@@ -3,7 +3,7 @@
A horizontal separator line used to visually separate sections within a page. A horizontal separator line used to visually separate sections within a page.
--> -->
<script lang="ts"> <script lang="ts">
import clsx from 'clsx'; import { cn } from '$shared/lib';
interface Props { interface Props {
/** /**
@@ -15,4 +15,4 @@ interface Props {
const { class: className = '' }: Props = $props(); const { class: className = '' }: Props = $props();
</script> </script>
<div class={clsx('w-full h-px bg-swiss-black/5 dark:bg-white/10 my-8 md:my-12', className)}></div> <div class={cn('w-full h-px bg-swiss-black/5 dark:bg-white/10 my-8 md:my-12', className)}></div>
@@ -4,7 +4,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import clsx from 'clsx'; import { cn } from '$shared/lib';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
@@ -79,7 +79,7 @@ function close() {
The inner div stays w-80 so Sidebar layout never reflows mid-animation. The inner div stays w-80 so Sidebar layout never reflows mid-animation.
--> -->
<div <div
class={clsx( class={cn(
'shrink-0 z-30 h-full relative', 'shrink-0 z-30 h-full relative',
'overflow-hidden', 'overflow-hidden',
'will-change-[width]', 'will-change-[width]',
+2 -2
View File
@@ -3,7 +3,7 @@
Generic loading placeholder with shimmer animation. Generic loading placeholder with shimmer animation.
--> -->
<script lang="ts"> <script lang="ts">
import clsx from 'clsx'; import { cn } from '$shared/lib';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
interface Props extends HTMLAttributes<HTMLDivElement> { interface Props extends HTMLAttributes<HTMLDivElement> {
@@ -18,7 +18,7 @@ let { class: className, animate = true, ...rest }: Props = $props();
</script> </script>
<div <div
class={clsx( class={cn(
'rounded-md bg-background-subtle/50 backdrop-blur-sm', 'rounded-md bg-background-subtle/50 backdrop-blur-sm',
animate && 'animate-pulse', animate && 'animate-pulse',
className, className,
+2 -2
View File
@@ -3,8 +3,8 @@
A single key:value pair in Space Mono. Optional trailing divider. A single key:value pair in Space Mono. Optional trailing divider.
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/lib';
import { Label } from '$shared/ui'; import { Label } from '$shared/ui';
import clsx from 'clsx';
import type { ComponentProps } from 'svelte'; import type { ComponentProps } from 'svelte';
interface Props extends Pick<ComponentProps<typeof Label>, 'variant'> { interface Props extends Pick<ComponentProps<typeof Label>, 'variant'> {
@@ -36,7 +36,7 @@ let {
}: Props = $props(); }: Props = $props();
</script> </script>
<div class={clsx('flex items-center gap-1', className)}> <div class={cn('flex items-center gap-1', className)}>
<Label variant="muted" size="xs">{label}:</Label> <Label variant="muted" size="xs">{label}:</Label>
<Label {variant} size="xs" bold>{value}</Label> <Label {variant} size="xs" bold>{value}</Label>
</div> </div>
+2 -2
View File
@@ -3,8 +3,8 @@
Renders multiple Stat components in a row with auto-separators. Renders multiple Stat components in a row with auto-separators.
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/lib';
import { Stat } from '$shared/ui'; import { Stat } from '$shared/ui';
import clsx from 'clsx';
import type { ComponentProps } from 'svelte'; import type { ComponentProps } from 'svelte';
interface StatItem extends Partial<Pick<ComponentProps<typeof Stat>, 'variant'>> { interface StatItem extends Partial<Pick<ComponentProps<typeof Stat>, 'variant'>> {
@@ -26,7 +26,7 @@ interface Props {
let { stats, class: className }: Props = $props(); let { stats, class: className }: Props = $props();
</script> </script>
<div class={clsx('flex items-center gap-4', className)}> <div class={cn('flex items-center gap-4', className)}>
{#each stats as stat, i} {#each stats as stat, i}
<Stat <Stat
label={stat.label} label={stat.label}
+2 -2
View File
@@ -3,13 +3,13 @@
Monospace <code> element for technical values, measurements, identifiers. Monospace <code> element for technical values, measurements, identifiers.
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/lib';
import { import {
type LabelSize, type LabelSize,
type LabelVariant, type LabelVariant,
labelSizeConfig, labelSizeConfig,
labelVariantConfig, labelVariantConfig,
} from '$shared/ui/Label/config'; } from '$shared/ui/Label/config';
import clsx from 'clsx';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
@@ -42,7 +42,7 @@ let {
</script> </script>
<code <code
class={clsx( class={cn(
'font-mono tracking-tight tabular-nums', 'font-mono tracking-tight tabular-nums',
labelSizeConfig[size], labelSizeConfig[size],
labelVariantConfig[variant], labelVariantConfig[variant],
+3 -3
View File
@@ -10,8 +10,8 @@
--> -->
<script lang="ts" generics="T"> <script lang="ts" generics="T">
import { createVirtualizer } from '$shared/lib'; import { createVirtualizer } from '$shared/lib';
import { cn } from '$shared/lib';
import { throttle } from '$shared/lib/utils'; import { throttle } from '$shared/lib/utils';
import clsx from 'clsx';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
@@ -324,13 +324,13 @@ $effect(() => {
{/snippet} {/snippet}
{#if useWindowScroll} {#if useWindowScroll}
<div class={clsx('relative w-full', className)} bind:this={viewportRef} {...rest}> <div class={cn('relative w-full', className)} bind:this={viewportRef} {...rest}>
{@render content()} {@render content()}
</div> </div>
{:else} {:else}
<div <div
bind:this={viewportRef} bind:this={viewportRef}
class={clsx( class={cn(
'relative overflow-y-auto overflow-x-hidden', 'relative overflow-y-auto overflow-x-hidden',
'rounded-md bg-background', 'rounded-md bg-background',
'w-full', 'w-full',
+6 -6
View File
@@ -52,12 +52,6 @@ export {
*/ */
default as FilterGroup, default as FilterGroup,
} from './FilterGroup/FilterGroup.svelte'; } from './FilterGroup/FilterGroup.svelte';
export {
/**
* Standard footer link with arrow icon and hover effects
*/
default as FooterLink,
} from './FooterLink/FooterLink.svelte';
export { export {
/** /**
* Small text for secondary meta-information * Small text for secondary meta-information
@@ -76,6 +70,12 @@ export {
*/ */
default as Label, default as Label,
} from './Label/Label.svelte'; } from './Label/Label.svelte';
export {
/**
* Styled link with optional icon
*/
default as Link,
} from './Link/Link.svelte';
export { export {
/** /**
* Full-page or component-level progress spinner * Full-page or component-level progress spinner
@@ -4,7 +4,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import { typographySettingsStore } from '$features/SetupFont'; import { typographySettingsStore } from '$features/SetupFont';
import clsx from 'clsx'; import { cn } from '$shared/lib';
import { comparisonStore } from '../../model'; import { comparisonStore } from '../../model';
interface Props { interface Props {
@@ -53,7 +53,7 @@ $effect(() => {
> >
{#each [0, 1] as s (s)} {#each [0, 1] as s (s)}
<span <span
class={clsx( class={cn(
'char-inner', 'char-inner',
'transition-colors duration-300', 'transition-colors duration-300',
isPast isPast
@@ -5,6 +5,7 @@
<script lang="ts"> <script lang="ts">
import { ThemeSwitch } from '$features/ChangeAppTheme'; import { ThemeSwitch } from '$features/ChangeAppTheme';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import { import {
Divider, Divider,
IconButton, IconButton,
@@ -15,7 +16,6 @@ import {
} from '$shared/ui'; } from '$shared/ui';
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close'; import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open'; import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
import clsx from 'clsx';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { comparisonStore } from '../../model'; import { comparisonStore } from '../../model';
@@ -48,7 +48,7 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');
</script> </script>
<header <header
class={clsx( class={cn(
'flex items-center justify-between', 'flex items-center justify-between',
'px-4 md:px-8 py-4 md:py-6', 'px-4 md:px-8 py-4 md:py-6',
'h-16 md:h-20 z-20', 'h-16 md:h-20 z-20',
@@ -5,12 +5,12 @@
Content (font list, controls) is injected via snippets. Content (font list, controls) is injected via snippets.
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/lib';
import { import {
ButtonGroup, ButtonGroup,
Label, Label,
ToggleButton, ToggleButton,
} from '$shared/ui'; } from '$shared/ui';
import clsx from 'clsx';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { import {
type Side, type Side,
@@ -40,7 +40,7 @@ let {
</script> </script>
<div <div
class={clsx( class={cn(
'flex flex-col h-full', 'flex flex-col h-full',
'w-80', 'w-80',
'bg-surface dark:bg-dark-bg', 'bg-surface dark:bg-dark-bg',
@@ -14,11 +14,11 @@ import {
type ResponsiveManager, type ResponsiveManager,
debounce, debounce,
} from '$shared/lib'; } from '$shared/lib';
import { cn } from '$shared/lib';
import { import {
CharacterComparisonEngine, CharacterComparisonEngine,
} from '$shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte'; } from '$shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte';
import { Loader } from '$shared/ui'; import { Loader } from '$shared/ui';
import clsx from 'clsx';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { Spring } from 'svelte/motion'; import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -61,6 +61,26 @@ const comparisonEngine = new CharacterComparisonEngine();
let layoutResult = $state<ReturnType<typeof comparisonEngine.layout>>({ lines: [], totalHeight: 0 }); let layoutResult = $state<ReturnType<typeof comparisonEngine.layout>>({ 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, { const sliderSpring = new Spring(50, {
stiffness: 0.2, stiffness: 0.2,
damping: 0.7, damping: 0.7,
@@ -124,25 +144,25 @@ $effect(() => {
} }
}); });
// Layout effect — depends on content, settings AND containerWidth
$effect(() => { $effect(() => {
const _text = comparisonStore.text; const _text = comparisonStore.text;
const _weight = typography.weight; const _weight = typography.weight;
const _size = typography.renderedSize; const _size = typography.renderedSize;
const _height = typography.height; const _height = typography.height;
const _spacing = typography.spacing; 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" // PRETEXT API strings: "weight sizepx family"
const fontAStr = getPretextFontString(_weight, _size, fontA.name); const fontAStr = getPretextFontString(_weight, _size, fontA.name);
const fontBStr = getPretextFontString(_weight, _size, fontB.name); const fontBStr = getPretextFontString(_weight, _size, fontB.name);
// Use offsetWidth to avoid transform scaling issues const padding = _isMobile ? 48 : 96;
const width = container.offsetWidth; const availableWidth = Math.max(0, _width - padding);
const padding = isMobile ? 48 : 96;
const availableWidth = width - padding;
const lineHeight = _size * _height; const lineHeight = _size * _height;
containerWidth = width;
layoutResult = comparisonEngine.layout( layoutResult = comparisonEngine.layout(
_text, _text,
fontAStr, 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. // Dynamic backgroundSize based on isMobile — can't express this in Tailwind.
// Color is set to currentColor so it respects dark mode via text color. // Color is set to currentColor so it respects dark mode via text color.
const gridStyle = $derived( const gridStyle = $derived(
@@ -198,10 +194,10 @@ const scaleClass = $derived(
Outer flex container — fills parent. Outer flex container — fills parent.
The paper div inside scales down when the sidebar opens on desktop. The paper div inside scales down when the sidebar opens on desktop.
--> -->
<div class={clsx('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}> <div class={cn('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}>
<!-- Paper surface --> <!-- Paper surface -->
<div <div
class={clsx( class={cn(
'w-full h-full flex flex-col items-center justify-center relative', 'w-full h-full flex flex-col items-center justify-center relative',
'bg-paper dark:bg-dark-card', 'bg-paper dark:bg-dark-card',
'shadow-2xl shadow-black/5 dark:shadow-black/20', 'shadow-2xl shadow-black/5 dark:shadow-black/20',
@@ -270,11 +266,11 @@ const scaleClass = $derived(
<TypographyMenu <TypographyMenu
bind:open={isTypographyMenuOpen} bind:open={isTypographyMenuOpen}
class={clsx( class={cn(
'absolute z-50', 'absolute z-10',
responsive.isMobileOrTablet responsive.isMobileOrTablet
? 'bottom-4 right-4 -translate-1/2' ? 'bottom-0 right-0 -translate-1/2'
: 'bottom-5 left-1/2 right-[unset] -translate-x-1/2', : 'bottom-2.5 left-1/2 -translate-x-1/2',
)} )}
/> />
</div> </div>
@@ -4,7 +4,7 @@
1px red vertical rule with square handles at top and bottom. 1px red vertical rule with square handles at top and bottom.
--> -->
<script lang="ts"> <script lang="ts">
import clsx from 'clsx'; import { cn } from '$shared/lib';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -31,7 +31,7 @@ let { sliderPos, isDragging }: Props = $props();
> >
<!-- Top handle --> <!-- Top handle -->
<div <div
class={clsx( class={cn(
'w-5 h-5 md:w-6 md:h-6', 'w-5 h-5 md:w-6 md:h-6',
'-ml-2.5 md:-ml-3', '-ml-2.5 md:-ml-3',
'mt-2 md:mt-4', 'mt-2 md:mt-4',
@@ -47,7 +47,7 @@ let { sliderPos, isDragging }: Props = $props();
<!-- Bottom handle --> <!-- Bottom handle -->
<div <div
class={clsx( class={cn(
'w-5 h-5 md:w-6 md:h-6', 'w-5 h-5 md:w-6 md:h-6',
'-ml-2.5 md:-ml-3', '-ml-2.5 md:-ml-3',
'mb-2 md:mb-4', 'mb-2 md:mb-4',
@@ -5,8 +5,8 @@
<script lang="ts"> <script lang="ts">
import { NavigationWrapper } from '$entities/Breadcrumb'; import { NavigationWrapper } from '$entities/Breadcrumb';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import { Section } from '$shared/ui'; import { Section } from '$shared/ui';
import clsx from 'clsx';
import { import {
getContext, getContext,
untrack, untrack,
@@ -38,7 +38,7 @@ $effect(() => {
headerAction={registerAction} headerAction={registerAction}
> >
{#snippet content({ className })} {#snippet content({ className })}
<div class={clsx(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}> <div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
<FontSearch bind:showFilters={isExpanded} /> <FontSearch bind:showFilters={isExpanded} />
</div> </div>
{/snippet} {/snippet}
+1 -1
View File
@@ -1 +1 @@
export { default as Footer } from './ui/Footer.svelte'; export { default as Footer } from './ui/Footer/Footer.svelte';
-36
View File
@@ -1,36 +0,0 @@
<!--
Widget: Footer
Application footer with project information and portfolio link.
Visible only on desktop screens.
-->
<script lang="ts">
import type { ResponsiveManager } from '$shared/lib/helpers';
import { FooterLink } from '$shared/ui';
import { getContext } from 'svelte';
const responsive = getContext<ResponsiveManager>('responsive');
const currentYear = new Date().getFullYear();
</script>
{#if responsive?.isDesktop || responsive?.isDesktopLarge}
<footer class="fixed bottom-5 right-5 z-50 flex flex-col items-end gap-1 pointer-events-none">
<!-- Portfolio Link (Vertical) -->
<div class="pointer-events-auto">
<FooterLink
text="allmy.work"
href="https://allmy.work/"
target="_blank"
rel="noopener noreferrer"
class="border border-subtle"
/>
</div>
<!-- Project Name (Horizontal) -->
<div class="pointer-events-auto flex items-center gap-2 bg-surface/80 dark:bg-dark-bg/80 backdrop-blur-sm px-3 py-1 border border-subtle">
<div class="w-1.5 h-1.5 bg-brand"></div>
<span class="text-2xs font-mono uppercase tracking-wider-mono text-neutral-500 dark:text-neutral-400">
GlyphDiff © 2025 — {currentYear}
</span>
</div>
</footer>
{/if}
@@ -0,0 +1,44 @@
<!--
Widget: Footer
Application footer with project information and portfolio link.
Visible only on desktop screens.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import type { ResponsiveManager } from '$shared/lib/helpers';
import { getContext } from 'svelte';
import FooterLink from '../FooterLink/FooterLink.svelte';
const responsive = getContext<ResponsiveManager>('responsive');
const isVertical = $derived(responsive?.isDesktop || responsive?.isDesktopLarge);
const currentYear = new Date().getFullYear();
</script>
<footer
class={cn(
'fixed z-10 flex flex-row items-end gap-1 pointer-events-none',
isVertical ? 'bottom-2.5 right-2.5 [writing-mode:vertical-rl] rotate-180' : 'bottom-4 left-4',
)}
>
<!-- Project Name (Horizontal) -->
{#if isVertical}
<div class="pointer-events-auto items-center gap-2 bg-surface/80 dark:bg-dark-bg/80 backdrop-blur-sm px-3 py-1 border border-subtle">
<div class="w-1.5 h-1.5 bg-brand"></div>
<span class="text-2xs font-mono uppercase tracking-wider-mono text-neutral-500 dark:text-neutral-400">
GlyphDiff © 2025 — {currentYear}
</span>
</div>
{/if}
<!-- Portfolio Link (Vertical) -->
<div class="pointer-events-auto">
<FooterLink
text="allmy.work"
href="https://allmy.work/"
target="_blank"
rel="noopener noreferrer"
class={cn('border border-subtle', isVertical ? 'text-2xs' : 'text-4xs')}
iconClass={isVertical ? 'rotate-90' : ''}
/>
</div>
</footer>
@@ -0,0 +1,55 @@
<!--
Component: FooterLink
Specific footer link implementation that uses the generic Link component
and adds the default arrow icon.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import { Link } from '$shared/ui';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import type { HTMLAnchorAttributes } from 'svelte/elements';
interface Props extends HTMLAnchorAttributes {
/**
* Link text
*/
text: string;
/**
* CSS classes for the default icon
*/
iconClass?: string;
/**
* Link URL
*/
href: string;
/**
* CSS classes
*/
class?: string;
}
let {
text,
iconClass,
href,
class: className,
...rest
}: Props = $props();
</script>
<Link
{href}
class={className}
{...rest}
>
<span>{text}</span>
{#snippet icon()}
<ArrowUpRightIcon
size={10}
class={cn(
'fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200',
iconClass,
)}
/>
{/snippet}
</Link>
@@ -6,11 +6,11 @@
import { NavigationWrapper } from '$entities/Breadcrumb'; import { NavigationWrapper } from '$entities/Breadcrumb';
import { fontStore } from '$entities/Font'; import { fontStore } from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import { import {
Label, Label,
Section, Section,
} from '$shared/ui'; } from '$shared/ui';
import clsx from 'clsx';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { layoutManager } from '../../model'; import { layoutManager } from '../../model';
import LayoutSwitch from '../LayoutSwitch/LayoutSwitch.svelte'; import LayoutSwitch from '../LayoutSwitch/LayoutSwitch.svelte';
@@ -50,7 +50,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
{/snippet} {/snippet}
{#snippet content({ className })} {#snippet content({ className })}
<div class={clsx(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}> <div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
<SampleList /> <SampleList />
</div> </div>
{/snippet} {/snippet}