34 Commits

Author SHA1 Message Date
Ilia Mashkov c01fc79a3e fix: add scrollMargin property since the IntersectionObserver has it 2026-05-05 17:04:23 +03:00
Ilia Mashkov 6bfa7ca777 chore: add .css files declaration 2026-05-05 17:03:43 +03:00
Ilia Mashkov 0d4356b8f1 chore: remove @ts-expect-error since scheduler was added in new TS release 2026-05-05 17:03:18 +03:00
Ilia Mashkov c18574d4c3 fix: remove deprecated tsconfig property 2026-05-05 17:02:25 +03:00
Ilia Mashkov 1c9a7f9fe1 chore: add .vscode to .gitignore 2026-05-05 16:49:56 +03:00
Ilia Mashkov fae6694479 chore(dprint): update markup_fmt plugin version, fix @render indentation and add couple of new rules 2026-05-05 16:49:27 +03:00
Ilia Mashkov a105c94176 chore: upgrade svelte-language-server to 0.18.0 2026-05-05 15:34:38 +03:00
Ilia Mashkov 77c2b27f8b chore: update remaining outdated packages (@chenglou/pretext 0.0.6, svelte-check 4.4.8) 2026-05-05 15:34:38 +03:00
Ilia Mashkov 1ce0d6c66f chore: upgrade tooling and ecosystem (jsdom 29, playwright 1.59.1, storybook 10.3.6) 2026-05-05 15:34:33 +03:00
Ilia Mashkov 6c20a68e19 chore: upgrade core build tooling (vite 8, svelte plugin 7, typescript 6) 2026-05-05 15:34:27 +03:00
Ilia Mashkov 3894912a22 feat(FontList): add a small gap for elements of ComparisonView sidebar font list 2026-05-05 12:05:19 +03:00
Ilia Mashkov e8d3727c6a feat: upgrade lucide icons to 1.14 2026-05-05 10:10:11 +03:00
Ilia Mashkov 5fbf090b24 fix(Footer): minor layout change 2026-05-05 10:06:30 +03:00
ilia a94e1f8b65 Merge pull request 'feat(shared): add cn utility for tailwind-aware class merging' (#38) from feature/minor-improvements into main
Workflow / build (push) Successful in 1m35s
Workflow / publish (push) Successful in 22s
Reviewed-on: #38
2026-04-23 12:11:02 +00:00
Ilia Mashkov f8ba2d7eb0 chore(Footer): move components to separate directories
Workflow / build (pull_request) Successful in 1m42s
Workflow / publish (pull_request) Has been skipped
2026-04-23 14:59:33 +03:00
Ilia Mashkov 3594033bcb feat(FooterLink): move FooterLink to the Footer widget layer, delete the one in shared/ui 2026-04-23 14:59:33 +03:00
Ilia Mashkov 2ae24912f7 feat(Footer): tweak the footer position 2026-04-23 14:59:32 +03:00
Ilia Mashkov 877719f106 feat(Link): create reusable Link ui component 2026-04-23 14:59:32 +03:00
Ilia Mashkov 4eafb96d35 feat(ComparisonView): replace window resize listener with ResiseObserver on the container to catch the container size change on sidebar open/close 2026-04-23 14:59:32 +03:00
Ilia Mashkov 652dfa5c90 feat: brand colored text selection 2026-04-23 14:59:32 +03:00
Ilia Mashkov 54087b7b2a feat: replace clsx with cn util 2026-04-23 14:59:32 +03:00
Ilia Mashkov cffebf05e3 feat(SliderArea): tweak the styles 2026-04-23 14:59:32 +03:00
Ilia Mashkov ada484e2e0 feat(FooterLink): tweak the styles 2026-04-23 14:59:32 +03:00
Ilia Mashkov dbcc1caeb0 feat(Footer): change the footer styles and layout to avoid overlapping with the TypographyMenu 2026-04-23 14:59:32 +03:00
Ilia Mashkov 2c579a3336 feat(shared): add cn utility for tailwind-aware class merging 2026-04-23 14:59:32 +03:00
Ilia Mashkov fe0d4e7daa fix: workflow
Workflow / build (push) Successful in 1m40s
Workflow / publish (push) Successful in 46s
2026-04-23 14:52:11 +03:00
Ilia Mashkov 108df323f9 test: add timeout to fail the test instead of OOM 2026-04-23 14:16:06 +03:00
Ilia Mashkov 2803bcd22c fix(createVirtualizer): add window check to resolve the ReferenceError 2026-04-23 14:16:06 +03:00
ilia 47a8487ce9 Merge pull request 'chore(SetupFont): rename controlManager to typographySettingsStore for better semantic' (#37) from feature/united-widget into main
Workflow / publish (push) Has been cancelled
Workflow / build (push) Has been cancelled
Reviewed-on: #37
2026-04-22 10:04:37 +00:00
Ilia Mashkov 1d5af5ea70 feat(Layout): add footer to layout 2026-04-22 13:01:46 +03:00
Ilia Mashkov 2221ecad4c feat(Footer): create Footer widget with project name and portfolio link 2026-04-22 13:01:16 +03:00
Ilia Mashkov cd8599d5b5 feat(Layout): add new favicon 2026-04-22 13:00:29 +03:00
Ilia Mashkov 6c91d570ec chore: remove usused code 2026-04-22 12:31:35 +03:00
Ilia Mashkov 91b80a5ada feat(ui): add FooterLink component 2026-04-22 12:31:02 +03:00
63 changed files with 1893 additions and 1204 deletions
+2 -1
View File
@@ -47,7 +47,8 @@ jobs:
run: yarn test:unit run: yarn test:unit
- name: Run Component Tests - name: Run Component Tests
run: yarn test:component timeout-minutes: 5
run: yarn test:component --reporter=verbose --logHeapUsage
publish: publish:
needs: build # Only runs if tests/lint pass needs: build # Only runs if tests/lint pass
+3
View File
@@ -10,6 +10,9 @@ node_modules
/build /build
/dist /dist
# IDE settings
.vscode
# Git worktrees (isolated development branches) # Git worktrees (isolated development branches)
.worktrees .worktrees
+4 -5
View File
@@ -13,7 +13,7 @@
"https://plugins.dprint.dev/typescript-0.93.0.wasm", "https://plugins.dprint.dev/typescript-0.93.0.wasm",
"https://plugins.dprint.dev/json-0.19.3.wasm", "https://plugins.dprint.dev/json-0.19.3.wasm",
"https://plugins.dprint.dev/markdown-0.17.8.wasm", "https://plugins.dprint.dev/markdown-0.17.8.wasm",
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm" "https://plugins.dprint.dev/g-plane/markup_fmt-v0.27.0.wasm"
], ],
"typescript": { "typescript": {
"lineWidth": 120, "lineWidth": 120,
@@ -57,9 +57,8 @@
"quotes": "double", "quotes": "double",
"scriptIndent": false, "scriptIndent": false,
"styleIndent": false, "styleIndent": false,
"formatComments": true,
"vBindStyle": "short", "svelteAttrShorthand": true,
"vOnStyle": "short", "svelteDirectiveShorthand": true
"formatComments": true
} }
} }
+33 -33
View File
@@ -27,45 +27,45 @@
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^4.1.3", "@chromatic-com/storybook": "5.1.2",
"@internationalized/date": "^3.10.0", "@internationalized/date": "3.12.1",
"@lucide/svelte": "^0.561.0", "@lucide/svelte": "^1.14.0",
"@playwright/test": "^1.57.0", "@playwright/test": "1.59.1",
"@storybook/addon-a11y": "^10.1.11", "@storybook/addon-a11y": "10.3.6",
"@storybook/addon-docs": "^10.1.11", "@storybook/addon-docs": "10.3.6",
"@storybook/addon-svelte-csf": "^5.0.10", "@storybook/addon-svelte-csf": "5.1.2",
"@storybook/addon-vitest": "^10.1.11", "@storybook/addon-vitest": "10.3.6",
"@storybook/svelte-vite": "^10.1.11", "@storybook/svelte-vite": "10.3.6",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "7.1.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "4.2.4",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1", "@testing-library/svelte": "^5.3.1",
"@tsconfig/svelte": "^5.0.6", "@tsconfig/svelte": "5.0.8",
"@types/jsdom": "^27", "@types/jsdom": "28.0.1",
"@vitest/browser-playwright": "^4.0.16", "@vitest/browser-playwright": "4.1.5",
"@vitest/coverage-v8": "^4.0.16", "@vitest/coverage-v8": "4.1.5",
"bits-ui": "^2.14.4", "bits-ui": "2.18.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dprint": "^0.50.2", "dprint": "0.54.0",
"jsdom": "^27.4.0", "jsdom": "29.1.1",
"lefthook": "^2.0.13", "lefthook": "2.1.6",
"oxlint": "^1.35.0", "oxlint": "1.62.0",
"playwright": "^1.57.0", "playwright": "1.59.1",
"storybook": "^10.1.11", "storybook": "10.3.6",
"svelte": "^5.45.6", "svelte": "5.55.5",
"svelte-check": "^4.3.4", "svelte-check": "4.4.8",
"svelte-language-server": "^0.17.23", "svelte-language-server": "0.18.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "3.5.0",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.18", "tailwindcss": "4.2.4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "6.0.3",
"vite": "^7.2.6", "vite": "8.0.10",
"vitest": "^4.0.16", "vitest": "4.1.5",
"vitest-browser-svelte": "^2.0.1" "vitest-browser-svelte": "2.1.1"
}, },
"dependencies": { "dependencies": {
"@chenglou/pretext": "^0.0.5", "@chenglou/pretext": "0.0.6",
"@tanstack/svelte-query": "^6.0.14" "@tanstack/svelte-query": "6.1.28"
} }
} }
+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
View File
@@ -36,6 +36,8 @@ declare module '*.jpg' {
export default content; export default content;
} }
declare module '*.css';
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
+9 -17
View File
@@ -3,21 +3,12 @@
Application shell with providers and page wrapper Application shell with providers and page wrapper
--> -->
<script lang="ts"> <script lang="ts">
/**
* Layout Component
*
* Root layout wrapper that provides the application shell structure. Handles favicon,
* toolbar provider initialization, and renders child routes with consistent structure.
*
* Layout structure:
* - Header area (currently empty, reserved for future use)
*
* - Footer area (currently empty, reserved for future use)
*/
import { themeManager } from '$features/ChangeAppTheme'; import { themeManager } from '$features/ChangeAppTheme';
import GD from '$shared/assets/GD.svg'; import G from '$shared/assets/G.svg';
import { ResponsiveProvider } from '$shared/lib'; import { ResponsiveProvider } from '$shared/lib';
import clsx from 'clsx'; import { cn } from '$shared/lib';
import { Footer } from '$widgets/Footer';
import { import {
type Snippet, type Snippet,
onDestroy, onDestroy,
@@ -40,7 +31,7 @@ onDestroy(() => themeManager.destroy());
</script> </script>
<svelte:head> <svelte:head>
<link rel="icon" href={GD} /> <link rel="icon" href={G} type="image/svg+xml" />
<link rel="preconnect" href="https://api.fontshare.com" /> <link rel="preconnect" href="https://api.fontshare.com" />
<link <link
@@ -82,14 +73,15 @@ 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', 'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg relative',
theme === 'dark' ? 'dark' : '', theme === 'dark' ? 'dark' : '',
)} )}
> >
{#if fontsReady} {#if fontsReady}
{@render children?.()} {@render children?.()}
{/if} {/if}
<footer></footer>
<Footer />
</div> </div>
</ResponsiveProvider> </ResponsiveProvider>
@@ -20,6 +20,7 @@ let mockObserverInstances: MockIntersectionObserver[] = [];
class MockIntersectionObserver implements IntersectionObserver { class MockIntersectionObserver implements IntersectionObserver {
root = null; root = null;
rootMargin = ''; rootMargin = '';
scrollMargin = '';
thresholds: number[] = []; thresholds: number[] = [];
readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = []; readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = [];
readonly observedElements = new Set<Element>(); readonly observedElements = new Set<Element>();
@@ -2,9 +2,7 @@
* Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback. * Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback.
*/ */
export async function yieldToMainThread(): Promise<void> { export async function yieldToMainThread(): Promise<void> {
// @ts-expect-error - scheduler not in TypeScript lib yet
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) { if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
await scheduler.yield(); await scheduler.yield();
} else { } else {
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
@@ -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',
+3
View File
@@ -0,0 +1,3 @@
<svg width="103" height="87" viewBox="0 0 103 87" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M50.688 86.144C43.008 86.144 36.0533 85.248 29.824 83.456C23.68 81.664 18.3467 78.976 13.824 75.392C9.38667 71.808 5.97333 67.3707 3.584 62.08C1.19467 56.7893 0 50.688 0 43.776C0 36.7787 1.23733 30.592 3.712 25.216C6.272 19.7547 9.856 15.1467 14.464 11.392C19.1573 7.63733 24.704 4.82133 31.104 2.944C37.5893 0.981333 44.7573 0 52.608 0C61.9093 0 69.9307 1.32267 76.672 3.968C83.4133 6.528 88.704 10.1547 92.544 14.848C96.4693 19.5413 98.688 25.1307 99.2 31.616H82.816C81.7067 28.2027 79.872 25.2587 77.312 22.784C74.8373 20.224 71.552 18.2613 67.456 16.896C63.36 15.4453 58.4107 14.72 52.608 14.72C45.184 14.72 38.8267 15.9147 33.536 18.304C28.3307 20.6933 24.3627 24.064 21.632 28.416C18.9013 32.768 17.536 37.888 17.536 43.776C17.536 49.4933 18.7307 54.4427 21.12 58.624C23.5093 62.72 27.1787 65.8773 32.128 68.096C37.1627 70.3147 43.5627 71.424 51.328 71.424C57.3013 71.424 62.5493 70.656 67.072 69.12C71.68 67.4987 75.52 65.3653 78.592 62.72C81.664 59.9893 83.84 56.96 85.12 53.632L91.776 51.2C90.6667 62.208 86.4853 70.784 79.232 76.928C72.064 83.072 62.5493 86.144 50.688 86.144ZM87.424 84.48C87.424 81.8347 87.5947 78.8053 87.936 75.392C88.2773 71.8933 88.704 68.3947 89.216 64.896C89.728 61.312 90.1973 58.0267 90.624 55.04H52.736V44.16H102.144V84.48H87.424Z" fill="#FF3B30"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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

Before

Width:  |  Height:  |  Size: 1.8 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -258,12 +258,13 @@ export function createVirtualizer<T>(
// Calculate initial offset ONCE // Calculate initial offset ONCE
const getElementOffset = () => { const getElementOffset = () => {
const rect = node.getBoundingClientRect(); const rect = node.getBoundingClientRect();
return rect.top + window.scrollY; const scrollY = typeof window !== 'undefined' ? window.scrollY : 0;
return rect.top + scrollY;
}; };
let cachedOffsetTop = 0; let cachedOffsetTop = 0;
let rafId: number | null = null; let rafId: number | null = null;
containerHeight = window.innerHeight; containerHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
const handleScroll = () => { const handleScroll = () => {
if (rafId !== null) { if (rafId !== null) {
+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',
@@ -106,7 +106,7 @@ let selected = $state(false);
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<ToggleButton <ToggleButton
{...args} {...args}
selected={selected} {selected}
onclick={() => { onclick={() => {
selected = !selected; selected = !selected;
}} }}
@@ -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"
+7 -7
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>
+45
View File
@@ -0,0 +1,45 @@
<!--
Component: Link
A styled link component based on the footer link design.
Supports optional icon snippet and standard anchor attributes.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import type { HTMLAnchorAttributes } from 'svelte/elements';
interface Props extends HTMLAnchorAttributes {
/**
* Link content
*/
children?: Snippet;
/**
* Optional icon snippet
*/
icon?: Snippet;
/**
* CSS classes
*/
class?: string;
}
let {
children,
icon,
class: className,
...rest
}: Props = $props();
</script>
<a
class={cn(
'group inline-flex items-center gap-1 text-2xs font-mono uppercase tracking-wider-mono',
'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',
className,
)}
{...rest}
>
{@render children?.()}
{@render icon?.()}
</a>
+87
View File
@@ -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: () => `<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 = {
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');
});
});
});
+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}
+1 -1
View File
@@ -93,7 +93,7 @@ const flyParams: FlyParams = {
> >
<div> <div>
{#if headerTitle} {#if headerTitle}
<SectionHeader title={headerTitle} subtitle={headerSubtitle} index={index} /> <SectionHeader title={headerTitle} subtitle={headerSubtitle} {index} />
{/if} {/if}
<SectionTitle text={title} /> <SectionTitle text={title} />
</div> </div>
@@ -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],
+15 -15
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';
@@ -288,24 +288,24 @@ $effect(() => {
> >
{#if itemIndex < items.length} {#if itemIndex < items.length}
{@render children({ {@render children({
item: items[itemIndex], item: items[itemIndex],
index: itemIndex, index: itemIndex,
isFullyVisible: row.isFullyVisible, isFullyVisible: row.isFullyVisible,
isPartiallyVisible: row.isPartiallyVisible, isPartiallyVisible: row.isPartiallyVisible,
proximity: row.proximity, proximity: row.proximity,
})} })}
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="min-h-0"> <div class="min-h-0">
{#if itemIndex < items.length} {#if itemIndex < items.length}
{@render children({ {@render children({
item: items[itemIndex], item: items[itemIndex],
index: itemIndex, index: itemIndex,
isFullyVisible: row.isFullyVisible, isFullyVisible: row.isFullyVisible,
isPartiallyVisible: row.isPartiallyVisible, isPartiallyVisible: row.isPartiallyVisible,
proximity: row.proximity, proximity: row.proximity,
})} })}
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -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
View File
@@ -70,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
@@ -101,6 +101,7 @@ function isFontReady(font: UnifiedFont): boolean {
data-font-list data-font-list
weight={DEFAULT_FONT_WEIGHT} weight={DEFAULT_FONT_WEIGHT}
itemHeight={44} itemHeight={44}
gap={2}
class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4" class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4"
> >
{#snippet skeleton()} {#snippet skeleton()}
@@ -5,8 +5,8 @@
<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 {
Badge,
Divider, Divider,
IconButton, IconButton,
Input, Input,
@@ -16,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';
@@ -49,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
View File
@@ -0,0 +1 @@
export { default as Footer } from './ui/Footer/Footer.svelte';
@@ -0,0 +1,47 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Footer from './Footer.svelte';
const { Story } = defineMeta({
title: 'Widgets/Footer',
component: Footer,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Application footer with project information and portfolio link. Visible only on desktop screens.',
},
story: { inline: false },
},
layout: 'fullscreen',
},
});
</script>
<Story
name="Desktop View"
parameters={{
viewport: { defaultViewport: 'desktop' },
}}
>
{#snippet template()}
<div class="h-[200px] relative bg-neutral-50 dark:bg-neutral-900">
<Footer />
</div>
{/snippet}
</Story>
<Story
name="Mobile View (Hidden)"
parameters={{
viewport: { defaultViewport: 'mobile1' },
}}
>
{#snippet template()}
<div class="h-[200px] relative bg-neutral-50 dark:bg-neutral-900">
<p class="p-4 text-sm text-neutral-500 italic">Footer should be hidden on mobile.</p>
<Footer />
</div>
{/snippet}
</Story>
@@ -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="flex flex-row pointer-events-auto items-center gap-2 bg-surface/80 dark:bg-dark-bg/80 backdrop-blur-sm px-2 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,54 @@
import {
render,
screen,
} from '@testing-library/svelte';
import { setContext } from 'svelte';
import Footer from './Footer.svelte';
// Mock component to provide context
import ContextWrapper from '$shared/lib/providers/ResponsiveProvider/ResponsiveProvider.svelte';
describe('Footer', () => {
const currentYear = new Date().getFullYear();
it('renders on desktop', () => {
// Mock responsive context
const mockResponsive = {
isDesktop: true,
isDesktopLarge: false,
};
const { container } = render(Footer, {
context: new Map([['responsive', mockResponsive]]),
});
expect(screen.getByText(`GlyphDiff © 2025 — ${currentYear}`)).toBeInTheDocument();
expect(screen.getByText('allmy.work')).toBeInTheDocument();
});
it('renders on large desktop', () => {
const mockResponsive = {
isDesktop: false,
isDesktopLarge: true,
};
render(Footer, {
context: new Map([['responsive', mockResponsive]]),
});
expect(screen.getByText(`GlyphDiff © 2025 — ${currentYear}`)).toBeInTheDocument();
});
it('does not render on mobile or tablet', () => {
const mockResponsive = {
isDesktop: false,
isDesktopLarge: false,
};
render(Footer, {
context: new Map([['responsive', mockResponsive]]),
});
expect(screen.queryByText(/GlyphDiff/)).not.toBeInTheDocument();
});
});
@@ -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}
+1 -1
View File
@@ -1,6 +1,7 @@
{ {
"extends": "@tsconfig/svelte/tsconfig.json", "extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"rootDir": ".",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"target": "ESNext", "target": "ESNext",
@@ -22,7 +23,6 @@
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
/* Path Aliases */ /* Path Aliases */
"baseUrl": ".",
"paths": { "paths": {
"$lib/*": ["./src/lib/*"], "$lib/*": ["./src/lib/*"],
"$app/*": ["./src/app/*"], "$app/*": ["./src/app/*"],
+4
View File
@@ -12,6 +12,10 @@ export default defineConfig({
restoreMocks: true, restoreMocks: true,
setupFiles: ['./vitest.setup.component.ts', './vitest.setup.jsdom.ts'], setupFiles: ['./vitest.setup.component.ts', './vitest.setup.jsdom.ts'],
globals: true, globals: true,
testTimeout: 15000,
pool: 'forks',
maxWorkers: 1,
isolate: false,
}, },
resolve: { resolve: {
+1212 -1002
View File
File diff suppressed because it is too large Load Diff