Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c01fc79a3e | |||
| 6bfa7ca777 | |||
| 0d4356b8f1 | |||
| c18574d4c3 | |||
| 1c9a7f9fe1 | |||
| fae6694479 | |||
| a105c94176 | |||
| 77c2b27f8b | |||
| 1ce0d6c66f | |||
| 6c20a68e19 | |||
| 3894912a22 | |||
| e8d3727c6a | |||
| 5fbf090b24 | |||
| a94e1f8b65 | |||
| f8ba2d7eb0 | |||
| 3594033bcb | |||
| 2ae24912f7 | |||
| 877719f106 | |||
| 4eafb96d35 | |||
| 652dfa5c90 | |||
| 54087b7b2a | |||
| cffebf05e3 | |||
| ada484e2e0 | |||
| dbcc1caeb0 | |||
| 2c579a3336 | |||
| fe0d4e7daa | |||
| 108df323f9 | |||
| 2803bcd22c | |||
| 47a8487ce9 | |||
| 1d5af5ea70 | |||
| 2221ecad4c | |||
| cd8599d5b5 | |||
| 6c91d570ec | |||
| 91b80a5ada |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Vendored
+2
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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 |
@@ -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 +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) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
buildQueryString,
|
buildQueryString,
|
||||||
clampNumber,
|
clampNumber,
|
||||||
|
cn,
|
||||||
debounce,
|
debounce,
|
||||||
getDecimalPlaces,
|
getDecimalPlaces,
|
||||||
roundToStepPrecision,
|
roundToStepPrecision,
|
||||||
|
|||||||
@@ -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__}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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',
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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]',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ $effect(() => {
|
|||||||
isFullyVisible: row.isFullyVisible,
|
isFullyVisible: row.isFullyVisible,
|
||||||
isPartiallyVisible: row.isPartiallyVisible,
|
isPartiallyVisible: row.isPartiallyVisible,
|
||||||
proximity: row.proximity,
|
proximity: row.proximity,
|
||||||
})}
|
})}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -305,7 +305,7 @@ $effect(() => {
|
|||||||
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',
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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/*"],
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user