Compare commits

..

52 Commits

Author SHA1 Message Date
Ilia Mashkov e0d39d861f refactor(GetFonts): rename filters/filterManager to available/appliedFilterStore
The 'filters' + 'filterManager' pair didn't reveal the schema-vs-selection
split. Rename to reflect the actual roles:

- FiltersStore / filtersStore       → AvailableFilterStore / availableFilterStore
- createFilterManager / FilterManager → createAppliedFilterStore / AppliedFilterStore
- filterManager singleton            → appliedFilterStore
- mapManagerToParams                 → mapAppliedFiltersToParams

Directories and file basenames follow the new singleton names. Public
barrel signature updated; all consumers (Search, FontSearch, Filters,
FilterControls) point at the new identifiers.
2026-05-24 18:08:05 +03:00
Ilia Mashkov b6494a8cb5 test(GetFonts): cover filters and sortStore + nest each in its own dir
Export createSortStore and FiltersStore so per-test instances can be
constructed without sharing singleton state. Add unit tests covering:

- sortStore: default + custom init, display→API value mapping, set()
  idempotency, singleton shape
- filters: empty initial state, fetch population, single-call dedup,
  error path, cached-fetch reuse across observers

Group each store with its tests under its own directory to match the
filterManager layout.
2026-05-24 17:49:26 +03:00
Ilia Mashkov cc218934f4 fix(ComparisonView): update batchFontStore import path in test
Dynamic import inside the vi.mock('$entities/Font') factory was missed
when batchFontStore was relocated into its own subdirectory in 1573950.
Restores the previously-failing comparisonStore test suite (9 tests) and
clears the lingering TS error in svelte-check.
2026-05-24 16:05:59 +03:00
Ilia Mashkov 3a327e2d92 refactor(GetFonts): tighten mapManagerToParams + add coverage
Collapse the three duplicated getGroup/map/length-guard chains into a
single selectedIn helper. Drop the unnecessary `as string[]` casts —
Property<TValue extends string>.value already yields string at the call
site.

Add unit tests covering empty query, populated query, missing group,
empty group, single + multi selection, unknown group ids, and the
combined param shape.
2026-05-24 15:45:07 +03:00
Ilia Mashkov 30621c33df refactor(GetFonts): consolidate model/state into model/store
Align the slice with the project-wide convention (entities/Font,
entities/Breadcrumb, features/ChangeAppTheme all use model/store/;
CLAUDE.md spec calls for store/). Move bindings, filters, and the
filterManager subdir out of the now-removed model/state/ directory.
2026-05-24 15:33:26 +03:00
Ilia Mashkov cb8f6ffc97 refactor(GetFonts): unify filterManager factory + singleton under model/state
Merge the factory previously in lib/filterManager/ with the singleton
previously in model/state/manager.svelte.ts into a single
model/state/filterManager/ slice. The factory builds stateful runes-backed
objects, so it belongs alongside the singleton in model/, not in lib/.

lib/ now contains only the pure mapManagerToParams transform.
Public barrel signature unchanged.
2026-05-24 15:23:25 +03:00
Ilia Mashkov 33d3429060 refactor(GetFonts): consolidate filtersStore wiring into bindings
Move the filtersStore → filterManager.setGroups $effect.root out of
manager.svelte.ts into bindings.svelte.ts so all cross-store reactive
wiring for the feature lives in one place. manager.svelte.ts now only
constructs and exports the singleton.
2026-05-24 15:08:54 +03:00
Ilia Mashkov e60309af78 refactor(GetFonts): centralize filterManager/sortStore → fontStore bridge
Move the duplicated $effect blocks that mapped filterManager and sortStore
into fontStore params out of Search, FontSearch and FilterControls into a
single $effect.root in features/GetFonts/model/state/bindings.svelte.ts.

Consumers now bind to the manager/store directly; the bridge is installed
once via a side-effect import from the feature barrel.
2026-05-24 15:05:28 +03:00
Ilia Mashkov 1573950605 chore(Font): move batchFontStore to separate directory 2026-05-24 13:54:15 +03:00
ilia 773ab55f5c Merge pull request 'Fix/mobile comparison view' (#41) from fix/mobile-comparison-view into main
Workflow / build (push) Successful in 13s
Workflow / publish (push) Successful in 17s
Reviewed-on: #41
2026-05-23 18:21:47 +00:00
Ilia Mashkov 67e02e4e75 feat: tag every build with the immutable commit SHA
Workflow / build (pull_request) Successful in 38s
Workflow / publish (pull_request) Has been skipped
2026-05-23 21:20:37 +03:00
Ilia Mashkov 5ca7a433ff fix: use dvh units to prevent ComparisonView from being covered with address bar on mobile 2026-05-23 21:19:51 +03:00
ilia 3b6ea99d09 Merge pull request 'Fix/text morphing position' (#40) from fix/text-morphing-position into main
Workflow / build (push) Successful in 1m42s
Workflow / publish (push) Successful in 18s
Reviewed-on: #40
2026-05-23 17:43:07 +00:00
Ilia Mashkov f762a09c23 fix(SliderArea): temporarily replace pretext measurements with canvas
Workflow / build (pull_request) Successful in 1m53s
Workflow / publish (pull_request) Has been skipped
2026-05-23 20:07:39 +03:00
Ilia Mashkov 95ae72719e chore: move getPretextFontString into separate directory 2026-05-23 20:03:13 +03:00
ilia f3c4e72b86 Merge pull request 'Fixes/minor tweaks' (#39) from fixes/minor-tweaks into main
Workflow / build (push) Successful in 1m37s
Workflow / publish (push) Successful in 36s
Reviewed-on: #39
2026-05-23 14:11:58 +00:00
Ilia Mashkov f41c4aab9c feat: move class prop to wrapper
Workflow / build (pull_request) Successful in 3m55s
Workflow / publish (pull_request) Has been skipped
2026-05-23 17:00:29 +03:00
Ilia Mashkov d1eb83fa90 fix: wire the search to the store 2026-05-23 16:59:59 +03:00
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
94 changed files with 2923 additions and 1505 deletions
+7 -2
View File
@@ -47,7 +47,8 @@ jobs:
run: yarn test:unit
- name: Run Component Tests
run: yarn test:component
timeout-minutes: 5
run: yarn test:component --reporter=verbose --logHeapUsage
publish:
needs: build # Only runs if tests/lint pass
@@ -62,5 +63,9 @@ jobs:
- name: Build and Push Docker Image
run: |
docker build -t git.allmy.work/${{ gitea.repository }}:latest .
docker build \
-t git.allmy.work/${{ gitea.repository }}:latest \
-t git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }} \
.
docker push git.allmy.work/${{ gitea.repository }}:latest
docker push git.allmy.work/${{ gitea.repository }}:${{ gitea.sha }}
+3
View File
@@ -10,6 +10,9 @@ node_modules
/build
/dist
# IDE settings
.vscode
# Git worktrees (isolated development branches)
.worktrees
+4 -5
View File
@@ -13,7 +13,7 @@
"https://plugins.dprint.dev/typescript-0.93.0.wasm",
"https://plugins.dprint.dev/json-0.19.3.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": {
"lineWidth": 120,
@@ -57,9 +57,8 @@
"quotes": "double",
"scriptIndent": false,
"styleIndent": false,
"vBindStyle": "short",
"vOnStyle": "short",
"formatComments": true
"formatComments": true,
"svelteAttrShorthand": true,
"svelteDirectiveShorthand": true
}
}
+33 -33
View File
@@ -27,45 +27,45 @@
"build-storybook": "storybook build"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.1.3",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@playwright/test": "^1.57.0",
"@storybook/addon-a11y": "^10.1.11",
"@storybook/addon-docs": "^10.1.11",
"@storybook/addon-svelte-csf": "^5.0.10",
"@storybook/addon-vitest": "^10.1.11",
"@storybook/svelte-vite": "^10.1.11",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.18",
"@chromatic-com/storybook": "5.1.2",
"@internationalized/date": "3.12.1",
"@lucide/svelte": "^1.14.0",
"@playwright/test": "1.59.1",
"@storybook/addon-a11y": "10.3.6",
"@storybook/addon-docs": "10.3.6",
"@storybook/addon-svelte-csf": "5.1.2",
"@storybook/addon-vitest": "10.3.6",
"@storybook/svelte-vite": "10.3.6",
"@sveltejs/vite-plugin-svelte": "7.1.0",
"@tailwindcss/vite": "4.2.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@tsconfig/svelte": "^5.0.6",
"@types/jsdom": "^27",
"@vitest/browser-playwright": "^4.0.16",
"@vitest/coverage-v8": "^4.0.16",
"bits-ui": "^2.14.4",
"@tsconfig/svelte": "5.0.8",
"@types/jsdom": "28.0.1",
"@vitest/browser-playwright": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"bits-ui": "2.18.1",
"clsx": "^2.1.1",
"dprint": "^0.50.2",
"jsdom": "^27.4.0",
"lefthook": "^2.0.13",
"oxlint": "^1.35.0",
"playwright": "^1.57.0",
"storybook": "^10.1.11",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"svelte-language-server": "^0.17.23",
"tailwind-merge": "^3.4.0",
"dprint": "0.54.0",
"jsdom": "29.1.1",
"lefthook": "2.1.6",
"oxlint": "1.62.0",
"playwright": "1.59.1",
"storybook": "10.3.6",
"svelte": "5.55.5",
"svelte-check": "4.4.8",
"svelte-language-server": "0.18.0",
"tailwind-merge": "3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.18",
"tailwindcss": "4.2.4",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vitest": "^4.0.16",
"vitest-browser-svelte": "^2.0.1"
"typescript": "6.0.3",
"vite": "8.0.10",
"vitest": "4.1.5",
"vitest-browser-svelte": "2.1.1"
},
"dependencies": {
"@chenglou/pretext": "^0.0.5",
"@tanstack/svelte-query": "^6.0.14"
"@chenglou/pretext": "0.0.6",
"@tanstack/svelte-query": "6.1.28"
}
}
+5
View File
@@ -219,6 +219,11 @@
@apply border-border outline-ring/50;
}
::selection {
background-color: var(--color-brand);
color: var(--swiss-white);
}
body {
@apply bg-background text-foreground;
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
+2
View File
@@ -36,6 +36,8 @@ declare module '*.jpg' {
export default content;
}
declare module '*.css';
/// <reference types="vite/client" />
interface ImportMetaEnv {
+9 -17
View File
@@ -3,21 +3,12 @@
Application shell with providers and page wrapper
-->
<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 GD from '$shared/assets/GD.svg';
import G from '$shared/assets/G.svg';
import { ResponsiveProvider } from '$shared/lib';
import clsx from 'clsx';
import { cn } from '$shared/lib';
import { Footer } from '$widgets/Footer';
import {
type Snippet,
onDestroy,
@@ -40,7 +31,7 @@ onDestroy(() => themeManager.destroy());
</script>
<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
@@ -82,14 +73,15 @@ onDestroy(() => themeManager.destroy());
<ResponsiveProvider>
<div
id="app-root"
class={clsx(
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
class={cn(
'min-h-dvh w-auto flex flex-col bg-surface dark:bg-dark-bg relative',
theme === 'dark' ? 'dark' : '',
)}
>
{#if fontsReady}
{@render children?.()}
{/if}
<footer></footer>
<Footer />
</div>
</ResponsiveProvider>
@@ -20,6 +20,7 @@ let mockObserverInstances: MockIntersectionObserver[] = [];
class MockIntersectionObserver implements IntersectionObserver {
root = null;
rootMargin = '';
scrollMargin = '';
thresholds: number[] = [];
readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = [];
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.
*/
export async function yieldToMainThread(): Promise<void> {
// @ts-expect-error - scheduler not in TypeScript lib yet
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
await scheduler.yield();
} else {
await new Promise<void>(resolve => {
@@ -3,12 +3,12 @@ import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
import {
fetchFontsByIds,
seedFontCache,
} from '../../api/proxy/proxyFonts';
} from '../../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../lib/errors/errors';
import type { UnifiedFont } from '../../model/types';
} from '../../../lib/errors/errors';
import type { UnifiedFont } from '../../types';
/**
* Internal fetcher that seeds the cache and handles error wrapping.
@@ -7,11 +7,11 @@ import {
it,
vi,
} from 'vitest';
import * as api from '../../api/proxy/proxyFonts';
import * as api from '../../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../lib/errors/errors';
} from '../../../lib/errors/errors';
import { BatchFontStore } from './batchFontStore.svelte';
describe('BatchFontStore', () => {
+1 -1
View File
@@ -2,7 +2,7 @@
export * from './appliedFontsStore/appliedFontsStore.svelte';
// Batch font store
export { BatchFontStore } from './batchFontStore.svelte';
export { BatchFontStore } from './batchFontStore/batchFontStore.svelte';
// Single FontStore
export {
@@ -4,7 +4,7 @@
Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
-->
<script lang="ts">
import clsx from 'clsx';
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import {
DEFAULT_FONT_WEIGHT,
@@ -61,7 +61,7 @@ const shouldReveal = $derived(status === 'loaded' || status === 'error');
{:else}
<div
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
class={clsx(className)}
class={cn(className)}
>
{@render children?.()}
</div>
+15 -9
View File
@@ -1,19 +1,25 @@
export {
createFilterManager,
type FilterManager,
mapManagerToParams,
} from './lib';
export { filtersStore } from './model/state/filters.svelte';
export { filterManager } from './model/state/manager.svelte';
export { mapAppliedFiltersToParams } from './lib';
export {
type AppliedFilterStore,
appliedFilterStore,
/**
* Filter Store
*/
availableFilterStore,
/**
* Filter Manager
*/
createAppliedFilterStore,
/**
* Sort Store
*/
SORT_MAP,
SORT_OPTIONS,
type SortApiValue,
type SortOption,
sortStore,
} from './model/store/sortStore.svelte';
} from './model';
export {
FilterControls,
+1 -6
View File
@@ -1,6 +1 @@
export {
createFilterManager,
type FilterManager,
} from './filterManager/filterManager.svelte';
export { mapManagerToParams } from './mapper/mapManagerToParams';
export { mapAppliedFiltersToParams } from './mapper/mapAppliedFiltersToParams';
@@ -0,0 +1,127 @@
import type { Property } from '$shared/lib';
import {
describe,
expect,
it,
} from 'vitest';
import { createAppliedFilterStore } from '../../model/store/appliedFilterStore/appliedFilterStore.svelte';
import { mapAppliedFiltersToParams } from './mapAppliedFiltersToParams';
/**
* Build a Property with explicit selection state.
*/
function prop(value: string, selected = false): Property<string> {
return { id: value, name: value, value, selected };
}
/**
* Build a filter group with a known id and a list of (value, selected) entries.
*/
function group(id: string, props: Array<[string, boolean]>) {
return {
id,
label: id,
properties: props.map(([value, selected]) => prop(value, selected)),
};
}
describe('mapAppliedFiltersToParams', () => {
describe('search query', () => {
it('omits q when query is empty', () => {
const manager = createAppliedFilterStore({ queryValue: '', groups: [] });
expect(mapAppliedFiltersToParams(manager).q).toBeUndefined();
});
it('passes the debounced query through as q', () => {
// Constructor seeds both immediate and debounced synchronously.
const manager = createAppliedFilterStore({ queryValue: 'roboto', groups: [] });
expect(mapAppliedFiltersToParams(manager).q).toBe('roboto');
});
});
describe('group selections', () => {
it('omits a group entirely when no group with that id exists', () => {
const manager = createAppliedFilterStore({ queryValue: '', groups: [] });
const params = mapAppliedFiltersToParams(manager);
expect(params.providers).toBeUndefined();
expect(params.categories).toBeUndefined();
expect(params.subsets).toBeUndefined();
});
it('omits a group when it exists but has no selections', () => {
const manager = createAppliedFilterStore({
queryValue: '',
groups: [group('providers', [['google', false], ['fontshare', false]])],
});
expect(mapAppliedFiltersToParams(manager).providers).toBeUndefined();
});
it('returns the selected values for a single group', () => {
const manager = createAppliedFilterStore({
queryValue: '',
groups: [group('providers', [['google', true], ['fontshare', false]])],
});
expect(mapAppliedFiltersToParams(manager).providers).toEqual(['google']);
});
it('returns multiple selected values in selection order', () => {
const manager = createAppliedFilterStore({
queryValue: '',
groups: [
group('categories', [
['serif', true],
['sans-serif', false],
['display', true],
['monospace', true],
]),
],
});
expect(mapAppliedFiltersToParams(manager).categories).toEqual(['serif', 'display', 'monospace']);
});
it('maps each of the three recognized group ids independently', () => {
const manager = createAppliedFilterStore({
queryValue: '',
groups: [
group('providers', [['google', true]]),
group('categories', [['serif', true], ['sans-serif', true]]),
group('subsets', [['latin', true]]),
],
});
const params = mapAppliedFiltersToParams(manager);
expect(params.providers).toEqual(['google']);
expect(params.categories).toEqual(['serif', 'sans-serif']);
expect(params.subsets).toEqual(['latin']);
});
it('ignores groups whose id does not match providers/categories/subsets', () => {
const manager = createAppliedFilterStore({
queryValue: '',
groups: [group('weights', [['400', true], ['700', true]])],
});
const params = mapAppliedFiltersToParams(manager);
expect(params.providers).toBeUndefined();
expect(params.categories).toBeUndefined();
expect(params.subsets).toBeUndefined();
});
});
describe('combined output', () => {
it('produces a complete param object when query and selections coexist', () => {
const manager = createAppliedFilterStore({
queryValue: 'inter',
groups: [
group('providers', [['google', true]]),
group('categories', [['sans-serif', true]]),
group('subsets', [['latin', false]]),
],
});
expect(mapAppliedFiltersToParams(manager)).toEqual({
q: 'inter',
providers: ['google'],
categories: ['sans-serif'],
subsets: undefined,
});
});
});
});
@@ -0,0 +1,48 @@
import type { ProxyFontsParams } from '$entities/Font/api';
import type { AppliedFilterStore } from '../../model';
/**
* Maps filter manager to proxy API parameters.
*
* Updated to support multiple filter values (arrays)
*
* @param manager - Filter manager instance with reactive state
* @returns - Partial proxy API parameters ready for API call
*
* @example
* ```ts
* // Example filter manager state:
* // {
* // queryValue: 'roboto',
* // providers: ['google', 'fontshare'],
* // categories: ['sans-serif', 'serif'],
* // subsets: ['latin']
* // }
*
* const params = mapAppliedFiltersToParams(manager);
* // Returns: {
* // providers: ['google', 'fontshare'],
* // categories: ['sans-serif', 'serif'],
* // subsets: ['latin'],
* // q: 'roboto'
* // }
* ```
*/
export function mapAppliedFiltersToParams(manager: AppliedFilterStore): Partial<ProxyFontsParams> {
/**
* Return the list of selected values for a group, or undefined when
* the group is missing or has no selection — matches the API's
* "omit empty filters" contract.
*/
const selectedIn = (id: string): string[] | undefined => {
const values = manager.getGroup(id)?.instance.selectedProperties.map(p => p.value);
return values && values.length > 0 ? values : undefined;
};
return {
q: manager.debouncedQueryValue || undefined,
providers: selectedIn('providers'),
categories: selectedIn('categories'),
subsets: selectedIn('subsets'),
};
}
@@ -1,53 +0,0 @@
import type { ProxyFontsParams } from '$entities/Font/api';
import type { FilterManager } from '../filterManager/filterManager.svelte';
/**
* Maps filter manager to proxy API parameters.
*
* Updated to support multiple filter values (arrays)
*
* @param manager - Filter manager instance with reactive state
* @returns - Partial proxy API parameters ready for API call
*
* @example
* ```ts
* // Example filter manager state:
* // {
* // queryValue: 'roboto',
* // providers: ['google', 'fontshare'],
* // categories: ['sans-serif', 'serif'],
* // subsets: ['latin']
* // }
*
* const params = mapManagerToParams(manager);
* // Returns: {
* // providers: ['google', 'fontshare'],
* // categories: ['sans-serif', 'serif'],
* // subsets: ['latin'],
* // q: 'roboto'
* // }
* ```
*/
export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsParams> {
const providers = manager.getGroup('providers')?.instance.selectedProperties.map(p => p.value);
const categories = manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value);
const subsets = manager.getGroup('subsets')?.instance.selectedProperties.map(p => p.value);
return {
// Search query (debounced)
q: manager.debouncedQueryValue || undefined,
// NEW: Support arrays - send all selected values
providers: providers && providers.length > 0
? providers as string[]
: undefined,
categories: categories && categories.length > 0
? categories as string[]
: undefined,
subsets: subsets && subsets.length > 0
? subsets as string[]
: undefined,
};
}
+19 -5
View File
@@ -16,18 +16,32 @@ export {
/**
* Low-level property selection store
*/
filtersStore,
} from './state/filters.svelte';
availableFilterStore,
} from './store/availableFilterStore/availableFilterStore.svelte';
/**
* Main filter controller
*/
export {
/**
* Reactive interface returned by `createAppliedFilterStore`
*/
type AppliedFilterStore,
/**
* High-level manager for syncing search and filters
*/
filterManager,
} from './state/manager.svelte';
appliedFilterStore,
/**
* Factory for constructing a filter manager instance
*/
createAppliedFilterStore,
} from './store/appliedFilterStore/appliedFilterStore.svelte';
/**
* Side-effect import: installs the global appliedFilterStore+sortStore → fontStore
* bridge on first import of this feature barrel. No exports.
*/
import './store/bindings.svelte';
/**
* Sorting logic
@@ -53,4 +67,4 @@ export {
* Reactive store for the current sort selection
*/
sortStore,
} from './store/sortStore.svelte';
} from './store/sortStore/sortStore.svelte';
@@ -1,39 +0,0 @@
/**
* Filter manager singleton
*
* Creates filterManager with empty groups initially, then reactively
* populates groups when filtersStore loads data from backend.
*/
import { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
import { filtersStore } from './filters.svelte';
export const filterManager = createFilterManager({
queryValue: '',
groups: [],
});
/**
* Reactively sync backend filter metadata into filterManager groups.
* When filtersStore.filters resolves, setGroups replaces the empty groups.
*/
$effect.root(() => {
$effect(() => {
const dynamicFilters = filtersStore.filters;
if (dynamicFilters.length > 0) {
filterManager.setGroups(
dynamicFilters.map(filter => ({
id: filter.id,
label: filter.name,
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
id: opt.id,
name: opt.name,
value: opt.value,
selected: false,
})),
})),
);
}
});
});
@@ -1,13 +1,16 @@
/**
* Filter manager for font filtering
* Filter manager factory and singleton.
*
* Manages multiple filter groups (providers, categories, subsets)
* with debounced search input. Provides reactive state for filter
* selections and convenience methods for bulk operations.
* Owns multiple filter groups (providers, categories, subsets) plus a
* debounced search input. Provides reactive state for filter selections
* and convenience methods for bulk operations.
*
* The factory (`createAppliedFilterStore`) is exported for tests; the app
* consumes the `appliedFilterStore` singleton at the bottom of this file.
*
* @example
* ```ts
* const manager = createFilterManager({
* const manager = createAppliedFilterStore({
* queryValue: '',
* groups: [
* { id: 'providers', label: 'Provider', properties: [...] },
@@ -25,7 +28,7 @@ import { createDebouncedState } from '$shared/lib/helpers';
import type {
FilterConfig,
FilterGroupConfig,
} from '../../model';
} from '../../types/filter';
/**
* Creates a filter manager instance
@@ -36,7 +39,7 @@ import type {
* @param config - Configuration with query value and filter groups
* @returns Filter manager instance with reactive state and methods
*/
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
export function createAppliedFilterStore<TValue extends string>(config: FilterConfig<TValue>) {
const search = createDebouncedState(config.queryValue ?? '');
// Create filter instances upfront
@@ -122,4 +125,16 @@ export function createFilterManager<TValue extends string>(config: FilterConfig<
};
}
export type FilterManager = ReturnType<typeof createFilterManager>;
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
/**
* App-wide filter manager singleton.
*
* Constructed with empty groups; the availableFilterStore appliedFilterStore wiring
* lives in `./bindings.svelte` and populates groups once backend filter
* metadata arrives.
*/
export const appliedFilterStore = createAppliedFilterStore({
queryValue: '',
groups: [],
});
@@ -7,10 +7,10 @@ import {
it,
vi,
} from 'vitest';
import { createFilterManager } from './filterManager.svelte';
import { createAppliedFilterStore } from './appliedFilterStore.svelte';
/**
* Test Suite for createFilterManager Helper Function
* Test Suite for createAppliedFilterStore Helper Function
*
* This suite tests the filter manager logic including:
* - Debounced query state (immediate vs delayed)
@@ -54,9 +54,9 @@ function createTestGroups(count: number, propertiesPerGroup = 3) {
}));
}
describe('createFilterManager - Initialization', () => {
describe('createAppliedFilterStore - Initialization', () => {
it('creates manager with empty query value', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(2),
});
@@ -66,7 +66,7 @@ describe('createFilterManager - Initialization', () => {
});
it('creates manager with initial query value', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: 'search term',
groups: createTestGroups(1),
});
@@ -76,7 +76,7 @@ describe('createFilterManager - Initialization', () => {
});
it('creates manager with undefined query value (defaults to empty string)', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
groups: createTestGroups(1),
});
@@ -86,7 +86,7 @@ describe('createFilterManager - Initialization', () => {
it('creates filter groups for each config group', () => {
const groups = createTestGroups(3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -99,7 +99,7 @@ describe('createFilterManager - Initialization', () => {
it('creates filter instances for each group', () => {
const groups = createTestGroups(2, 5);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -118,7 +118,7 @@ describe('createFilterManager - Initialization', () => {
{ id: 'providers', label: 'Providers', properties: createTestProperties(2) },
{ id: 'categories', label: 'Categories', properties: createTestProperties(3) },
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -129,7 +129,7 @@ describe('createFilterManager - Initialization', () => {
it('handles single group', () => {
const groups = createTestGroups(1);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -139,7 +139,7 @@ describe('createFilterManager - Initialization', () => {
});
});
describe('createFilterManager - Debounced Query', () => {
describe('createAppliedFilterStore - Debounced Query', () => {
beforeEach(() => {
vi.useFakeTimers();
});
@@ -149,7 +149,7 @@ describe('createFilterManager - Debounced Query', () => {
});
it('immediate query value updates instantly', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -161,7 +161,7 @@ describe('createFilterManager - Debounced Query', () => {
});
it('debounced query value updates after default delay (300ms)', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -178,7 +178,7 @@ describe('createFilterManager - Debounced Query', () => {
});
it('rapid query changes reset the debounce timer', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -200,7 +200,7 @@ describe('createFilterManager - Debounced Query', () => {
});
it('handles empty string in query', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: 'initial',
groups: createTestGroups(1),
});
@@ -213,7 +213,7 @@ describe('createFilterManager - Debounced Query', () => {
});
it('preserves initial query value until changed', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: 'initial search',
groups: createTestGroups(1),
});
@@ -228,9 +228,9 @@ describe('createFilterManager - Debounced Query', () => {
});
});
describe('createFilterManager - hasAnySelection Derived State', () => {
describe('createAppliedFilterStore - hasAnySelection Derived State', () => {
it('returns false when no filters are selected', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(3, 3),
});
@@ -240,7 +240,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
it('returns true when one filter in one group is selected', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -255,7 +255,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
it('returns true when multiple filters across groups are selected', () => {
const groups = createTestGroups(3, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -272,7 +272,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
it('returns false after deselecting all filters', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -286,7 +286,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
it('reacts to selection changes in individual groups', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -318,7 +318,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
properties: createTestProperties(3, []),
},
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -331,7 +331,7 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
{ id: 'group-0', label: 'Group 0', properties: [] },
{ id: 'group-1', label: 'Group 1', properties: [] },
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -340,10 +340,10 @@ describe('createFilterManager - hasAnySelection Derived State', () => {
});
});
describe('createFilterManager - getGroup() Method', () => {
describe('createAppliedFilterStore - getGroup() Method', () => {
it('returns the correct group by ID', () => {
const groups = createTestGroups(3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -357,7 +357,7 @@ describe('createFilterManager - getGroup() Method', () => {
it('returns undefined for non-existent group ID', () => {
const groups = createTestGroups(2);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -369,7 +369,7 @@ describe('createFilterManager - getGroup() Method', () => {
it('returns group with accessible filter instance', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -385,7 +385,7 @@ describe('createFilterManager - getGroup() Method', () => {
it('returns first group when requested', () => {
const groups = createTestGroups(3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -398,7 +398,7 @@ describe('createFilterManager - getGroup() Method', () => {
it('returns last group when requested', () => {
const groups = createTestGroups(5);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -410,10 +410,10 @@ describe('createFilterManager - getGroup() Method', () => {
});
});
describe('createFilterManager - deselectAllGlobal() Method', () => {
describe('createAppliedFilterStore - deselectAllGlobal() Method', () => {
it('deselects all filters across all groups', () => {
const groups = createTestGroups(3, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -436,7 +436,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
it('handles deselecting when nothing is selected', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -453,7 +453,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
{ id: 'group-0', label: 'Group 0', properties: [] },
{ id: 'group-1', label: 'Group 1', properties: [] },
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -464,7 +464,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
it('can select filters after global deselect', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -482,7 +482,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
it('handles partially selected groups', () => {
const groups = createTestGroups(3, 5);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -505,7 +505,7 @@ describe('createFilterManager - deselectAllGlobal() Method', () => {
});
});
describe('createFilterManager - Complex Scenarios', () => {
describe('createAppliedFilterStore - Complex Scenarios', () => {
beforeEach(() => {
vi.useFakeTimers();
});
@@ -516,7 +516,7 @@ describe('createFilterManager - Complex Scenarios', () => {
it('handles query changes and filter selections together', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -553,7 +553,7 @@ describe('createFilterManager - Complex Scenarios', () => {
},
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -582,7 +582,7 @@ describe('createFilterManager - Complex Scenarios', () => {
it('manages multiple independent filter groups correctly', () => {
const groups = createTestGroups(4, 5);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -607,7 +607,7 @@ describe('createFilterManager - Complex Scenarios', () => {
it('handles toggle operations via getGroup', () => {
const groups = createTestGroups(2, 3);
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -623,9 +623,9 @@ describe('createFilterManager - Complex Scenarios', () => {
});
});
describe('createFilterManager - Interface Compliance', () => {
describe('createAppliedFilterStore - Interface Compliance', () => {
it('exposes queryValue getter', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: 'test',
groups: createTestGroups(1),
});
@@ -636,7 +636,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('exposes queryValue setter', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: 'test',
groups: createTestGroups(1),
});
@@ -647,7 +647,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('exposes debouncedQueryValue getter', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: 'test',
groups: createTestGroups(1),
});
@@ -658,7 +658,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('exposes groups getter', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -669,7 +669,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('exposes hasAnySelection getter', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -680,7 +680,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('exposes getGroup method', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -689,7 +689,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('exposes deselectAllGlobal method', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -698,7 +698,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
it('does not expose debouncedQueryValue setter', () => {
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups: createTestGroups(1),
});
@@ -708,7 +708,7 @@ describe('createFilterManager - Interface Compliance', () => {
});
});
describe('createFilterManager - Edge Cases', () => {
describe('createAppliedFilterStore - Edge Cases', () => {
it('handles single property groups', () => {
const groups: Array<{
id: string;
@@ -722,7 +722,7 @@ describe('createFilterManager - Edge Cases', () => {
},
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -749,7 +749,7 @@ describe('createFilterManager - Edge Cases', () => {
},
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -773,7 +773,7 @@ describe('createFilterManager - Edge Cases', () => {
},
];
const manager = createFilterManager({
const manager = createAppliedFilterStore({
queryValue: '',
groups,
});
@@ -6,12 +6,12 @@
*
* @example
* ```ts
* import { filtersStore } from '$features/GetFonts';
* import { availableFilterStore } from '$features/GetFonts';
*
* // Access filters (reactive)
* $: filters = filtersStore.filters;
* $: isLoading = filtersStore.isLoading;
* $: error = filtersStore.error;
* $: filters = availableFilterStore.filters;
* $: isLoading = availableFilterStore.isLoading;
* $: error = availableFilterStore.error;
* ```
*/
@@ -31,7 +31,7 @@ import {
* Fetches and caches filter metadata using fetchProxyFilters()
* Provides reactive access to filter data
*/
class FiltersStore {
export class AvailableFilterStore {
/**
* TanStack Query result state
*/
@@ -125,4 +125,4 @@ class FiltersStore {
/**
* Singleton instance
*/
export const filtersStore = new FiltersStore();
export const availableFilterStore = new AvailableFilterStore();
@@ -0,0 +1,116 @@
import { queryClient } from '$shared/api/queryClient';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import * as filtersApi from '../../../api/filters/filters';
import type { FilterMetadata } from '../../../api/filters/filters';
import { AvailableFilterStore } from './availableFilterStore.svelte';
/**
* Build a minimal FilterMetadata fixture for tests.
*/
function metadata(id: string, optionValues: string[] = []): FilterMetadata {
return {
id,
name: id,
description: '',
type: 'enum',
options: optionValues.map(value => ({
id: value,
name: value,
value,
count: 1,
})),
} as FilterMetadata;
}
describe('AvailableFilterStore', () => {
let store: AvailableFilterStore;
beforeEach(() => {
queryClient.clear();
// TanStack defaults retry=3 with exponential backoff, which would
// make the error-path test wait >5s. Disable for deterministic timing.
queryClient.setDefaultOptions({ queries: { retry: false } });
vi.clearAllMocks();
});
afterEach(() => {
store?.destroy();
});
describe('initial state', () => {
it('starts with an empty filter list', () => {
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
store = new AvailableFilterStore();
expect(store.filters).toEqual([]);
});
it('reports null error before any failure', () => {
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
store = new AvailableFilterStore();
expect(store.error).toBeNull();
});
});
describe('successful fetch', () => {
it('populates filters with the fetched metadata', async () => {
const data = [
metadata('providers', ['google', 'fontshare']),
metadata('categories', ['serif', 'sans-serif']),
];
vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue(data);
store = new AvailableFilterStore();
await vi.waitFor(() => expect(store.filters).toEqual(data), { timeout: 1000 });
expect(store.isError).toBe(false);
expect(store.error).toBeNull();
});
it('calls fetchProxyFilters exactly once for the initial load', async () => {
const spy = vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]);
store = new AvailableFilterStore();
await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 });
});
});
describe('error handling', () => {
it('flips isError and exposes the error message on fetch failure', async () => {
vi.spyOn(filtersApi, 'fetchProxyFilters').mockRejectedValue(new Error('boom'));
store = new AvailableFilterStore();
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBe('boom');
expect(store.filters).toEqual([]);
});
});
describe('caching', () => {
it('does not trigger a second fetch when another instance shares the query key', async () => {
const data = [metadata('providers', ['google'])];
const spy = vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue(data);
store = new AvailableFilterStore();
await vi.waitFor(() => expect(store.filters).toEqual(data), { timeout: 1000 });
expect(spy).toHaveBeenCalledTimes(1);
// A second observer on the same query key should reuse the cached
// result rather than triggering a new request.
const second = new AvailableFilterStore();
try {
// Give the new observer a tick to potentially refetch (it shouldn't).
await new Promise(r => setTimeout(r, 50));
expect(spy).toHaveBeenCalledTimes(1);
} finally {
second.destroy();
}
});
});
});
@@ -0,0 +1,61 @@
/**
* Bridges feature-level UI state (appliedFilterStore + sortStore) to the
* entity-level fontStore query params.
*
* Centralizing this here means consumers (Search, FontSearch,
* FilterControls, etc.) bind to the manager/store directly without
* each repeating the same mapping effect. The bridge is a singleton
* concern — it tracks singleton state and writes to a singleton query
* observer, so it lives at module scope, not in any individual widget.
*/
import { fontStore } from '$entities/Font';
import { untrack } from 'svelte';
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
import { availableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
import { sortStore } from './sortStore/sortStore.svelte';
$effect.root(() => {
/**
* Populate appliedFilterStore groups when backend filter metadata resolves.
* availableFilterStore is async; until it loads, appliedFilterStore has empty groups
* and the UI renders nothing for them.
*/
$effect(() => {
const dynamicFilters = availableFilterStore.filters;
if (dynamicFilters.length > 0) {
appliedFilterStore.setGroups(
dynamicFilters.map(filter => ({
id: filter.id,
label: filter.name,
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
id: opt.id,
name: opt.name,
value: opt.value,
selected: false,
})),
})),
);
}
});
/**
* Mirror filter selections + debounced search query into fontStore params.
* untrack the write so fontStore's internal $state reads don't feed back
* into this effect's dependency graph.
*/
$effect(() => {
const params = mapAppliedFiltersToParams(appliedFilterStore);
untrack(() => fontStore.setParams(params));
});
/**
* Mirror sort selection into fontStore.
*/
$effect(() => {
const apiSort = sortStore.apiValue;
untrack(() => fontStore.setSort(apiSort));
});
});
@@ -17,7 +17,7 @@ export const SORT_MAP: Record<SortOption, 'name' | 'popularity' | 'lastModified'
export type SortApiValue = (typeof SORT_MAP)[SortOption];
function createSortStore(initial: SortOption = 'Popularity') {
export function createSortStore(initial: SortOption = 'Popularity') {
let current = $state<SortOption>(initial);
return {
@@ -0,0 +1,68 @@
import {
describe,
expect,
it,
} from 'vitest';
import {
SORT_MAP,
SORT_OPTIONS,
type SortOption,
createSortStore,
sortStore,
} from './sortStore.svelte';
describe('createSortStore', () => {
describe('initialization', () => {
it('defaults to Popularity when no initial value is provided', () => {
const store = createSortStore();
expect(store.value).toBe('Popularity');
});
it('accepts an explicit initial value', () => {
const store = createSortStore('Newest');
expect(store.value).toBe('Newest');
});
});
describe('apiValue mapping', () => {
it.each<[SortOption, (typeof SORT_MAP)[SortOption]]>([
['Name', 'name'],
['Popularity', 'popularity'],
['Newest', 'lastModified'],
])('maps %s to %s', (display, api) => {
const store = createSortStore(display);
expect(store.apiValue).toBe(api);
});
});
describe('set()', () => {
it('updates both value and apiValue together', () => {
const store = createSortStore('Name');
store.set('Newest');
expect(store.value).toBe('Newest');
expect(store.apiValue).toBe('lastModified');
});
it('is idempotent — setting the current value keeps state consistent', () => {
const store = createSortStore('Popularity');
store.set('Popularity');
expect(store.value).toBe('Popularity');
});
});
});
describe('sortStore singleton', () => {
it('exposes the same shape as a factory instance', () => {
expect(typeof sortStore.value).toBe('string');
expect(typeof sortStore.apiValue).toBe('string');
expect(typeof sortStore.set).toBe('function');
});
it('accepts all SORT_OPTIONS as valid set() inputs', () => {
for (const option of SORT_OPTIONS) {
sortStore.set(option);
expect(sortStore.value).toBe(option);
expect(sortStore.apiValue).toBe(SORT_MAP[option]);
}
});
});
@@ -9,7 +9,7 @@ const { Story } = defineMeta({
docs: {
description: {
component:
'Renders the full list of filter groups managed by filterManager. Each group maps to a collapsible FilterGroup with checkboxes. No props — reads directly from the filterManager singleton.',
'Renders the full list of filter groups managed by appliedFilterStore. Each group maps to a collapsible FilterGroup with checkboxes. No props — reads directly from the appliedFilterStore singleton.',
},
story: { inline: false },
},
@@ -4,10 +4,10 @@
-->
<script lang="ts">
import { FilterGroup } from '$shared/ui';
import { filterManager } from '../../model';
import { appliedFilterStore } from '../../model';
</script>
{#each filterManager.groups as group (group.id)}
{#each appliedFilterStore.groups as group (group.id)}
<FilterGroup
displayedLabel={group.label}
filter={group.instance}
@@ -1,6 +1,6 @@
import {
filterManager,
filtersStore,
appliedFilterStore,
availableFilterStore,
} from '$features/GetFonts';
import {
render,
@@ -11,9 +11,9 @@ import Filters from './Filters.svelte';
describe('Filters', () => {
beforeEach(() => {
// Clear groups and mock filtersStore to be empty so the auto-sync effect doesn't overwrite us
filterManager.setGroups([]);
vi.spyOn(filtersStore, 'filters', 'get').mockReturnValue([]);
// Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us
appliedFilterStore.setGroups([]);
vi.spyOn(availableFilterStore, 'filters', 'get').mockReturnValue([]);
});
afterEach(() => {
@@ -28,7 +28,7 @@ describe('Filters', () => {
});
it('renders a label for each filter group', () => {
filterManager.setGroups([
appliedFilterStore.setGroups([
{ id: 'cat', label: 'Categories', properties: [] },
{ id: 'prov', label: 'Font Providers', properties: [] },
]);
@@ -38,7 +38,7 @@ describe('Filters', () => {
});
it('renders filter properties within groups', () => {
filterManager.setGroups([
appliedFilterStore.setGroups([
{
id: 'cat',
label: 'Category',
@@ -54,7 +54,7 @@ describe('Filters', () => {
});
it('renders multiple groups with their properties', () => {
filterManager.setGroups([
appliedFilterStore.setGroups([
{
id: 'cat',
label: 'Category',
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
docs: {
description: {
component:
'Sort options and Reset_Filters button rendered below the filter list. Reads sort state from sortStore and dispatches resets via filterManager. Requires responsive context — wrap with Providers.',
'Sort options and Reset_Filters button rendered below the filter list. Reads sort state from sortStore and dispatches resets via appliedFilterStore. Requires responsive context — wrap with Providers.',
},
story: { inline: false },
},
@@ -4,19 +4,15 @@
Sits below the filter list, separated by a top border.
-->
<script lang="ts">
import { fontStore } from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import { Button } from '$shared/ui';
import { Label } from '$shared/ui';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import clsx from 'clsx';
import {
getContext,
untrack,
} from 'svelte';
import { getContext } from 'svelte';
import {
SORT_OPTIONS,
filterManager,
appliedFilterStore,
sortStore,
} from '../../model';
@@ -31,21 +27,16 @@ const {
class: className,
}: Props = $props();
$effect(() => {
const apiSort = sortStore.apiValue;
untrack(() => fontStore.setSort(apiSort));
});
const responsive = getContext<ResponsiveManager>('responsive');
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
function handleReset() {
filterManager.deselectAllGlobal();
appliedFilterStore.deselectAllGlobal();
}
</script>
<div
class={clsx(
class={cn(
'flex flex-col md:flex-row justify-between items-start md:items-center',
'gap-1 md:gap-6',
'pt-6 mt-6 md:pt-8 md:mt-8',
@@ -77,7 +68,7 @@ function handleReset() {
variant="ghost"
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
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"
>
{#snippet icon()}
@@ -11,6 +11,7 @@ import {
MULTIPLIER_S,
} from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import {
Button,
ComboControl,
@@ -20,7 +21,6 @@ import {
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import XIcon from '@lucide/svelte/icons/x';
import { Popover } from 'bits-ui';
import clsx from 'clsx';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
@@ -72,10 +72,11 @@ $effect(() => {
{#if !hidden}
{#if responsive.isMobileOrTablet}
<div class={className}>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button class={className} variant="primary" {...props}>
<Button variant="primary" {...props}>
{#snippet icon()}
<Settings2Icon class="size-4" />
{/snippet}
@@ -88,7 +89,7 @@ $effect(() => {
side="top"
align="end"
sideOffset={8}
class={clsx(
class={cn(
'z-50 w-72',
'bg-surface dark:bg-dark-card',
'border border-subtle',
@@ -140,13 +141,14 @@ $effect(() => {
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
{:else}
<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 }}
>
<div
class={clsx(
class={cn(
'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',
'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
const getElementOffset = () => {
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 rafId: number | null = null;
containerHeight = window.innerHeight;
containerHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
const handleScroll = () => {
if (rafId !== null) {
+1
View File
@@ -39,6 +39,7 @@ export {
export {
buildQueryString,
clampNumber,
cn,
debounce,
getDecimalPlaces,
roundToStepPrecision,
+2 -2
View File
@@ -7,7 +7,7 @@
correctly via the HTML element's class attribute.
-->
<script lang="ts">
import clsx from 'clsx';
import { cn } from '$shared/lib';
import type {
Component,
Snippet,
@@ -32,7 +32,7 @@ let { icon: Icon, class: className, attrs = {} }: Props = $props();
</script>
{#if Icon}
{@const __iconClass__ = clsx('size-4', className)}
{@const __iconClass__ = cn('size-4', className)}
<!-- Render icon component dynamically with class prop -->
<Icon
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,
} from './buildQueryString/buildQueryString';
export { clampNumber } from './clampNumber/clampNumber';
export { cn } from './cn';
export { debounce } from './debounce/debounce';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
+2 -2
View File
@@ -3,11 +3,11 @@
Pill badge with border and optional status dot.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import {
type LabelSize,
labelSizeConfig,
} from '$shared/ui/Label/config';
import clsx from 'clsx';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
@@ -64,7 +64,7 @@ let {
</script>
<span
class={clsx(
class={cn(
'inline-flex items-center gap-1 px-2 py-0.5 border rounded-full',
'font-mono uppercase tracking-wide',
labelSizeConfig[size],
+10 -11
View File
@@ -3,7 +3,7 @@
design-system button. Uppercase, zero border-radius, Space Grotesk.
-->
<script lang="ts">
import clsx from 'clsx';
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
import type {
@@ -71,7 +71,7 @@ let {
const isIconOnly = $derived(!!icon && !children);
const variantStyles: Record<ButtonVariant, string> = {
primary: clsx(
primary: cn(
'bg-swiss-red text-white',
'hover:bg-swiss-red/90',
'active:bg-swiss-red/80',
@@ -87,7 +87,7 @@ const variantStyles: Record<ButtonVariant, string> = {
'disabled:cursor-not-allowed',
'disabled:transform-none',
),
secondary: clsx(
secondary: cn(
'bg-surface dark:bg-paper',
'text-swiss-black dark:text-neutral-200',
'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:cursor-not-allowed',
),
outline: clsx(
outline: cn(
'bg-transparent',
'text-swiss-black dark:text-neutral-200',
'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:cursor-not-allowed',
),
ghost: clsx(
ghost: cn(
'bg-transparent',
'text-secondary',
'border border-transparent',
@@ -119,7 +119,7 @@ const variantStyles: Record<ButtonVariant, string> = {
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
'disabled:cursor-not-allowed',
),
icon: clsx(
icon: cn(
'bg-surface dark:bg-dark-bg',
'text-secondary',
'border border-transparent',
@@ -130,8 +130,8 @@ const variantStyles: Record<ButtonVariant, string> = {
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
'disabled:cursor-not-allowed',
),
tertiary: clsx(
// Font override — must come after base in clsx() to win via tailwind-merge
tertiary: cn(
// Font override — must come after base in cn() to win via tailwind-merge
'font-secondary font-medium normal-case tracking-normal',
// Inactive state
'bg-transparent',
@@ -168,14 +168,13 @@ const iconSizeStyles: Record<ButtonSize, string> = {
const activeStyles: Partial<Record<ButtonVariant, string>> = {
secondary: 'bg-paper dark:bg-paper shadow-sm border-black/20 dark:border-white/20',
tertiary:
'bg-paper dark:bg-dark-card border-black/10 dark:border-white/10 shadow-sm text-neutral-900 dark:text-neutral-100',
tertiary: 'bg-paper dark:bg-dark-card border-black/10 dark:border-white/10 shadow-sm text-brand dark:text-brand',
ghost: 'bg-transparent dark:bg-transparent text-brand dark:text-brand',
outline: 'bg-surface dark:bg-paper border-brand',
icon: 'bg-paper dark:bg-paper text-brand border-subtle',
};
const classes = $derived(clsx(
const classes = $derived(cn(
// Base
'inline-flex items-center justify-center',
'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.
-->
<script lang="ts">
import clsx from 'clsx';
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
@@ -23,7 +23,7 @@ let { children, class: className, ...rest }: Props = $props();
</script>
<div
class={clsx(
class={cn(
'flex items-center gap-1 p-1',
'bg-surface dark:bg-dark-bg',
'border border-subtle',
@@ -106,7 +106,7 @@ let selected = $state(false);
<div class="flex items-center gap-4">
<ToggleButton
{...args}
selected={selected}
{selected}
onclick={() => {
selected = !selected;
}}
@@ -5,12 +5,12 @@
-->
<script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { cn } from '$shared/lib';
import { Slider } from '$shared/ui';
import { Button } from '$shared/ui/Button';
import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus';
import { Popover } from 'bits-ui';
import clsx from 'clsx';
import TechText from '../TechText/TechText.svelte';
interface Props {
@@ -78,7 +78,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
-->
{#if reduced}
<div
class={clsx(
class={cn(
'flex gap-4 items-end w-full',
className,
)}
@@ -98,7 +98,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
<!-- ── FULL MODE ──────────────────────────────────────────────────────────────── -->
{:else}
<div class={clsx('flex items-center px-1 relative', className)}>
<div class={cn('flex items-center px-1 relative', className)}>
<!-- Decrease button -->
<Button
variant="icon"
@@ -119,7 +119,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
{#snippet child({ props })}
<button
{...props}
class={clsx(
class={cn(
'flex flex-col items-center justify-center w-14 py-1',
'select-none rounded-none transition-all duration-150',
'border border-transparent',
@@ -3,7 +3,7 @@
Labeled container for form controls
-->
<script lang="ts">
import clsx from 'clsx';
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
interface Props {
@@ -24,7 +24,7 @@ interface Props {
const { label, children, class: className }: Props = $props();
</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">
{label}
</div>
+2 -2
View File
@@ -3,7 +3,7 @@
1px separator line, horizontal or vertical.
-->
<script lang="ts">
import clsx from 'clsx';
import { cn } from '$shared/lib';
interface Props {
/**
@@ -24,7 +24,7 @@ let {
</script>
<div
class={clsx(
class={cn(
'bg-black/10 dark:bg-white/10',
orientation === 'horizontal' ? 'w-full h-px' : 'w-px h-full',
className,
+2 -2
View File
@@ -4,11 +4,11 @@
-->
<script lang="ts">
import type { Filter } from '$shared/lib';
import { cn } from '$shared/lib';
import { Button } from '$shared/ui';
import { Label } from '$shared/ui';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import clsx from 'clsx';
import { cubicOut } from 'svelte/easing';
import {
draw,
@@ -68,7 +68,7 @@ $effect(() => {
</svg>
{/snippet}
<div class={clsx('flex flex-col', className)}>
<div class={cn('flex flex-col', className)}>
<Label
variant="default"
size="sm"
+4 -4
View File
@@ -3,7 +3,7 @@
Provides classes for styling footnotes
-->
<script lang="ts">
import clsx from 'clsx';
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
interface Props {
@@ -26,14 +26,14 @@ const { children, class: className, render }: Props = $props();
{#if render}
{@render render({
class: clsx(
class: cn(
'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft',
className,
),
})}
})}
{:else if children}
<span
class={clsx(
class={cn(
'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft',
className,
)}
+5 -5
View File
@@ -3,8 +3,8 @@
design-system input. Zero border-radius, Space Grotesk, precise states.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import XIcon from '@lucide/svelte/icons/x';
import clsx from 'clsx';
import type { Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing';
import type { HTMLInputAttributes } from 'svelte/elements';
@@ -90,7 +90,7 @@ const hasRightSlot = $derived(!!rightIcon || showClearButton);
const cfg = $derived(inputSizeConfig[size]);
const styles = $derived(inputVariantConfig[variant]);
const inputClasses = $derived(clsx(
const inputClasses = $derived(cn(
'font-primary rounded-none outline-none transition-all duration-200',
'text-neutral-900 dark:text-neutral-100',
'placeholder:text-neutral-400 dark:placeholder:text-neutral-600',
@@ -107,8 +107,8 @@ const inputClasses = $derived(clsx(
));
</script>
<div class={clsx('flex flex-col gap-1', fullWidth && 'w-full')}>
<div class={clsx('relative group', fullWidth && 'w-full')}>
<div class={cn('flex flex-col gap-1', fullWidth && 'w-full')}>
<div class={cn('relative group', fullWidth && 'w-full')}>
<!-- Left icon slot -->
{#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">
@@ -147,7 +147,7 @@ const inputClasses = $derived(clsx(
<!-- Helper / error text -->
{#if helperText}
<span
class={clsx(
class={cn(
'text-2xs font-mono tracking-wide px-1',
error ? 'text-brand ' : 'text-secondary',
)}
+2 -2
View File
@@ -3,7 +3,7 @@
Inline monospace label. The base primitive for all micrographic text.
-->
<script lang="ts">
import clsx from 'clsx';
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import {
type LabelFont,
@@ -72,7 +72,7 @@ let {
</script>
<span
class={clsx(
class={cn(
'font-mono tracking-widest leading-none',
'inline-flex items-center gap-1.5',
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
-->
<script lang="ts">
import { cn } from '$shared/lib';
import { Badge } from '$shared/ui';
import clsx from 'clsx';
interface Props {
/**
@@ -18,7 +18,7 @@ const { class: className }: Props = $props();
const title = 'GLYPHDIFF';
</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">
{title}
</h1>
@@ -5,7 +5,7 @@
-->
<script lang="ts">
import type { PerspectiveManager } from '$shared/lib';
import clsx from 'clsx';
import { cn } from '$shared/lib';
import { type Snippet } from 'svelte';
interface Props {
@@ -73,7 +73,7 @@ const isVisible = $derived(manager.isFront);
</script>
<div
class={clsx('will-change-transform', className)}
class={cn('will-change-transform', className)}
style:transform-style="preserve-3d"
style:transform={style?.transform}
style:filter={style?.filter}
+1 -1
View File
@@ -93,7 +93,7 @@ const flyParams: FlyParams = {
>
<div>
{#if headerTitle}
<SectionHeader title={headerTitle} subtitle={headerSubtitle} index={index} />
<SectionHeader title={headerTitle} subtitle={headerSubtitle} {index} />
{/if}
<SectionTitle text={title} />
</div>
@@ -3,8 +3,8 @@
Numbered section heading with optional subtitle and pulse dot.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import { Label } from '$shared/ui';
import clsx from 'clsx';
interface Props {
/**
@@ -41,7 +41,7 @@ let {
const indexStr = $derived(String(index).padStart(2, '0'));
</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">
{#if pulse}
<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.
-->
<script lang="ts">
import clsx from 'clsx';
import { cn } from '$shared/lib';
interface Props {
/**
@@ -15,4 +15,4 @@ interface Props {
const { class: className = '' }: Props = $props();
</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">
import type { ResponsiveManager } from '$shared/lib';
import clsx from 'clsx';
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
@@ -79,7 +79,7 @@ function close() {
The inner div stays w-80 so Sidebar layout never reflows mid-animation.
-->
<div
class={clsx(
class={cn(
'shrink-0 z-30 h-full relative',
'overflow-hidden',
'will-change-[width]',
+2 -2
View File
@@ -3,7 +3,7 @@
Generic loading placeholder with shimmer animation.
-->
<script lang="ts">
import clsx from 'clsx';
import { cn } from '$shared/lib';
import type { HTMLAttributes } from 'svelte/elements';
interface Props extends HTMLAttributes<HTMLDivElement> {
@@ -18,7 +18,7 @@ let { class: className, animate = true, ...rest }: Props = $props();
</script>
<div
class={clsx(
class={cn(
'rounded-md bg-background-subtle/50 backdrop-blur-sm',
animate && 'animate-pulse',
className,
+2 -2
View File
@@ -3,8 +3,8 @@
A single key:value pair in Space Mono. Optional trailing divider.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import { Label } from '$shared/ui';
import clsx from 'clsx';
import type { ComponentProps } from 'svelte';
interface Props extends Pick<ComponentProps<typeof Label>, 'variant'> {
@@ -36,7 +36,7 @@ let {
}: Props = $props();
</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} size="xs" bold>{value}</Label>
</div>
+2 -2
View File
@@ -3,8 +3,8 @@
Renders multiple Stat components in a row with auto-separators.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import { Stat } from '$shared/ui';
import clsx from 'clsx';
import type { ComponentProps } from 'svelte';
interface StatItem extends Partial<Pick<ComponentProps<typeof Stat>, 'variant'>> {
@@ -26,7 +26,7 @@ interface Props {
let { stats, class: className }: Props = $props();
</script>
<div class={clsx('flex items-center gap-4', className)}>
<div class={cn('flex items-center gap-4', className)}>
{#each stats as stat, i}
<Stat
label={stat.label}
+2 -2
View File
@@ -3,13 +3,13 @@
Monospace <code> element for technical values, measurements, identifiers.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import {
type LabelSize,
type LabelVariant,
labelSizeConfig,
labelVariantConfig,
} from '$shared/ui/Label/config';
import clsx from 'clsx';
import type { Snippet } from 'svelte';
interface Props {
@@ -42,7 +42,7 @@ let {
</script>
<code
class={clsx(
class={cn(
'font-mono tracking-tight tabular-nums',
labelSizeConfig[size],
labelVariantConfig[variant],
+5 -5
View File
@@ -10,8 +10,8 @@
-->
<script lang="ts" generics="T">
import { createVirtualizer } from '$shared/lib';
import { cn } from '$shared/lib';
import { throttle } from '$shared/lib/utils';
import clsx from 'clsx';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
@@ -293,7 +293,7 @@ $effect(() => {
isFullyVisible: row.isFullyVisible,
isPartiallyVisible: row.isPartiallyVisible,
proximity: row.proximity,
})}
})}
{/if}
</div>
{:else}
@@ -305,7 +305,7 @@ $effect(() => {
isFullyVisible: row.isFullyVisible,
isPartiallyVisible: row.isPartiallyVisible,
proximity: row.proximity,
})}
})}
{/if}
</div>
{/if}
@@ -324,13 +324,13 @@ $effect(() => {
{/snippet}
{#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()}
</div>
{:else}
<div
bind:this={viewportRef}
class={clsx(
class={cn(
'relative overflow-y-auto overflow-x-hidden',
'rounded-md bg-background',
'w-full',
+6
View File
@@ -70,6 +70,12 @@ export {
*/
default as Label,
} from './Label/Label.svelte';
export {
/**
* Styled link with optional icon
*/
default as Link,
} from './Link/Link.svelte';
export {
/**
* Full-page or component-level progress spinner
+2 -1
View File
@@ -1,2 +1,3 @@
export * from './utils/dotTransition';
export * from './utils/getPretextFontString';
export * from './utils/ensureCanvasFonts/ensureCanvasFonts';
export * from './utils/getPretextFontString/getPretextFontString';
@@ -0,0 +1,301 @@
import {
afterEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { getPretextFontString } from '../getPretextFontString/getPretextFontString';
import { ensureCanvasFonts } from './ensureCanvasFonts';
const FALLBACK_FAMILY = '__glyphdiff_no_such_font_42__';
const fallbackFont = (sizePx: number) => getPretextFontString(400, sizePx, FALLBACK_FAMILY);
/**
* Fake Canvas2D context that returns a scripted width per font string.
* Tracks how many times measureText was called so tests can assert polling
* behavior without depending on wall-clock time.
*/
function createFakeCtx() {
const widthsByFont = new Map<string, number | (() => number)>();
const measureCalls: Array<{ font: string; text: string }> = [];
const ctx = {
font: '',
measureText(text: string) {
measureCalls.push({ font: ctx.font, text });
const entry = widthsByFont.get(ctx.font);
const width = typeof entry === 'function' ? entry() : entry ?? 0;
return { width };
},
};
return {
ctx: ctx as unknown as CanvasRenderingContext2D,
widthsByFont,
measureCalls,
};
}
interface MockGlobals {
fontsLoad: ReturnType<typeof vi.fn>;
rafCalls: number;
nowValues: number[];
nowIndex: { current: number };
restore: () => void;
}
function installGlobals(opts: {
/** Sequence of values returned by performance.now(); last value repeats. */
nowSequence: number[];
/** If true, OffscreenCanvas is defined and getContext returns the fake ctx. */
useOffscreenCanvas: boolean;
ctx: CanvasRenderingContext2D | null;
}): MockGlobals {
const fontsLoad = vi.fn().mockResolvedValue([]);
const originals: Array<[string, PropertyDescriptor | undefined]> = [];
const setGlobal = (key: string, value: unknown) => {
originals.push([key, Object.getOwnPropertyDescriptor(globalThis, key)]);
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
};
setGlobal('document', {
fonts: { load: fontsLoad },
createElement: vi.fn(() => ({
getContext: vi.fn(() => opts.ctx),
})),
});
if (opts.useOffscreenCanvas) {
class FakeOffscreenCanvas {
getContext() {
return opts.ctx;
}
}
setGlobal('OffscreenCanvas', FakeOffscreenCanvas);
} else {
setGlobal('OffscreenCanvas', undefined);
}
const nowIndex = { current: 0 };
setGlobal('performance', {
now: () => opts.nowSequence[Math.min(nowIndex.current++, opts.nowSequence.length - 1)],
});
let rafCount = 0;
const rafState = { count: 0 };
setGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafState.count++;
rafCount++;
Promise.resolve().then(() => cb(0));
return rafCount;
});
return {
fontsLoad,
get rafCalls() {
return rafState.count;
},
nowValues: opts.nowSequence,
nowIndex,
restore() {
for (const [key, desc] of originals) {
if (desc) {
Object.defineProperty(globalThis, key, desc);
} else {
delete (globalThis as any)[key];
}
}
},
} as MockGlobals;
}
describe('ensureCanvasFonts', () => {
let cleanup: (() => void) | undefined;
afterEach(() => {
cleanup?.();
cleanup = undefined;
});
it('awaits document.fonts.load for every font string', async () => {
const { ctx, widthsByFont } = createFakeCtx();
const fontA = getPretextFontString(400, 36, 'Roboto');
const fontB = getPretextFontString(400, 36, 'Smooch Sans');
// Real font width clearly differs from the unknown-family fallback width.
widthsByFont.set(fontA, 200);
widthsByFont.set(fontB, 130);
// The fallback probe uses the same size with an unknown family.
widthsByFont.set(fallbackFont(36), 280);
const mocks = installGlobals({
nowSequence: [0, 5],
useOffscreenCanvas: true,
ctx,
});
cleanup = mocks.restore;
await ensureCanvasFonts([fontA, fontB]);
expect(mocks.fontsLoad).toHaveBeenCalledTimes(2);
expect(mocks.fontsLoad).toHaveBeenCalledWith(fontA);
expect(mocks.fontsLoad).toHaveBeenCalledWith(fontB);
});
it('returns without polling when fonts already measure as non-fallback', async () => {
const { ctx, widthsByFont } = createFakeCtx();
const font = getPretextFontString(400, 36, 'Roboto');
widthsByFont.set(font, 200);
widthsByFont.set(fallbackFont(36), 280);
const mocks = installGlobals({
nowSequence: [0, 5],
useOffscreenCanvas: true,
ctx,
});
cleanup = mocks.restore;
await ensureCanvasFonts([font]);
// First iteration succeeds → no rAF needed
expect(mocks.rafCalls).toBe(0);
});
it('polls via requestAnimationFrame until measurement diverges from fallback', async () => {
const { ctx, widthsByFont, measureCalls } = createFakeCtx();
const font = getPretextFontString(400, 36, 'Roboto');
widthsByFont.set(fallbackFont(36), 280);
// Roboto reports the fallback width for the first two reads, then resolves.
let robotoReads = 0;
widthsByFont.set(font, () => {
robotoReads++;
return robotoReads <= 2 ? 280 : 200;
});
// Provide enough now() values for: initial fallback measurement +
// multiple loop iterations within the deadline.
const mocks = installGlobals({
nowSequence: [0, 10, 20, 30, 40, 50],
useOffscreenCanvas: true,
ctx,
});
cleanup = mocks.restore;
await ensureCanvasFonts([font]);
// Two iterations failed → two rAF awaits before success on the third.
expect(mocks.rafCalls).toBe(2);
// Measurement was called once for the fallback probe + three poll attempts.
const robotoCalls = measureCalls.filter(c => c.font === font).length;
expect(robotoCalls).toBe(3);
});
it('exits when performance.now passes the 1s deadline even if fonts never load', async () => {
const { ctx, widthsByFont } = createFakeCtx();
const font = getPretextFontString(400, 36, 'NeverLoads');
widthsByFont.set(fallbackFont(36), 280);
// Always returns fallback width → poll never finds a divergence.
widthsByFont.set(font, 280);
const mocks = installGlobals({
// Start at 0, then the next check jumps past the 1000ms deadline.
nowSequence: [0, 0, 1001],
useOffscreenCanvas: true,
ctx,
});
cleanup = mocks.restore;
await expect(ensureCanvasFonts([font])).resolves.toBeUndefined();
});
it('returns early when no canvas context is available', async () => {
const mocks = installGlobals({
nowSequence: [0],
useOffscreenCanvas: false,
ctx: null,
});
cleanup = mocks.restore;
await expect(
ensureCanvasFonts([getPretextFontString(400, 16, 'X')]),
).resolves.toBeUndefined();
// fonts.load still ran; just no canvas polling.
expect(mocks.fontsLoad).toHaveBeenCalledTimes(1);
expect(mocks.rafCalls).toBe(0);
});
it('falls back to a DOM canvas when OffscreenCanvas is unavailable', async () => {
const { ctx, widthsByFont } = createFakeCtx();
const font = getPretextFontString(400, 36, 'Roboto');
widthsByFont.set(font, 200);
widthsByFont.set(fallbackFont(36), 280);
const mocks = installGlobals({
nowSequence: [0, 5],
useOffscreenCanvas: false,
ctx,
});
cleanup = mocks.restore;
await ensureCanvasFonts([font]);
expect((globalThis as any).document.createElement).toHaveBeenCalledWith('canvas');
});
it('uses the font size from each font string for the fallback probe', async () => {
const { ctx, widthsByFont, measureCalls } = createFakeCtx();
const fontA = getPretextFontString(400, 24, 'FontA');
const fontB = getPretextFontString(700, 48, 'FontB');
widthsByFont.set(fallbackFont(24), 150);
widthsByFont.set(fallbackFont(48), 360);
widthsByFont.set(fontA, 100);
widthsByFont.set(fontB, 200);
const mocks = installGlobals({
nowSequence: [0, 5],
useOffscreenCanvas: true,
ctx,
});
cleanup = mocks.restore;
await ensureCanvasFonts([fontA, fontB]);
const fallbackFonts = measureCalls
.map(c => c.font)
.filter(f => f.includes(FALLBACK_FAMILY));
expect(fallbackFonts).toContain(fallbackFont(24));
expect(fallbackFonts).toContain(fallbackFont(48));
});
it('removes a font from the pending set as soon as it diverges, leaving others to poll', async () => {
const { ctx, widthsByFont, measureCalls } = createFakeCtx();
const fontA = getPretextFontString(400, 36, 'A');
const fontB = getPretextFontString(400, 36, 'B');
widthsByFont.set(fallbackFont(36), 280);
// A loads immediately; B takes one extra frame.
widthsByFont.set(fontA, 200);
let bReads = 0;
widthsByFont.set(fontB, () => {
bReads++;
return bReads === 1 ? 280 : 150;
});
const mocks = installGlobals({
nowSequence: [0, 10, 20, 30, 40],
useOffscreenCanvas: true,
ctx,
});
cleanup = mocks.restore;
await ensureCanvasFonts([fontA, fontB]);
// A measured once (resolved iter 1). B measured twice (iter 1 fallback, iter 2 real).
const aCalls = measureCalls.filter(c => c.font === fontA).length;
const bCalls = measureCalls.filter(c => c.font === fontB).length;
expect(aCalls).toBe(1);
expect(bCalls).toBe(2);
expect(mocks.rafCalls).toBe(1);
});
});
@@ -0,0 +1,62 @@
/**
* Ensures a set of fonts is usable in a `<canvas>` measurement context.
*
* `document.fonts.load()` resolves once the FontFace bytes are fetched and
* parsed, but Chrome lazily registers fonts with the canvas measurement engine
* after that `measureText` keeps returning a fallback width for some frames
* even though `document.fonts.check()` reports the font as loaded.
*
* Pretext caches measurements per font string forever, so a single fallback
* measurement during initial mount permanently poisons the cache and the
* comparison morph boundary drifts visibly from the thumb. This helper polls
* canvas measurement until each font reports a width that differs from the
* "unknown font family" fallback, guaranteeing the next `measureText` call
* sees the real glyph metrics.
*/
import { getPretextFontString } from '../getPretextFontString/getPretextFontString';
const PROBE_TEXT = 'mmmmmmmmmm';
const MAX_WAIT_MS = 1000;
const DEFAULT_PROBE_SIZE_PX = 16;
// Family unlikely to exist in any system — gives canvas's "unknown font" fallback width.
const FALLBACK_PROBE_FAMILY = '__glyphdiff_no_such_font_42__';
export async function ensureCanvasFonts(fontStrings: string[]): Promise<void> {
await Promise.all(fontStrings.map(f => document.fonts.load(f)));
// Pretext uses OffscreenCanvas when available; DOM canvas has separate font
// registration timing, so we MUST poll using the same canvas type pretext does.
const ctx = typeof OffscreenCanvas !== 'undefined'
? new OffscreenCanvas(1, 1).getContext('2d')
: document.createElement('canvas').getContext('2d');
if (!ctx) {
return;
}
// Measure each font's "unknown font" fallback width (different per browser, per OS).
// Canvas uses this same fallback for any font family it can't resolve, so when the
// requested font finally registers, measureText will return a non-fallback width.
const fallbackWidths = new Map<string, number>();
for (const font of fontStrings) {
const sizeMatch = font.match(/(\d+(?:\.\d+)?)px/);
const sizePx = sizeMatch ? parseFloat(sizeMatch[1]) : DEFAULT_PROBE_SIZE_PX;
ctx.font = getPretextFontString(400, sizePx, FALLBACK_PROBE_FAMILY);
fallbackWidths.set(font, ctx.measureText(PROBE_TEXT).width);
}
const deadline = performance.now() + MAX_WAIT_MS;
const pending = new Set(fontStrings);
while (pending.size > 0 && performance.now() < deadline) {
for (const font of Array.from(pending)) {
ctx.font = font;
const w = ctx.measureText(PROBE_TEXT).width;
if (Math.abs(w - fallbackWidths.get(font)!) > 0.5) {
pending.delete(font);
}
}
if (pending.size === 0) {
break;
}
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
}
}
@@ -54,7 +54,7 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte'
vi.mock('$entities/Font', async importOriginal => {
const actual = await importOriginal<typeof import('$entities/Font')>();
const { BatchFontStore } = await import(
'$entities/Font/model/store/batchFontStore.svelte'
'$entities/Font/model/store/batchFontStore/batchFontStore.svelte'
);
return {
...actual,
@@ -4,7 +4,7 @@
-->
<script lang="ts">
import { typographySettingsStore } from '$features/SetupFont';
import clsx from 'clsx';
import { cn } from '$shared/lib';
import { comparisonStore } from '../../model';
interface Props {
@@ -53,7 +53,7 @@ $effect(() => {
>
{#each [0, 1] as s (s)}
<span
class={clsx(
class={cn(
'char-inner',
'transition-colors duration-300',
isPast
@@ -32,7 +32,7 @@ $effect(() => {
<NavigationWrapper index={0} title="Comparison">
{#snippet content(action)}
<div class="flex h-screen w-full overflow-hidden bg-surface dark:bg-background">
<div class="flex h-dvh w-full overflow-hidden bg-surface dark:bg-background">
<!-- Sidebar -->
<SidebarContainer bind:isOpen={isSidebarOpen}>
{#snippet sidebar()}
@@ -101,6 +101,7 @@ function isFontReady(font: UnifiedFont): boolean {
data-font-list
weight={DEFAULT_FONT_WEIGHT}
itemHeight={44}
gap={2}
class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4"
>
{#snippet skeleton()}
@@ -5,8 +5,8 @@
<script lang="ts">
import { ThemeSwitch } from '$features/ChangeAppTheme';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import {
Badge,
Divider,
IconButton,
Input,
@@ -16,7 +16,6 @@ import {
} from '$shared/ui';
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
import clsx from 'clsx';
import { getContext } from 'svelte';
import { comparisonStore } from '../../model';
@@ -49,7 +48,7 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');
</script>
<header
class={clsx(
class={cn(
'flex items-center justify-between',
'px-4 md:px-8 py-4 md:py-6',
'h-16 md:h-20 z-20',
@@ -1,10 +1,11 @@
<!--
Component: Search
Typeface search input for the comparison view.
Updates the global filterManager query to filter the font list.
Writes through appliedFilterStore; the global bridge in $features/GetFonts
propagates the value into fontStore.
-->
<script lang="ts">
import { filterManager } from '$features/GetFonts';
import { appliedFilterStore } from '$features/GetFonts';
import { SearchBar } from '$shared/ui';
</script>
@@ -14,7 +15,7 @@ import { SearchBar } from '$shared/ui';
class="w-full"
placeholder="Typeface Search"
aria-label="Search typefaces"
bind:value={filterManager.queryValue}
bind:value={appliedFilterStore.queryValue}
fullWidth
/>
</div>
@@ -1,4 +1,4 @@
import { filterManager } from '$features/GetFonts';
import { appliedFilterStore } from '$features/GetFonts';
import {
render,
screen,
@@ -7,7 +7,7 @@ import Search from './Search.svelte';
describe('Search', () => {
beforeEach(() => {
filterManager.queryValue = '';
appliedFilterStore.queryValue = '';
});
describe('Rendering', () => {
@@ -23,8 +23,8 @@ describe('Search', () => {
});
describe('Value binding', () => {
it('reflects filterManager.queryValue as initial value', () => {
filterManager.queryValue = 'Inter';
it('reflects appliedFilterStore.queryValue as initial value', () => {
appliedFilterStore.queryValue = 'Inter';
render(Search);
expect(screen.getByRole('textbox')).toHaveValue('Inter');
});
@@ -5,12 +5,12 @@
Content (font list, controls) is injected via snippets.
-->
<script lang="ts">
import { cn } from '$shared/lib';
import {
ButtonGroup,
Label,
ToggleButton,
} from '$shared/ui';
import clsx from 'clsx';
import type { Snippet } from 'svelte';
import {
type Side,
@@ -40,7 +40,7 @@ let {
</script>
<div
class={clsx(
class={cn(
'flex flex-col h-full',
'w-80',
'bg-surface dark:bg-dark-bg',
@@ -14,15 +14,18 @@ import {
type ResponsiveManager,
debounce,
} from '$shared/lib';
import { cn } from '$shared/lib';
import {
CharacterComparisonEngine,
} from '$shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte';
import { Loader } from '$shared/ui';
import clsx from 'clsx';
import { getContext } from 'svelte';
import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition';
import { getPretextFontString } from '../../lib';
import {
ensureCanvasFonts,
getPretextFontString,
} from '../../lib';
import { comparisonStore } from '../../model';
import Character from '../Character/Character.svelte';
import Line from '../Line/Line.svelte';
@@ -61,6 +64,26 @@ const comparisonEngine = new CharacterComparisonEngine();
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, {
stiffness: 0.2,
damping: 0.7,
@@ -124,25 +147,35 @@ $effect(() => {
}
});
// Layout effect — depends on content, settings AND containerWidth.
// Awaits font loading into the canvas measurement context before invoking
// the engine; otherwise pretext caches fallback-font widths globally per
// font string, and the morph boundary drifts from the thumb visually.
$effect(() => {
const _text = comparisonStore.text;
const _weight = typography.weight;
const _size = typography.renderedSize;
const _height = typography.height;
const _spacing = typography.spacing;
const _width = containerWidth;
const _isMobile = isMobile;
if (!container || !fontA || !fontB || _width <= 0) {
return;
}
if (container && fontA && fontB) {
// PRETEXT API strings: "weight sizepx family"
const fontAStr = getPretextFontString(_weight, _size, fontA.name);
const fontBStr = getPretextFontString(_weight, _size, fontB.name);
// Use offsetWidth to avoid transform scaling issues
const width = container.offsetWidth;
const padding = isMobile ? 48 : 96;
const availableWidth = width - padding;
const padding = _isMobile ? 48 : 96;
const availableWidth = Math.max(0, _width - padding);
const lineHeight = _size * _height;
containerWidth = width;
let cancelled = false;
ensureCanvasFonts([fontAStr, fontBStr]).then(() => {
if (cancelled) {
return;
}
layoutResult = comparisonEngine.layout(
_text,
fontAStr,
@@ -152,31 +185,11 @@ $effect(() => {
_spacing,
_size,
);
}
});
});
$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,
);
}
return () => {
cancelled = true;
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
// Dynamic backgroundSize based on isMobile — can't express this in Tailwind.
@@ -198,10 +211,10 @@ const scaleClass = $derived(
Outer flex container — fills parent.
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 -->
<div
class={clsx(
class={cn(
'w-full h-full flex flex-col items-center justify-center relative',
'bg-paper dark:bg-dark-card',
'shadow-2xl shadow-black/5 dark:shadow-black/20',
@@ -270,11 +283,11 @@ const scaleClass = $derived(
<TypographyMenu
bind:open={isTypographyMenuOpen}
class={clsx(
'absolute z-50',
class={cn(
'absolute z-10',
responsive.isMobileOrTablet
? 'bottom-4 right-4 -translate-1/2'
: 'bottom-5 left-1/2 right-[unset] -translate-x-1/2',
? 'bottom-0 right-0 -translate-1/2'
: 'bottom-2.5 left-1/2 -translate-x-1/2',
)}
/>
</div>
@@ -4,7 +4,7 @@
1px red vertical rule with square handles at top and bottom.
-->
<script lang="ts">
import clsx from 'clsx';
import { cn } from '$shared/lib';
import { cubicOut } from 'svelte/easing';
import { fade } from 'svelte/transition';
@@ -31,7 +31,7 @@ let { sliderPos, isDragging }: Props = $props();
>
<!-- Top handle -->
<div
class={clsx(
class={cn(
'w-5 h-5 md:w-6 md:h-6',
'-ml-2.5 md:-ml-3',
'mt-2 md:mt-4',
@@ -47,7 +47,7 @@ let { sliderPos, isDragging }: Props = $props();
<!-- Bottom handle -->
<div
class={clsx(
class={cn(
'w-5 h-5 md:w-6 md:h-6',
'-ml-2.5 md:-ml-3',
'mb-2 md:mb-4',
@@ -3,12 +3,10 @@
Provides a search input and filtration for fonts
-->
<script lang="ts">
import { fontStore } from '$entities/Font';
import {
FilterControls,
Filters,
filterManager,
mapManagerToParams,
appliedFilterStore,
} from '$features/GetFonts';
import { springySlideFade } from '$shared/lib';
import {
@@ -16,7 +14,6 @@ import {
SearchBar,
} from '$shared/ui';
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
import { untrack } from 'svelte';
import { cubicOut } from 'svelte/easing';
import {
Tween,
@@ -34,11 +31,6 @@ interface Props {
let { showFilters = $bindable(true) }: Props = $props();
$effect(() => {
const params = mapManagerToParams(filterManager);
untrack(() => fontStore.setParams(params));
});
const transform = new Tween(
{ scale: 1, rotate: 0 },
{ duration: 250, easing: cubicOut },
@@ -66,7 +58,7 @@ function toggleFilters() {
class="w-full"
placeholder="Typeface Search"
aria-label="Search typefaces"
bind:value={filterManager.queryValue}
bind:value={appliedFilterStore.queryValue}
fullWidth
/>
@@ -5,8 +5,8 @@
<script lang="ts">
import { NavigationWrapper } from '$entities/Breadcrumb';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import { Section } from '$shared/ui';
import clsx from 'clsx';
import {
getContext,
untrack,
@@ -38,7 +38,7 @@ $effect(() => {
headerAction={registerAction}
>
{#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} />
</div>
{/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 { fontStore } from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import {
Label,
Section,
} from '$shared/ui';
import clsx from 'clsx';
import { getContext } from 'svelte';
import { layoutManager } from '../../model';
import LayoutSwitch from '../LayoutSwitch/LayoutSwitch.svelte';
@@ -50,7 +50,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
{/snippet}
{#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 />
</div>
{/snippet}
+1 -1
View File
@@ -1,6 +1,7 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext",
@@ -22,7 +23,6 @@
"verbatimModuleSyntax": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"$lib/*": ["./src/lib/*"],
"$app/*": ["./src/app/*"],
+4
View File
@@ -12,6 +12,10 @@ export default defineConfig({
restoreMocks: true,
setupFiles: ['./vitest.setup.component.ts', './vitest.setup.jsdom.ts'],
globals: true,
testTimeout: 15000,
pool: 'forks',
maxWorkers: 1,
isolate: false,
},
resolve: {
+1212 -1002
View File
File diff suppressed because it is too large Load Diff