Compare commits

..

83 Commits

Author SHA1 Message Date
Ilia Mashkov
66dcffa448 chore(storybook): replace viewport with defaultViewport 2026-04-18 11:04:10 +03:00
Ilia Mashkov
cca00fccaa chore(storybook): remove mobile stories and initialWidth prop from stories. The mobile view available throught viewport selector in the header 2026-04-18 11:03:43 +03:00
Ilia Mashkov
af05443763 chore(storybook): purge unused Providers props 2026-04-18 11:02:34 +03:00
Ilia Mashkov
99d92d487f feat(storybook): replace width with maxWidth for StoryStage 2026-04-18 11:01:36 +03:00
Ilia Mashkov
4a907619cc chore(storybook): purge custom viewports from storybook preview 2026-04-18 11:00:32 +03:00
Ilia Mashkov
6c69d7a5b3 test(ComparisonView): cover parts of the widget with tests 2026-04-18 01:19:01 +03:00
Ilia Mashkov
993812de0a test(GetFonts): add tests for Filters component behavior 2026-04-18 01:18:02 +03:00
Ilia Mashkov
67c16530af test(ChangeAppTheme): cover theme switcher component with tests 2026-04-18 01:17:25 +03:00
Ilia Mashkov
fbbb439023 test(Breadcrumb): add test for BreadcrumbHeader component 2026-04-18 01:16:45 +03:00
Ilia Mashkov
c2046770ef test(SampleList): add test coverage for LayoutSwitch component 2026-04-18 01:16:09 +03:00
Ilia Mashkov
adfba38063 test: exclude lucide from dependency optimization 2026-04-18 01:15:25 +03:00
Ilia Mashkov
dfb304d436 test: remove legacy tests and add new ones 2026-04-17 22:16:44 +03:00
Ilia Mashkov
f55043a1e7 test(Badge): cover Baddge with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
409dd1b229 test(Divider): cover Divider with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
9fbce095b2 test(Footnote): cover Footnote with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
171627e0ea test(Input): cover Input with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
d07fb1a3af test(Label): cover Label with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
6f84644ecb test(Loader): cover Loader with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
5ab5cda611 test(SearchBar): cover SearchBar with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
7975d9aeee test(Skeleton): cover Skeleton with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
2ba5fc0e3e test(Slider): cover Slider with tests 2026-04-17 20:24:09 +03:00
Ilia Mashkov
1947d7731e test(Stat): cover Stat with tests 2026-04-17 20:09:59 +03:00
Ilia Mashkov
38bfc4ba4b test(TechTech): cover TextTech with tests 2026-04-17 20:09:41 +03:00
Ilia Mashkov
6cf3047b74 test(Button): cover Button with tests 2026-04-17 19:20:13 +03:00
Ilia Mashkov
81363156d7 feat: set up vitest browser config for svelte components tests 2026-04-17 18:52:37 +03:00
Ilia Mashkov
bb65f1c8d6 feat: add missing storybook files and type template arguments properly 2026-04-17 18:01:24 +03:00
Ilia Mashkov
5eb9584797 feat(TypographyMenu): add bindable "open" prop to close popover from outside 2026-04-17 16:30:41 +03:00
Ilia Mashkov
bb5c3667b4 feat(SliderArea): utilize responsive breakpoints for TypographyMenu positioning 2026-04-17 14:39:25 +03:00
Ilia Mashkov
3711616a91 feat(TypograpyMenu): change custom button for existed Button component 2026-04-17 14:31:57 +03:00
Ilia Mashkov
6905c54040 chore: edit comments 2026-04-17 14:30:30 +03:00
Ilia Mashkov
1e8e22e2eb fix: edit tailwind variable name 2026-04-17 13:56:43 +03:00
Ilia Mashkov
8a93c7b545 chore: purge shadcn from codebase. Replace with bits-ui components and other tools 2026-04-17 13:37:44 +03:00
Ilia Mashkov
0004b81e40 chore(ComboControl): replace shadcn tooltip with the one from bits-ui 2026-04-17 13:20:47 +03:00
Ilia Mashkov
fb1d2765d0 chore: purge TooltipProvider 2026-04-17 13:20:01 +03:00
Ilia Mashkov
12e8bc0a89 chore: enforce brackets for if clause and for/while loops 2026-04-17 13:05:36 +03:00
Ilia Mashkov
cfaff46d59 chore: follow the general comments style 2026-04-17 12:14:55 +03:00
Ilia Mashkov
0ebf75b24e refactor: replace arbitrary text sizes in FontSampler, TypographyMenu; fix font token in SectionTitle 2026-04-17 09:42:24 +03:00
Ilia Mashkov
7b46e06f8b refactor: replace arbitrary text sizes in ComboControl, ControlGroup, Input, Slider, SectionHeader 2026-04-17 09:41:55 +03:00
Ilia Mashkov
0737db69a9 refactor: replace px text sizes in Button, Loader, Footnote with named tokens 2026-04-17 09:41:14 +03:00
Ilia Mashkov
64b4a65e7b refactor: replace arbitrary sizes in labelSizeConfig with named tokens 2026-04-17 09:40:53 +03:00
Ilia Mashkov
7f0d2b54e0 feat: add micro type scale and tracking-wider-mono tokens to @theme 2026-04-17 09:40:42 +03:00
Ilia Mashkov
5b1a1d0b0a fix: use Button's size prop instead of direct font-size class 2026-04-17 08:56:46 +03:00
Ilia Mashkov
0562b94b03 feat(Label): add font prop to purge custom classes 2026-04-17 08:55:38 +03:00
Ilia Mashkov
ef08512986 feat(Badge): add nowrap prop to purge custom classes 2026-04-17 08:54:29 +03:00
Ilia Mashkov
816d4b89ce refactor: tailwind tier 1 — border-subtle/text-secondary/focus-ring utilities + Input config extraction 2026-04-16 16:32:41 +03:00
Ilia Mashkov
aa1379c15b chore: remove unused code 2026-04-16 15:59:58 +03:00
Ilia Mashkov
33e589f041 feat: remove widgets from page 2026-04-16 15:58:33 +03:00
Ilia Mashkov
b12dc6257d feat(ComparisonView): add wrapper for search bar 2026-04-16 15:58:10 +03:00
Ilia Mashkov
35e0f06a77 feat(ComparisonView): add color transition for each character 2026-04-16 15:55:57 +03:00
Ilia Mashkov
dde187e0b2 chore: move ControlId type to the entities/Font layer 2026-04-16 11:19:17 +03:00
Ilia Mashkov
5a7c61ade7 feat(FontVirtualList): re-touch on weight change and pin visible fonts 2026-04-16 11:05:09 +03:00
Ilia Mashkov
d2bce85f9c feat(ComparisonStore): pin fontA/fontB to prevent eviction while on-screen 2026-04-16 10:55:41 +03:00
Ilia Mashkov
e509463911 chore: remove unused 2026-04-16 09:07:46 +03:00
Ilia Mashkov
db08f523f6 chore: move typography constants to the entity/Font layer 2026-04-16 09:05:34 +03:00
Ilia Mashkov
c5fa159c14 fix(FontList): remove weight prop, use default weight for FontList 2026-04-16 08:51:18 +03:00
Ilia Mashkov
8645c7dcc8 feat: use typographySettingsStore everywhere for the typography settings 2026-04-16 08:44:49 +03:00
Ilia Mashkov
fbeb84270b feat(Layout): remove breadcrumbs 2026-04-16 08:40:16 +03:00
Ilia Mashkov
c1ac9b5bc4 chore(SetupFont): rename controlManager to typographySettingsStore for better semantic 2026-04-16 08:22:08 +03:00
46d0d887b1 Merge pull request 'feature/unified-tanstack-query' (#36) from feature/unified-tanstack-query into main
All checks were successful
Workflow / build (push) Successful in 45s
Workflow / publish (push) Successful in 47s
Reviewed-on: #36
2026-04-16 04:53:28 +00:00
Ilia Mashkov
0a489a8adc fix(BaseQueryStore): use QueryObserverOptions instead of QueryOptions
All checks were successful
Workflow / build (pull_request) Successful in 58s
Workflow / publish (pull_request) Has been skipped
QueryOptions has queryKey as optional; QueryObserverOptions requires it,
matching what QueryObserver.constructor and setOptions actually expect.
2026-04-15 22:37:30 +03:00
Ilia Mashkov
cd349aec92 fix: imports 2026-04-15 22:32:45 +03:00
Ilia Mashkov
adaa6d7648 feat: refactor ComparisonStore to use BatchFontStore
Replace hand-rolled async fetching (fetchFontsByIds + isRestoring flag)
with BatchFontStore backed by TanStack Query. Three reactive effects
handle batch sync, CSS font loading, and default-font fallback.
isLoading now derives from batchStore.isLoading + fontsReady.
2026-04-15 22:25:34 +03:00
Ilia Mashkov
10f4781a67 test: enrich coverage for queryKeys, BaseQueryStore, and BatchFontStore
- queryKeys: add mutation-safety test for batch(), key hierarchy tests
  (list/batch/detail keys rooted in their parent base keys), and
  unique-key test for different detail IDs
- BaseQueryStore: add initial-state test (data undefined, isError false
  before any fetch resolves)
- BatchFontStore: add FontResponseError type assertion on malformed
  response, null error assertion on success, and setIds([]) disables
  query and returns empty fonts without triggering a fetch
2026-04-15 15:59:01 +03:00
Ilia Mashkov
f4a568832a feat: implement reactive BatchFontStore 2026-04-15 12:29:16 +03:00
Ilia Mashkov
4e9670118a feat: add seedFontCache utility 2026-04-15 12:21:04 +03:00
Ilia Mashkov
8e88d1b7cf feat: add BaseQueryStore for reactive query observers 2026-04-15 12:19:25 +03:00
Ilia Mashkov
1cbc262af7 feat: add stable query key factory 2026-04-15 12:06:32 +03:00
f072c5b270 Merge pull request 'fix/initial-fonts-loading' (#35) from fix/initial-fonts-loading into main
All checks were successful
Workflow / build (push) Successful in 46s
Workflow / publish (push) Successful in 45s
Reviewed-on: #35
2026-04-15 08:37:40 +00:00
Ilia Mashkov
bfa99cde20 fix(comparisonStore): add missing batch request and effect for initial font loading
All checks were successful
Workflow / build (pull_request) Successful in 3m8s
Workflow / publish (pull_request) Has been skipped
2026-04-15 11:35:37 +03:00
Ilia Mashkov
75b62265be fix: add missing export 2026-04-15 09:13:22 +03:00
5b81be6614 Merge pull request 'feature/pretext' (#34) from feature/pretext into main
Some checks failed
Workflow / build (push) Failing after 36s
Workflow / publish (push) Has been skipped
Reviewed-on: #34
2026-04-14 07:12:41 +00:00
Ilia Mashkov
a74abbb0b3 feat: wire createFontRowSizeResolver into SampleList for pretext-backed row heights
Some checks failed
Workflow / build (pull_request) Failing after 49s
Workflow / publish (pull_request) Has been skipped
2026-04-13 13:23:03 +03:00
Ilia Mashkov
20accb9c93 feat: implement createFontRowSizeResolver with canvas-measured heights and reactive status check 2026-04-13 08:54:19 +03:00
Ilia Mashkov
46b9db1db3 feat: export ItemSizeResolver type and document reactive estimateSize contract 2026-04-12 19:43:44 +03:00
Ilia Mashkov
4b017a83bb fix: add missing JSDoc, return types, and as-any comments to layout engines 2026-04-12 09:51:36 +03:00
Ilia Mashkov
49822f8af7 feat: install pretext library 2026-04-12 09:08:01 +03:00
Ilia Mashkov
338ca9b4fd feat: export TextLayoutEngine and CharacterComparisonEngine from shared helpers index
Remove deleted createCharacterComparison exports and benchmark.
2026-04-11 16:44:49 +03:00
Ilia Mashkov
99f662e2d5 fix: iterate pre-computed chars array in Line.svelte to fix unicode grapheme splitting bug 2026-04-11 16:26:41 +03:00
Ilia Mashkov
5977e0a0dc fix: correct advances null-check in CharacterComparisonEngine and remove unused TextLayoutEngine dep 2026-04-11 16:14:28 +03:00
Ilia Mashkov
2b0d8470e5 test: fix CharacterComparisonEngine tests — correct env directive, canvas mock, and full spec coverage 2026-04-11 16:14:24 +03:00
Ilia Mashkov
351ee9fd52 docs: add inline documentation to TextLayoutEngine 2026-04-11 16:10:01 +03:00
Ilia Mashkov
a526a51af8 test: fix TextLayoutEngine tests — correct jsdom directive placement and canvas mock setup
fix: correct grapheme-width fallback in TextLayoutEngine for null breakableFitAdvances
2026-04-11 15:48:52 +03:00
Ilia Mashkov
fcde78abad test: add canvas mock helper for pretext-based engine tests 2026-04-11 15:48:47 +03:00
211 changed files with 6405 additions and 4090 deletions

View File

@@ -41,7 +41,7 @@ jobs:
run: yarn lint
- name: Type Check
run: yarn check:shadcn-excluded
run: yarn check
publish:
needs: build # Only runs if tests/lint pass

View File

@@ -4,12 +4,11 @@
This provides:
- ResponsiveManager context for breakpoint tracking
- TooltipProvider for shadcn Tooltip components
- TooltipProvider for tooltip components
-->
<script lang="ts">
import { createResponsiveManager } from '$shared/lib';
import type { ResponsiveManager } from '$shared/lib';
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import { setContext } from 'svelte';
interface Props {
@@ -24,6 +23,4 @@ $effect(() => responsiveManager.init());
setContext<ResponsiveManager>('responsive', responsiveManager);
</script>
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
{@render children()}
</TooltipProvider>
{@render children()}

View File

@@ -1,14 +1,18 @@
<script lang="ts">
interface Props {
children: import('svelte').Snippet;
width?: string; // Optional width override
/**
* Tailwind max-width class applied to the card, or 'none' to remove width constraint.
* @default 'max-w-3xl'
*/
maxWidth?: string;
}
let { children, width = 'max-w-3xl' }: Props = $props();
let { children, maxWidth = 'max-w-3xl' }: Props = $props();
</script>
<div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
<div class="w-full bg-card shadow-lg ring-1 ring-border rounded-xl p-12 {width}">
<div class="w-full bg-card shadow-lg ring-1 ring-border rounded-xl p-12 {maxWidth !== 'none' ? maxWidth : ''}">
<div class="relative flex justify-center items-center text-foreground">
{@render children()}
</div>

View File

@@ -5,68 +5,6 @@ import ThemeDecorator from './ThemeDecorator.svelte';
import '../src/app/styles/app.css';
const preview: Preview = {
globalTypes: {
viewport: {
description: 'Viewport size for responsive design',
defaultValue: 'widgetWide',
toolbar: {
icon: 'view',
items: [
{
value: 'reset',
icon: 'refresh',
title: 'Reset viewport',
},
{
value: 'mobile1',
icon: 'mobile',
title: 'iPhone 5/SE',
},
{
value: 'mobile2',
icon: 'mobile',
title: 'iPhone 14 Pro Max',
},
{
value: 'tablet',
icon: 'tablet',
title: 'iPad (Portrait)',
},
{
value: 'desktop',
icon: 'desktop',
title: 'Desktop (Small)',
},
{
value: 'widgetMedium',
icon: 'view',
title: 'Widget Medium',
},
{
value: 'widgetWide',
icon: 'view',
title: 'Widget Wide',
},
{
value: 'widgetExtraWide',
icon: 'view',
title: 'Widget Extra Wide',
},
{
value: 'fullWidth',
icon: 'view',
title: 'Full Width',
},
{
value: 'fullScreen',
icon: 'expand',
title: 'Full Screen',
},
],
dynamicTitle: true,
},
},
},
parameters: {
layout: 'padded',
controls: {
@@ -195,10 +133,11 @@ const preview: Preview = {
},
}),
// Wrap with StoryStage for presentation styling
story => ({
(story, context) => ({
Component: StoryStage,
props: {
children: story(),
maxWidth: context.parameters.storyStage?.maxWidth,
},
}),
],

View File

@@ -8,14 +8,14 @@ A modern font exploration and comparison tool for browsing fonts from Google Fon
- **Side-by-Side Comparison**: Compare up to 4 fonts simultaneously with customizable text, size, and typography settings
- **Advanced Filtering**: Filter by category, provider, character subsets, and weight
- **Virtual Scrolling**: Fast, smooth browsing of thousands of fonts
- **Responsive UI**: Beautiful interface built with shadcn components and Tailwind CSS
- **Responsive UI**: Beautiful interface built with Tailwind CSS
- **Type-Safe**: Full TypeScript coverage with strict mode enabled
## Tech Stack
- **Framework**: Svelte 5 with reactive primitives (runes)
- **Styling**: Tailwind CSS v4
- **Components**: shadcn-svelte (via bits-ui)
- **Components**: Bits UI primitives
- **State Management**: TanStack Query for async data
- **Architecture**: Feature-Sliced Design (FSD)
- **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks)

View File

@@ -1,16 +0,0 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$shared/shadcn/ui",
"utils": "$shared/shadcn/utils/shadcn-utils",
"ui": "$shared/shadcn/ui",
"hooks": "$shared/shadcn/hooks",
"lib": "$shared"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

View File

@@ -31,7 +31,12 @@
"importDeclaration.forceMultiLine": "whenMultiple",
"importDeclaration.forceSingleLine": false,
"exportDeclaration.forceMultiLine": "whenMultiple",
"exportDeclaration.forceSingleLine": false
"exportDeclaration.forceSingleLine": false,
"ifStatement.useBraces": "always",
"whileStatement.useBraces": "always",
"forStatement.useBraces": "always",
"forInStatement.useBraces": "always",
"forOfStatement.useBraces": "always"
},
"json": {
"indentWidth": 2,

View File

@@ -17,7 +17,7 @@ pre-push:
run: yarn tsc --noEmit
svelte-check:
run: yarn check:shadcn-excluded --threshold warning
run: yarn check --threshold warning
format-check:
glob: "*.{ts,js,svelte,json,md}"

View File

@@ -11,7 +11,6 @@
"prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''",
"check": "svelte-check",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"check:shadcn-excluded": "svelte-check --no-tsconfig --ignore \"src/shared/shadcn\"",
"lint": "oxlint",
"format": "dprint fmt",
"format:check": "dprint check",
@@ -66,6 +65,7 @@
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@chenglou/pretext": "^0.0.5",
"@tanstack/svelte-query": "^6.0.14"
}
}

View File

@@ -7,7 +7,7 @@
/* Base font size */
--font-size: 16px;
/* GLYPHDIFF Swiss Design System */
/* GLYPHDIFF Design System */
/* Primary Colors */
--swiss-beige: #f3f0e9;
--swiss-red: #ff3b30;
@@ -91,7 +91,6 @@
--space-4xl: 4rem;
/* Typography Scale */
--text-2xs: 0.625rem;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
@@ -205,6 +204,14 @@
--font-mono: 'Space Mono', monospace;
--font-primary: 'Space Grotesk', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
/* Micro typography scale — extends Tailwind's text-xs (0.75rem) downward */
--text-5xs: 0.4375rem;
--text-4xs: 0.5rem;
--text-3xs: 0.5625rem;
--text-2xs: 0.625rem;
/* Monospace label tracking — used in Loader and Footnote */
--tracking-wider-mono: 0.2em;
}
@layer base {
@@ -265,6 +272,21 @@
}
}
@layer utilities {
/* 21× border-black/5 dark:border-white/10 → single token */
.border-subtle {
@apply border-black/5 dark:border-white/10;
}
/* Secondary text pair */
.text-secondary {
@apply text-neutral-500 dark:text-neutral-400;
}
/* Standard focus ring */
.focus-ring {
@apply focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2;
}
}
/* Global utility - useful across your app */
@media (prefers-reduced-motion: reduce) {
* {

View File

@@ -14,12 +14,10 @@
*
* - Footer area (currently empty, reserved for future use)
*/
import { BreadcrumbHeader } from '$entities/Breadcrumb';
import { themeManager } from '$features/ChangeAppTheme';
import GD from '$shared/assets/GD.svg';
import { ResponsiveProvider } from '$shared/lib';
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import clsx from 'clsx';
import {
type Snippet,
onDestroy,
@@ -80,24 +78,14 @@ onDestroy(() => themeManager.destroy());
<ResponsiveProvider>
<div
id="app-root"
class={cn(
class={clsx(
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
theme === 'dark' ? 'dark' : '',
)}
>
<header>
<BreadcrumbHeader />
</header>
<!-- <ScrollArea class="h-screen w-screen"> -->
<!-- <main class="flex-1 w-full mx-auto relative"> -->
<TooltipProvider>
{#if fontsReady}
{@render children?.()}
{/if}
</TooltipProvider>
<!-- </main> -->
<!-- </ScrollArea> -->
{#if fontsReady}
{@render children?.()}
{/if}
<footer></footer>
</div>
</ResponsiveProvider>

View File

@@ -34,11 +34,17 @@
* A breadcrumb item representing a tracked section
*/
export interface BreadcrumbItem {
/** Unique index for ordering */
/**
* Unique index for ordering
*/
index: number;
/** Display title for the breadcrumb */
/**
* Display title for the breadcrumb
*/
title: string;
/** DOM element to track */
/**
* DOM element to track
*/
element: HTMLElement;
}
@@ -50,21 +56,37 @@ export interface BreadcrumbItem {
* past while moving down the page.
*/
class ScrollBreadcrumbsStore {
/** All tracked breadcrumb items */
/**
* All tracked breadcrumb items
*/
#items = $state<BreadcrumbItem[]>([]);
/** Set of indices that have scrolled past (exited viewport while scrolling down) */
/**
* Set of indices that have scrolled past (exited viewport while scrolling down)
*/
#scrolledPast = $state<Set<number>>(new Set());
/** Intersection Observer instance */
/**
* Intersection Observer instance
*/
#observer: IntersectionObserver | null = null;
/** Offset for smooth scrolling (sticky header height) */
/**
* Offset for smooth scrolling (sticky header height)
*/
#scrollOffset = 0;
/** Current scroll direction */
/**
* Current scroll direction
*/
#isScrollingDown = $state(false);
/** Previous scroll Y position to determine direction */
/**
* Previous scroll Y position to determine direction
*/
#prevScrollY = 0;
/** Throttled scroll handler */
/**
* Throttled scroll handler
*/
#handleScroll: (() => void) | null = null;
/** Listener count for cleanup */
/**
* Listener count for cleanup
*/
#listenerCount = 0;
/**
@@ -83,13 +105,17 @@ class ScrollBreadcrumbsStore {
* (fires as soon as any part of element crosses viewport edge).
*/
#initObserver(): void {
if (this.#observer) return;
if (this.#observer) {
return;
}
this.#observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
const item = this.#items.find(i => i.element === entry.target);
if (!item) continue;
if (!item) {
continue;
}
if (!entry.isIntersecting && this.#isScrollingDown) {
// Element exited viewport while scrolling DOWN - add to breadcrumbs
@@ -141,17 +167,23 @@ class ScrollBreadcrumbsStore {
this.#detachScrollListener();
}
/** All tracked items sorted by index */
/**
* All tracked items sorted by index
*/
get items(): BreadcrumbItem[] {
return this.#items.slice().sort((a, b) => a.index - b.index);
}
/** Items that have scrolled past viewport top (visible in breadcrumbs) */
/**
* Items that have scrolled past viewport top (visible in breadcrumbs)
*/
get scrolledPastItems(): BreadcrumbItem[] {
return this.items.filter(item => this.#scrolledPast.has(item.index));
}
/** Index of the most recently scrolled item (active section) */
/**
* Index of the most recently scrolled item (active section)
*/
get activeIndex(): number | null {
const past = this.scrolledPastItems;
return past.length > 0 ? past[past.length - 1].index : null;
@@ -171,7 +203,9 @@ class ScrollBreadcrumbsStore {
* @param offset - Scroll offset in pixels (for sticky headers)
*/
add(item: BreadcrumbItem, offset = 0): void {
if (this.#items.find(i => i.index === item.index)) return;
if (this.#items.find(i => i.index === item.index)) {
return;
}
this.#scrollOffset = offset;
this.#items.push(item);
@@ -188,7 +222,9 @@ class ScrollBreadcrumbsStore {
*/
remove(index: number): void {
const item = this.#items.find(i => i.index === index);
if (!item) return;
if (!item) {
return;
}
this.#observer?.unobserve(item.element);
this.#items = this.#items.filter(i => i.index !== index);
@@ -209,7 +245,9 @@ class ScrollBreadcrumbsStore {
*/
scrollTo(index: number, container: HTMLElement | Window = window): void {
const item = this.#items.find(i => i.index === index);
if (!item) return;
if (!item) {
return;
}
const rect = item.element.getBoundingClientRect();
const scrollTop = container === window ? window.scrollY : (container as HTMLElement).scrollTop;

View File

@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import {
afterEach,
beforeEach,
@@ -24,7 +26,9 @@ class MockIntersectionObserver implements IntersectionObserver {
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.callbacks.push(callback);
if (options?.rootMargin) this.rootMargin = options.rootMargin;
if (options?.rootMargin) {
this.rootMargin = options.rootMargin;
}
if (options?.threshold) {
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold];
}
@@ -118,7 +122,9 @@ describe('ScrollBreadcrumbsStore', () => {
(event: string, listener: EventListenerOrEventListenerObject) => {
if (event === 'scroll') {
const index = scrollListeners.indexOf(listener as () => void);
if (index > -1) scrollListeners.splice(index, 1);
if (index > -1) {
scrollListeners.splice(index, 1);
}
}
return undefined;
},

View File

@@ -0,0 +1,65 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
const { Story } = defineMeta({
title: 'Entities/BreadcrumbHeader',
component: BreadcrumbHeader,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Fixed header that slides in when the user scrolls past tracked page sections. Reads `scrollBreadcrumbsStore.scrolledPastItems` — renders nothing when the list is empty. Requires the `responsive` context provided by `Providers`.',
},
story: { inline: false },
},
layout: 'fullscreen',
},
argTypes: {},
});
</script>
<script>
import Providers from '$shared/lib/storybook/Providers.svelte';
import BreadcrumbHeaderSeeded from './BreadcrumbHeaderSeeded.svelte';
</script>
<Story
name="With Breadcrumbs"
parameters={{
docs: {
description: {
story:
'Three sections are registered with the breadcrumb store. The story scrolls the iframe so the IntersectionObserver marks them as scrolled-past, revealing the fixed header.',
},
},
}}
>
{#snippet template()}
<Providers>
<BreadcrumbHeaderSeeded />
</Providers>
{/snippet}
</Story>
<Story
name="Empty"
parameters={{
docs: {
description: {
story:
'No sections registered — BreadcrumbHeader renders nothing. This is the initial state before the user scrolls past any tracked section.',
},
},
}}
>
{#snippet template()}
<Providers>
<div style="padding: 2rem; color: #888; font-size: 0.875rem;">
BreadcrumbHeader renders nothing when scrolledPastItems is empty.
</div>
<BreadcrumbHeader />
</Providers>
{/snippet}
</Story>

View File

@@ -44,7 +44,7 @@ function createButtonText(item: BreadcrumbItem) {
flex items-center justify-between
z-40
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
border-b border-black/5 dark:border-white/10
border-b border-subtle
"
>
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">

View File

@@ -0,0 +1,11 @@
import { render } from '@testing-library/svelte';
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
const context = new Map([['responsive', { isMobile: false, isMobileOrTablet: false }]]);
describe('BreadcrumbHeader', () => {
it('renders nothing when no sections have been scrolled past', () => {
const { container } = render(BreadcrumbHeader, { context });
expect(container.firstElementChild).toBeNull();
});
});

View File

@@ -0,0 +1,49 @@
<script>
import { onMount } from 'svelte';
import { scrollBreadcrumbsStore } from '../../model';
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
const sections = [
{ index: 100, title: 'Introduction' },
{ index: 101, title: 'Typography' },
{ index: 102, title: 'Spacing' },
];
/** @type {HTMLDivElement} */
let container;
onMount(() => {
for (const section of sections) {
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
}
/*
* Scroll past the sections so IntersectionObserver marks them as
* scrolled-past, making scrolledPastItems non-empty and the header visible.
*/
setTimeout(() => {
window.scrollTo({ top: 2000, behavior: 'instant' });
}, 100);
return () => {
for (const { index } of sections) {
scrollBreadcrumbsStore.remove(index);
}
window.scrollTo({ top: 0, behavior: 'instant' });
};
});
</script>
<div bind:this={container} style="height: 2400px; padding-top: 900px;">
{#each sections as section}
<div
data-story-index={section.index}
style="height: 400px; padding: 2rem; background: #f5f5f5; margin-bottom: 1rem;"
>
{section.title} — scroll up to see the breadcrumb header
</div>
{/each}
</div>
<BreadcrumbHeader />

View File

@@ -0,0 +1,109 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import NavigationWrapper from './NavigationWrapper.svelte';
const { Story } = defineMeta({
title: 'Entities/NavigationWrapper',
component: NavigationWrapper,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Thin wrapper that registers an HTML section with `scrollBreadcrumbsStore` via a Svelte use-directive action. Has no visual output of its own — renders `{@render content(registerBreadcrumb)}` where `registerBreadcrumb` is the action to attach with `use:`. On destroy the section is automatically removed from the store.',
},
story: { inline: false },
},
layout: 'fullscreen',
},
argTypes: {
index: {
control: { type: 'number', min: 0 },
description: 'Unique index used for ordering in the breadcrumb trail',
},
title: {
control: 'text',
description: 'Display title shown in the breadcrumb header',
},
offset: {
control: { type: 'number', min: 0 },
description: 'Scroll offset in pixels to account for sticky headers',
},
},
});
</script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
</script>
<Story
name="Single Section"
args={{ index: 0, title: 'Introduction', offset: 96 }}
parameters={{
docs: {
description: {
story:
'A single section registered with NavigationWrapper. The `content` snippet receives the `register` action and applies it via `use:register`.',
},
},
}}
>
{#snippet template(args: ComponentProps<typeof NavigationWrapper>)}
<NavigationWrapper {...args}>
{#snippet content(register)}
<section use:register style="padding: 2rem; background: #f5f5f5; min-height: 200px;">
<p style="font-size: 0.875rem; color: #555;">
Section registered as <strong>{args.title}</strong> at index {args.index}. Scroll past this
section to see it appear in the breadcrumb header.
</p>
</section>
{/snippet}
</NavigationWrapper>
{/snippet}
</Story>
<Story
name="Multiple Sections"
parameters={{
docs: {
description: {
story:
'Three sequential sections each wrapped in NavigationWrapper with distinct indices and titles. Demonstrates how the breadcrumb trail builds as the user scrolls.',
},
},
}}
>
{#snippet template()}
<div style="display: flex; flex-direction: column; gap: 0;">
<NavigationWrapper index={0} title="Introduction" offset={96}>
{#snippet content(register)}
<section use:register style="padding: 2rem; background: #f5f5f5; min-height: 300px;">
<h2 style="margin: 0 0 0.5rem;">Introduction</h2>
<p style="font-size: 0.875rem; color: #555;">
Registered as section 0. Scroll down to build the breadcrumb trail.
</p>
</section>
{/snippet}
</NavigationWrapper>
<NavigationWrapper index={1} title="Typography" offset={96}>
{#snippet content(register)}
<section use:register style="padding: 2rem; background: #ebebeb; min-height: 300px;">
<h2 style="margin: 0 0 0.5rem;">Typography</h2>
<p style="font-size: 0.875rem; color: #555;">Registered as section 1.</p>
</section>
{/snippet}
</NavigationWrapper>
<NavigationWrapper index={2} title="Spacing" offset={96}>
{#snippet content(register)}
<section use:register style="padding: 2rem; background: #e0e0e0; min-height: 300px;">
<h2 style="margin: 0 0 0.5rem;">Spacing</h2>
<p style="font-size: 0.875rem; color: #555;">Registered as section 2.</p>
</section>
{/snippet}
</NavigationWrapper>
</div>
{/snippet}
</Story>

View File

@@ -19,10 +19,13 @@ vi.mock('$shared/api/api', () => ({
}));
import { api } from '$shared/api/api';
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
seedFontCache,
} from './proxyFonts';
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
@@ -46,6 +49,7 @@ function mockApiGet<T>(data: T) {
describe('proxyFonts', () => {
beforeEach(() => {
vi.mocked(api.get).mockReset();
queryClient.clear();
});
describe('fetchProxyFonts', () => {
@@ -168,4 +172,33 @@ describe('proxyFonts', () => {
expect(result).toEqual([]);
});
});
describe('seedFontCache', () => {
test('should populate cache with multiple fonts', () => {
const fonts = [
createMockFont({ id: '1', name: 'A' }),
createMockFont({ id: '2', name: 'B' }),
];
seedFontCache(fonts);
expect(queryClient.getQueryData(fontKeys.detail('1'))).toEqual(fonts[0]);
expect(queryClient.getQueryData(fontKeys.detail('2'))).toEqual(fonts[1]);
});
test('should update existing cached fonts with new data', () => {
const id = 'update-me';
queryClient.setQueryData(fontKeys.detail(id), createMockFont({ id, name: 'Old' }));
const updated = createMockFont({ id, name: 'New' });
seedFontCache([updated]);
expect(queryClient.getQueryData(fontKeys.detail(id))).toEqual(updated);
});
test('should handle empty input arrays gracefully', () => {
const spy = vi.spyOn(queryClient, 'setQueryData');
seedFontCache([]);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
});
});

View File

@@ -11,13 +11,23 @@
*/
import { api } from '$shared/api/api';
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type { UnifiedFont } from '../../model/types';
import type {
FontCategory,
FontSubset,
} from '../../model/types';
/**
* Normalizes cache by seeding individual font entries from collection responses.
* This ensures that a font fetched in a list or batch is available via its detail key.
*
* @param fonts - Array of fonts to cache
*/
export function seedFontCache(fonts: UnifiedFont[]): void {
fonts.forEach(font => {
queryClient.setQueryData(fontKeys.detail(font.id), font);
});
}
/**
* Proxy API base URL
@@ -87,16 +97,24 @@ export interface ProxyFontsParams extends QueryParams {
* Includes pagination metadata alongside font data
*/
export interface ProxyFontsResponse {
/** Array of unified font objects */
/**
* List of font objects returned by the proxy
*/
fonts: UnifiedFont[];
/** Total number of fonts matching the query */
/**
* Total number of matching fonts (ignoring limit/offset)
*/
total: number;
/** Limit used for this request */
/**
* Page size used for the request
*/
limit: number;
/** Offset used for this request */
/**
* Start index for the result set
*/
offset: number;
}
@@ -179,7 +197,9 @@ export async function fetchProxyFontById(
* @returns Promise resolving to an array of fonts
*/
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
if (ids.length === 0) return [];
if (ids.length === 0) {
return [];
}
const queryString = ids.join(',');
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;

View File

@@ -1,3 +1,4 @@
export * from './api';
export * from './lib';
export * from './model';
export * from './ui';

View File

@@ -3,7 +3,9 @@ import type {
UnifiedFont,
} from '../../model';
/** Valid font weight values (100-900 in increments of 100) */
/**
* Valid font weight values (100-900 in increments of 100)
*/
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
/**

View File

@@ -48,3 +48,6 @@ export {
FontNetworkError,
FontResponseError,
} from './errors/errors';
export { createFontRowSizeResolver } from './sizeResolver/createFontRowSizeResolver';
export type { FontRowSizeResolverOptions } from './sizeResolver/createFontRowSizeResolver';

View File

@@ -1,31 +1,3 @@
/**
* Mock font filter data
*
* Factory functions and preset mock data for font-related filters.
* Used in Storybook stories for font filtering components.
*
* ## Usage
*
* ```ts
* import {
* createMockFilter,
* MOCK_FILTERS,
* } from '$entities/Font/lib/mocks';
*
* // Create a custom filter
* const customFilter = createMockFilter({
* properties: [
* { id: 'option1', name: 'Option 1', value: 'option1' },
* { id: 'option2', name: 'Option 2', value: 'option2', selected: true },
* ],
* });
*
* // Use preset filters
* const categoriesFilter = MOCK_FILTERS.categories;
* const subsetsFilter = MOCK_FILTERS.subsets;
* ```
*/
import type {
FontCategory,
FontProvider,
@@ -34,13 +6,13 @@ import type {
import type { Property } from '$shared/lib';
import { createFilter } from '$shared/lib';
// TYPE DEFINITIONS
/**
* Options for creating a mock filter
*/
export interface MockFilterOptions {
/** Filter properties */
/**
* Initial set of properties for the mock filter
*/
properties: Property<string>[];
}
@@ -48,16 +20,20 @@ export interface MockFilterOptions {
* Preset mock filters for font filtering
*/
export interface MockFilters {
/** Provider filter (Google, Fontshare) */
/**
* Provider filter (Google, Fontshare)
*/
providers: ReturnType<typeof createFilter<'google' | 'fontshare'>>;
/** Category filter (sans-serif, serif, display, etc.) */
/**
* Category filter (sans-serif, serif, display, etc.)
*/
categories: ReturnType<typeof createFilter<FontCategory>>;
/** Subset filter (latin, latin-ext, cyrillic, etc.) */
/**
* Subset filter (latin, latin-ext, cyrillic, etc.)
*/
subsets: ReturnType<typeof createFilter<FontSubset>>;
}
// FONT CATEGORIES
/**
* Unified categories (combines both providers)
*/
@@ -71,8 +47,6 @@ export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
{ id: 'script', name: 'Script', value: 'script' },
];
// FONT SUBSETS
/**
* Common font subsets
*/
@@ -85,8 +59,6 @@ export const FONT_SUBSETS: Property<FontSubset>[] = [
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
];
// FONT PROVIDERS
/**
* Font providers
*/
@@ -95,8 +67,6 @@ export const FONT_PROVIDERS: Property<FontProvider>[] = [
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
];
// FILTER FACTORIES
/**
* Create a mock filter from properties
*/
@@ -139,8 +109,6 @@ export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
return createFilter<FontProvider>({ properties });
}
// PRESET FILTERS
/**
* Preset mock filters - use these directly in stories
*/
@@ -216,8 +184,6 @@ export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
}),
};
// GENERIC FILTER MOCKS
/**
* Create a mock filter with generic string properties
* Useful for testing generic filter components
@@ -239,7 +205,9 @@ export function createGenericFilter(
* Preset generic filters for testing
*/
export const GENERIC_FILTERS = {
/** Small filter with 3 items */
/**
* Small filter with 3 items
*/
small: createFilter({
properties: [
{ id: 'option-1', name: 'Option 1', value: 'option-1' },
@@ -247,7 +215,9 @@ export const GENERIC_FILTERS = {
{ id: 'option-3', name: 'Option 3', value: 'option-3' },
],
}),
/** Medium filter with 6 items */
/**
* Medium filter with 6 items
*/
medium: createFilter({
properties: [
{ id: 'alpha', name: 'Alpha', value: 'alpha' },
@@ -258,7 +228,9 @@ export const GENERIC_FILTERS = {
{ id: 'zeta', name: 'Zeta', value: 'zeta' },
],
}),
/** Large filter with 12 items */
/**
* Large filter with 12 items
*/
large: createFilter({
properties: [
{ id: 'jan', name: 'January', value: 'jan' },
@@ -275,7 +247,9 @@ export const GENERIC_FILTERS = {
{ id: 'dec', name: 'December', value: 'dec' },
],
}),
/** Filter with some pre-selected items */
/**
* Filter with some pre-selected items
*/
partial: createFilter({
properties: [
{ id: 'red', name: 'Red', value: 'red', selected: true },
@@ -284,7 +258,9 @@ export const GENERIC_FILTERS = {
{ id: 'yellow', name: 'Yellow', value: 'yellow', selected: false },
],
}),
/** Filter with all items selected */
/**
* Filter with all items selected
*/
allSelected: createFilter({
properties: [
{ id: 'cat', name: 'Cat', value: 'cat', selected: true },
@@ -292,7 +268,9 @@ export const GENERIC_FILTERS = {
{ id: 'bird', name: 'Bird', value: 'bird', selected: true },
],
}),
/** Empty filter (no items) */
/**
* Empty filter (no items)
*/
empty: createFilter({
properties: [],
}),

View File

@@ -51,23 +51,41 @@ import type {
* Options for creating a mock UnifiedFont
*/
export interface MockUnifiedFontOptions {
/** Unique identifier (default: derived from name) */
/**
* Unique identifier (default: derived from name)
*/
id?: string;
/** Font display name (default: 'Mock Font') */
/**
* Font display name (default: 'Mock Font')
*/
name?: string;
/** Font provider (default: 'google') */
/**
* Font provider (default: 'google')
*/
provider?: FontProvider;
/** Font category (default: 'sans-serif') */
/**
* Font category (default: 'sans-serif')
*/
category?: FontCategory;
/** Font subsets (default: ['latin']) */
/**
* Font subsets (default: ['latin'])
*/
subsets?: FontSubset[];
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
/**
* Font variants (default: ['regular', '700', 'italic', '700italic'])
*/
variants?: FontVariant[];
/** Style URLs (if not provided, mock URLs are generated) */
/**
* Style URLs (if not provided, mock URLs are generated)
*/
styles?: FontStyleUrls;
/** Metadata overrides */
/**
* Metadata overrides
*/
metadata?: Partial<FontMetadata>;
/** Features overrides */
/**
* Features overrides
*/
features?: Partial<FontFeatures>;
}

View File

@@ -1,8 +1,4 @@
/**
* ============================================================================
* MOCK FONT STORE HELPERS
* ============================================================================
*
* Factory functions and preset mock data for TanStack Query stores and state management.
* Used in Storybook stories for components that use reactive stores.
*
@@ -35,27 +31,73 @@ import {
generateMockFonts,
} from './fonts.mock';
// TANSTACK QUERY MOCK TYPES
/**
* Mock TanStack Query state
*/
export interface MockQueryState<TData = unknown, TError = Error> {
/**
* Primary query status (pending, success, error)
*/
status: QueryStatus;
/**
* Payload data (present on success)
*/
data?: TData;
/**
* Caught error object (present on error)
*/
error?: TError;
/**
* True if initial load is in progress
*/
isLoading?: boolean;
/**
* True if background fetch is in progress
*/
isFetching?: boolean;
/**
* True if query resolved successfully
*/
isSuccess?: boolean;
/**
* True if query failed
*/
isError?: boolean;
/**
* True if query is waiting to be executed
*/
isPending?: boolean;
/**
* Timestamp of last successful data retrieval
*/
dataUpdatedAt?: number;
/**
* Timestamp of last recorded error
*/
errorUpdatedAt?: number;
/**
* Total number of consecutive failures
*/
failureCount?: number;
/**
* Detailed reason for the last failure
*/
failureReason?: TError;
/**
* Number of times an error has been caught
*/
errorUpdateCount?: number;
/**
* True if currently refetching in background
*/
isRefetching?: boolean;
/**
* True if refetch attempt failed
*/
isRefetchError?: boolean;
/**
* True if query is paused (e.g. offline)
*/
isPaused?: boolean;
}
@@ -63,26 +105,72 @@ export interface MockQueryState<TData = unknown, TError = Error> {
* Mock TanStack Query observer result
*/
export interface MockQueryObserverResult<TData = unknown, TError = Error> {
/**
* Current observer status
*/
status?: QueryStatus;
/**
* Cached or active data payload
*/
data?: TData;
/**
* Caught error from the observer
*/
error?: TError;
/**
* Loading flag for the observer
*/
isLoading?: boolean;
/**
* Fetching flag for the observer
*/
isFetching?: boolean;
/**
* Success flag for the observer
*/
isSuccess?: boolean;
/**
* Error flag for the observer
*/
isError?: boolean;
/**
* Pending flag for the observer
*/
isPending?: boolean;
/**
* Last update time for data
*/
dataUpdatedAt?: number;
/**
* Last update time for error
*/
errorUpdatedAt?: number;
/**
* Consecutive failure count
*/
failureCount?: number;
/**
* Failure reason object
*/
failureReason?: TError;
/**
* Error count for the observer
*/
errorUpdateCount?: number;
/**
* Refetching flag
*/
isRefetching?: boolean;
/**
* Refetch error flag
*/
isRefetchError?: boolean;
/**
* Paused flag
*/
isPaused?: boolean;
}
// TANSTACK QUERY MOCK FACTORIES
/**
* Create a mock query state for TanStack Query
*/
@@ -138,33 +226,53 @@ export function createSuccessState<TData>(data: TData): MockQueryObserverResult<
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
}
// FONT STORE MOCKS
/**
* Mock UnifiedFontStore state
*/
export interface MockFontStoreState {
/** All cached fonts */
/**
* Map of mock fonts indexed by ID
*/
fonts: Record<string, UnifiedFont>;
/** Current page */
/**
* Currently active page number
*/
page: number;
/** Total pages available */
/**
* Total number of pages calculated from limit
*/
totalPages: number;
/** Items per page */
/**
* Number of items per page
*/
limit: number;
/** Total font count */
/**
* Total number of available fonts
*/
total: number;
/** Loading state */
/**
* Store-level loading status
*/
isLoading: boolean;
/** Error state */
/**
* Caught error object
*/
error: Error | null;
/** Search query */
/**
* Mock search filter string
*/
searchQuery: string;
/** Selected provider */
/**
* Mock provider filter selection
*/
provider: 'google' | 'fontshare' | 'all';
/** Selected category */
/**
* Mock category filter selection
*/
category: string | null;
/** Selected subset */
/**
* Mock subset filter selection
*/
subset: string | null;
}
@@ -210,10 +318,12 @@ export function createMockFontStoreState(
}
/**
* Preset font store states
* Preset font store states for UI testing
*/
export const MOCK_FONT_STORE_STATES = {
/** Initial loading state */
/**
* Initial loading state with no data
*/
loading: createMockFontStoreState({
isLoading: true,
fonts: {},
@@ -221,7 +331,9 @@ export const MOCK_FONT_STORE_STATES = {
page: 1,
}),
/** Empty state (no fonts found) */
/**
* State with no fonts matching filters
*/
empty: createMockFontStoreState({
fonts: {},
total: 0,
@@ -229,7 +341,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** First page with fonts */
/**
* First page of results (10 items)
*/
firstPage: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]),
@@ -241,7 +355,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** Second page with fonts */
/**
* Second page of results (10 items)
*/
secondPage: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]),
@@ -253,7 +369,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** Last page with fonts */
/**
* Final page of results (5 items)
*/
lastPage: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]),
@@ -265,7 +383,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** Error state */
/**
* Terminal failure state
*/
error: createMockFontStoreState({
fonts: {},
error: new Error('Failed to load fonts'),
@@ -274,7 +394,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** With search query */
/**
* State with active search query
*/
withSearch: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]),
@@ -285,7 +407,9 @@ export const MOCK_FONT_STORE_STATES = {
searchQuery: 'Roboto',
}),
/** Filtered by category */
/**
* State with active category filter
*/
filteredByCategory: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS)
@@ -299,7 +423,9 @@ export const MOCK_FONT_STORE_STATES = {
category: 'serif',
}),
/** Filtered by provider */
/**
* State with active provider filter
*/
filteredByProvider: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS)
@@ -313,7 +439,9 @@ export const MOCK_FONT_STORE_STATES = {
provider: 'google',
}),
/** Large dataset */
/**
* Large collection for performance testing (50 items)
*/
largeDataset: createMockFontStoreState({
fonts: Object.fromEntries(
generateMockFonts(50).map(font => [font.id, font]),
@@ -326,17 +454,30 @@ export const MOCK_FONT_STORE_STATES = {
}),
};
// MOCK STORE OBJECT
/**
* Create a mock store object that mimics TanStack Query behavior
* Useful for components that subscribe to store properties
*/
export function createMockStore<T>(config: {
/**
* Reactive data payload
*/
data?: T;
/**
* Loading status flag
*/
isLoading?: boolean;
/**
* Error status flag
*/
isError?: boolean;
/**
* Catch-all error object
*/
error?: Error;
/**
* Background fetching flag
*/
isFetching?: boolean;
}) {
const {
@@ -348,50 +489,81 @@ export function createMockStore<T>(config: {
} = config;
return {
/**
* Returns the active data payload
*/
get data() {
return data;
},
/**
* True if initially loading
*/
get isLoading() {
return isLoading;
},
/**
* True if last request failed
*/
get isError() {
return isError;
},
/**
* Returns the caught error object
*/
get error() {
return error;
},
/**
* True if fetching in background
*/
get isFetching() {
return isFetching;
},
/**
* True if query is stable and has data
*/
get isSuccess() {
return !isLoading && !isError && data !== undefined;
},
/**
* Returns semantic status string
*/
get status() {
if (isLoading) return 'pending';
if (isError) return 'error';
if (isLoading) {
return 'pending';
}
if (isError) {
return 'error';
}
return 'success';
},
};
}
/**
* Preset mock stores
* Preset mock stores for common UI states
*/
export const MOCK_STORES = {
/** Font store in loading state */
/**
* Initial loading state
*/
loadingFontStore: createMockStore<UnifiedFont[]>({
isLoading: true,
data: undefined,
}),
/** Font store with fonts loaded */
/**
* Successful data load state
*/
successFontStore: createMockStore<UnifiedFont[]>({
data: Object.values(UNIFIED_FONTS),
isLoading: false,
isError: false,
}),
/** Font store with error */
/**
* API error state
*/
errorFontStore: createMockStore<UnifiedFont[]>({
data: undefined,
isLoading: false,
@@ -399,7 +571,9 @@ export const MOCK_STORES = {
error: new Error('Failed to load fonts'),
}),
/** Font store with empty results */
/**
* Empty result set state
*/
emptyFontStore: createMockStore<UnifiedFont[]>({
data: [],
isLoading: false,
@@ -414,36 +588,69 @@ export const MOCK_STORES = {
const mockState = createMockFontStoreState(state);
return {
// State properties
/**
* Collection of mock fonts
*/
get fonts() {
return mockState.fonts;
},
/**
* Current mock page
*/
get page() {
return mockState.page;
},
/**
* Total mock pages
*/
get totalPages() {
return mockState.totalPages;
},
/**
* Mock items per page
*/
get limit() {
return mockState.limit;
},
/**
* Total mock items
*/
get total() {
return mockState.total;
},
/**
* Mock loading status
*/
get isLoading() {
return mockState.isLoading;
},
/**
* Mock error status
*/
get error() {
return mockState.error;
},
/**
* Mock search string
*/
get searchQuery() {
return mockState.searchQuery;
},
/**
* Mock provider filter
*/
get provider() {
return mockState.provider;
},
/**
* Mock category filter
*/
get category() {
return mockState.category;
},
/**
* Mock subset filter
*/
get subset() {
return mockState.subset;
},
@@ -464,15 +671,45 @@ export const MOCK_STORES = {
* Matches FontStore's public API for Storybook use
*/
fontStore: (config: {
/**
* Preset font list
*/
fonts?: UnifiedFont[];
/**
* Total item count
*/
total?: number;
/**
* Items per page
*/
limit?: number;
/**
* Pagination offset
*/
offset?: number;
/**
* Loading flag
*/
isLoading?: boolean;
/**
* Fetching flag
*/
isFetching?: boolean;
/**
* Error flag
*/
isError?: boolean;
/**
* Catch-all error object
*/
error?: Error | null;
/**
* Has more pages flag
*/
hasMore?: boolean;
/**
* Current page number
*/
page?: number;
} = {}) => {
const {
@@ -495,27 +732,51 @@ export const MOCK_STORES = {
return {
// State getters
/**
* Current mock parameters
*/
get params() {
return state.params;
},
/**
* Mock font list
*/
get fonts() {
return mockFonts;
},
/**
* Mock loading state
*/
get isLoading() {
return isLoading;
},
/**
* Mock fetching state
*/
get isFetching() {
return isFetching;
},
/**
* Mock error state
*/
get isError() {
return isError;
},
/**
* Mock error object
*/
get error() {
return error;
},
/**
* Mock empty state check
*/
get isEmpty() {
return !isLoading && !isFetching && mockFonts.length === 0;
},
/**
* Mock pagination metadata
*/
get pagination() {
return {
total: mockTotal,
@@ -527,18 +788,33 @@ export const MOCK_STORES = {
};
},
// Category getters
/**
* Derived sans-serif filter
*/
get sansSerifFonts() {
return mockFonts.filter(f => f.category === 'sans-serif');
},
/**
* Derived serif filter
*/
get serifFonts() {
return mockFonts.filter(f => f.category === 'serif');
},
/**
* Derived display filter
*/
get displayFonts() {
return mockFonts.filter(f => f.category === 'display');
},
/**
* Derived handwriting filter
*/
get handwritingFonts() {
return mockFonts.filter(f => f.category === 'handwriting');
},
/**
* Derived monospace filter
*/
get monospaceFonts() {
return mockFonts.filter(f => f.category === 'monospace');
},

View File

@@ -0,0 +1,168 @@
// @vitest-environment jsdom
import { TextLayoutEngine } from '$shared/lib';
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
import { clearCache } from '@chenglou/pretext';
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import type { FontLoadStatus } from '../../model/types';
import { mockUnifiedFont } from '../mocks';
import { createFontRowSizeResolver } from './createFontRowSizeResolver';
// Fixed-width canvas mock: every character is 10px wide regardless of font.
// This makes wrapping math predictable: N chars × 10px = N×10 total width.
const CHAR_WIDTH = 10;
const LINE_HEIGHT = 20;
const CONTAINER_WIDTH = 200;
const CONTENT_PADDING_X = 32; // p-4 × 2 sides = 32px
const CHROME_HEIGHT = 56;
const FALLBACK_HEIGHT = 220;
const FONT_SIZE_PX = 16;
describe('createFontRowSizeResolver', () => {
let statusMap: Map<string, FontLoadStatus>;
let getStatus: (key: string) => FontLoadStatus | undefined;
beforeEach(() => {
installCanvasMock((_font, text) => text.length * CHAR_WIDTH);
clearCache();
statusMap = new Map();
getStatus = key => statusMap.get(key);
});
function makeResolver(overrides?: Partial<Parameters<typeof createFontRowSizeResolver>[0]>) {
const font = mockUnifiedFont({ id: 'inter', name: 'Inter' });
return {
font,
resolver: createFontRowSizeResolver({
getFonts: () => [font],
getWeight: () => 400,
getPreviewText: () => 'Hello',
getContainerWidth: () => CONTAINER_WIDTH,
getFontSizePx: () => FONT_SIZE_PX,
getLineHeightPx: () => LINE_HEIGHT,
getStatus,
contentHorizontalPadding: CONTENT_PADDING_X,
chromeHeight: CHROME_HEIGHT,
fallbackHeight: FALLBACK_HEIGHT,
...overrides,
}),
};
}
it('returns fallbackHeight when font status is undefined', () => {
const { resolver } = makeResolver();
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight when font status is "loading"', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loading');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight when font status is "error"', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'error');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight when containerWidth is 0', () => {
const { resolver } = makeResolver({ getContainerWidth: () => 0 });
statusMap.set('inter@400', 'loaded');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight when previewText is empty', () => {
const { resolver } = makeResolver({ getPreviewText: () => '' });
statusMap.set('inter@400', 'loaded');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight for out-of-bounds rowIndex', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loaded');
expect(resolver(99)).toBe(FALLBACK_HEIGHT);
});
it('returns computed height (totalHeight + chromeHeight) when font is loaded', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loaded');
// 'Hello' = 5 chars × 10px = 50px. contentWidth = 200 - 32 = 168px. Fits on one line.
// totalHeight = 1 × LINE_HEIGHT = 20. result = 20 + CHROME_HEIGHT = 76.
const result = resolver(0);
expect(result).toBe(LINE_HEIGHT + CHROME_HEIGHT);
});
it('returns increased height when text wraps due to narrow container', () => {
// contentWidth = 40 - 32 = 8px — 'Hello' (50px) forces wrapping onto many lines
const { resolver } = makeResolver({ getContainerWidth: () => 40 });
statusMap.set('inter@400', 'loaded');
const result = resolver(0);
expect(result).toBeGreaterThan(LINE_HEIGHT + CHROME_HEIGHT);
});
it('does not call layout() again on second call with same arguments', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loaded');
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
resolver(0);
resolver(0);
expect(layoutSpy).toHaveBeenCalledTimes(1);
layoutSpy.mockRestore();
});
it('calls layout() again when containerWidth changes (cache miss)', () => {
let width = CONTAINER_WIDTH;
const { resolver } = makeResolver({ getContainerWidth: () => width });
statusMap.set('inter@400', 'loaded');
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
resolver(0);
width = 100;
resolver(0);
expect(layoutSpy).toHaveBeenCalledTimes(2);
layoutSpy.mockRestore();
});
it('returns greater height when container narrows (more wrapping)', () => {
let width = CONTAINER_WIDTH;
const { resolver } = makeResolver({ getContainerWidth: () => width });
statusMap.set('inter@400', 'loaded');
const h1 = resolver(0);
width = 100; // narrower → more wrapping
const h2 = resolver(0);
expect(h2).toBeGreaterThanOrEqual(h1);
});
it('uses variable font key for variable fonts', () => {
const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } });
const { resolver } = makeResolver({ getFonts: () => [vfFont] });
// Variable fonts use '{id}@vf' key, not '{id}@{weight}'
statusMap.set('roboto@vf', 'loaded');
const result = resolver(0);
expect(result).not.toBe(FALLBACK_HEIGHT);
expect(result).toBeGreaterThan(0);
});
it('returns fallbackHeight for variable font when static key is set instead', () => {
const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } });
const { resolver } = makeResolver({ getFonts: () => [vfFont] });
// Setting the static key should NOT unlock computed height for variable fonts
statusMap.set('roboto@400', 'loaded');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
});

View File

@@ -0,0 +1,134 @@
import { TextLayoutEngine } from '$shared/lib';
import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey';
import type {
FontLoadStatus,
UnifiedFont,
} from '../../model/types';
/**
* Options for {@link createFontRowSizeResolver}.
*
* All getter functions are called on every resolver invocation. When called
* inside a Svelte `$derived.by` block, any reactive state read within them
* (e.g. `SvelteMap.get()`) is automatically tracked as a dependency.
*/
export interface FontRowSizeResolverOptions {
/**
* Returns the current fonts array. Index `i` corresponds to row `i`.
*/
getFonts: () => UnifiedFont[];
/**
* Returns the active font weight (e.g. 400).
*/
getWeight: () => number;
/**
* Returns the preview text string.
*/
getPreviewText: () => string;
/**
* Returns the scroll container's inner width in pixels. Returns 0 before mount.
*/
getContainerWidth: () => number;
/**
* Returns the font size in pixels (e.g. `controlManager.renderedSize`).
*/
getFontSizePx: () => number;
/**
* Returns the computed line height in pixels.
* Typically `controlManager.height * controlManager.renderedSize`.
*/
getLineHeightPx: () => number;
/**
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
*
* In production: `(key) => appliedFontsManager.statuses.get(key)`.
* Injected for testability — avoids a module-level singleton dependency in tests.
* The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context
* for reactivity to work. This is satisfied when `itemHeight` is called by
* `createVirtualizer`'s `estimateSize`.
*/
getStatus: (fontKey: string) => FontLoadStatus | undefined;
/**
* Total horizontal padding of the text content area in pixels.
* Use the smallest breakpoint value (mobile `p-4` = 32px) to guarantee
* the content width is never over-estimated, keeping the height estimate safe.
*/
contentHorizontalPadding: number;
/**
* Fixed height in pixels of chrome that is not text content (header bar, etc.).
*/
chromeHeight: number;
/**
* Height in pixels to return when the font is not loaded or container width is 0.
*/
fallbackHeight: number;
}
/**
* Creates a row-height resolver for `FontSampler` rows in `VirtualList`.
*
* The returned function is suitable as the `itemHeight` prop of `VirtualList`.
* Pass it from the widget layer (`SampleList`) so that typography values from
* `controlManager` are injected as getter functions rather than imported directly,
* keeping `$entities/Font` free of `$features` dependencies.
*
* **Reactivity:** When the returned function reads `getStatus()` inside a
* `$derived.by` block (as `estimateSize` does in `createVirtualizer`), any
* `SvelteMap.get()` call within `getStatus` registers a Svelte 5 dependency.
* When a font's status changes to `'loaded'`, `offsets` recomputes automatically —
* no DOM snap occurs.
*
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
* prevents redundant `TextLayoutEngine.layout()` calls. The cache is invalidated
* naturally because a change in any input produces a different cache key.
*
* @param options - Configuration and getter functions (all injected for testability).
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
*/
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
const engine = new TextLayoutEngine();
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
const cache = new Map<string, number>();
return function resolveRowHeight(rowIndex: number): number {
const fonts = options.getFonts();
const font = fonts[rowIndex];
if (!font) {
return options.fallbackHeight;
}
const containerWidth = options.getContainerWidth();
const previewText = options.getPreviewText();
if (containerWidth <= 0 || !previewText) {
return options.fallbackHeight;
}
const weight = options.getWeight();
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
// Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(),
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
const status = options.getStatus(fontKey);
if (status !== 'loaded') {
return options.fallbackHeight;
}
const fontSizePx = options.getFontSizePx();
const lineHeightPx = options.getLineHeightPx();
const contentWidth = containerWidth - options.contentHorizontalPadding;
const fontCssString = `${weight} ${fontSizePx}px "${font.name}"`;
const cacheKey = `${fontCssString}|${previewText}|${contentWidth}|${lineHeightPx}`;
const cached = cache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx);
const result = totalHeight + options.chromeHeight;
cache.set(cacheKey, result);
return result;
};
}

View File

@@ -1,5 +1,5 @@
import type { ControlModel } from '$shared/lib';
import type { ControlId } from '..';
import type { ControlId } from '../types/typography';
/**
* Font size constants

View File

@@ -1,7 +1,3 @@
export {
appliedFontsManager,
createFontStore,
FontStore,
fontStore,
} from './store';
export * from './const/const';
export * from './store';
export * from './types';

View File

@@ -1,10 +1,10 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import { AppliedFontsManager } from './appliedFontsStore.svelte';
import { FontFetchError } from './errors';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
// ── Fake collaborators ────────────────────────────────────────────────────────
class FakeBufferCache {
async get(_url: string): Promise<ArrayBuffer> {
return new ArrayBuffer(8);
@@ -13,7 +13,9 @@ class FakeBufferCache {
clear(): void {}
}
/** Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure. */
/**
* Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure.
*/
class FailingBufferCache {
async get(url: string): Promise<never> {
throw new FontFetchError(url, new Error('network error'), 500);
@@ -22,8 +24,6 @@ class FailingBufferCache {
clear(): void {}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
id,
name: id,
@@ -32,8 +32,6 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
...overrides,
});
// ── Suite ─────────────────────────────────────────────────────────────────────
describe('AppliedFontsManager', () => {
let manager: AppliedFontsManager;
let eviction: FontEvictionPolicy;
@@ -66,8 +64,6 @@ describe('AppliedFontsManager', () => {
vi.unstubAllGlobals();
});
// ── touch() ───────────────────────────────────────────────────────────────
describe('touch()', () => {
it('queues and loads a new font', async () => {
manager.touch([makeConfig('roboto')]);
@@ -131,8 +127,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── queue processing ──────────────────────────────────────────────────────
describe('queue processing', () => {
it('filters non-critical weights in data-saver mode', async () => {
(navigator as any).connection = { saveData: true };
@@ -163,8 +157,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── Phase 1: fetch ────────────────────────────────────────────────────────
describe('Phase 1 — fetch', () => {
it('sets status to error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -209,8 +201,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── Phase 2: parse ────────────────────────────────────────────────────────
describe('Phase 2 — parse', () => {
it('sets status to error on parse failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -241,8 +231,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── #purgeUnused ──────────────────────────────────────────────────────────
describe('#purgeUnused', () => {
it('evicts fonts after TTL expires', async () => {
manager.touch([makeConfig('ephemeral')]);
@@ -300,8 +288,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── destroy() ─────────────────────────────────────────────────────────────
describe('destroy()', () => {
it('clears all statuses', async () => {
manager.touch([makeConfig('roboto')]);

View File

@@ -156,7 +156,9 @@ export class AppliedFontsManager {
}
}
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
/**
* Returns true if data-saver mode is enabled (defers non-critical weights).
*/
#shouldDeferNonCritical(): boolean {
return (navigator as any).connection?.saveData === true;
}
@@ -188,13 +190,11 @@ export class AppliedFontsManager {
const concurrency = getEffectiveConcurrency();
const buffers = new Map<string, ArrayBuffer>();
// ==================== PHASE 1: Concurrent Fetching ====================
// Fetch multiple font files in parallel since network I/O is non-blocking
for (let i = 0; i < entries.length; i += concurrency) {
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers);
}
// ==================== PHASE 2: Sequential Parsing ====================
// Parse buffers one at a time with periodic yields to avoid blocking UI
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
let lastYield = performance.now();
@@ -246,12 +246,16 @@ export class AppliedFontsManager {
);
for (const result of results) {
if (result.ok) continue;
if (result.ok) {
continue;
}
const { key, config, reason } = result;
const isAbort = reason instanceof FontFetchError
&& reason.cause instanceof Error
&& reason.cause.name === 'AbortError';
if (isAbort) continue;
if (isAbort) {
continue;
}
if (reason instanceof FontFetchError) {
console.error(`Font fetch failed: ${config.name}`, reason);
}
@@ -279,7 +283,9 @@ export class AppliedFontsManager {
}
}
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */
/**
* Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted.
*/
#purgeUnused() {
const now = Date.now();
// Iterate through all tracked font keys
@@ -291,7 +297,9 @@ export class AppliedFontsManager {
// Remove FontFace from document to free memory
const font = this.#loadedFonts.get(key);
if (font) document.fonts.delete(font);
if (font) {
document.fonts.delete(font);
}
// Evict from cache and cleanup URL mapping
const url = this.#urlByKey.get(key);
@@ -307,7 +315,9 @@ export class AppliedFontsManager {
}
}
/** Returns current loading status for a font, or undefined if never requested. */
/**
* Returns current loading status for a font, or undefined if never requested.
*/
getFontStatus(id: string, weight: number, isVariable = false) {
try {
return this.statuses.get(generateFontKey({ id, weight, isVariable }));
@@ -316,17 +326,23 @@ export class AppliedFontsManager {
}
}
/** Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. */
/**
* Pins a font so it is never evicted by #purgeUnused(), regardless of TTL.
*/
pin(id: string, weight: number, isVariable = false): void {
this.#eviction.pin(generateFontKey({ id, weight, isVariable }));
}
/** Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires. */
/**
* Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires.
*/
unpin(id: string, weight: number, isVariable = false): void {
this.#eviction.unpin(generateFontKey({ id, weight, isVariable }));
}
/** Waits for all fonts to finish loading using document.fonts.ready. */
/**
* Waits for all fonts to finish loading using document.fonts.ready.
*/
async ready(): Promise<void> {
if (typeof document === 'undefined') {
return;
@@ -336,7 +352,9 @@ export class AppliedFontsManager {
} catch { /* document unloaded */ }
}
/** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */
/**
* Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after.
*/
destroy() {
// Abort all in-flight network requests
this.#abortController.abort();
@@ -375,5 +393,7 @@ export class AppliedFontsManager {
}
}
/** Singleton instance — use throughout the application for unified font loading state. */
/**
* Singleton instance — use throughout the application for unified font loading state.
*/
export const appliedFontsManager = new AppliedFontsManager();

View File

@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import { FontFetchError } from '../../errors';
import { FontBufferCache } from './FontBufferCache';

View File

@@ -3,9 +3,13 @@ import { FontFetchError } from '../../errors';
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
interface FontBufferCacheOptions {
/** Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation. */
/**
* Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation.
*/
fetcher?: Fetcher;
/** Cache API cache name. Defaults to `'font-cache-v1'`. */
/**
* Cache API cache name. Defaults to `'font-cache-v1'`.
*/
cacheName?: string;
}
@@ -85,12 +89,16 @@ export class FontBufferCache {
return buffer;
}
/** Removes a URL from the in-memory cache. Next call to `get()` will re-fetch. */
/**
* Removes a URL from the in-memory cache. Next call to `get()` will re-fetch.
*/
evict(url: string): void {
this.#buffersByUrl.delete(url);
}
/** Clears all in-memory cached buffers. */
/**
* Clears all in-memory cached buffers.
*/
clear(): void {
this.#buffersByUrl.clear();
}

View File

@@ -1,5 +1,7 @@
interface FontEvictionPolicyOptions {
/** TTL in milliseconds. Defaults to 5 minutes. */
/**
* TTL in milliseconds. Defaults to 5 minutes.
*/
ttl?: number;
}
@@ -28,12 +30,16 @@ export class FontEvictionPolicy {
this.#usageTracker.set(key, now);
}
/** Pins a font key so it is never evicted regardless of TTL. */
/**
* Pins a font key so it is never evicted regardless of TTL.
*/
pin(key: string): void {
this.#pinnedFonts.add(key);
}
/** Unpins a font key, allowing it to be evicted once its TTL expires. */
/**
* Unpins a font key, allowing it to be evicted once its TTL expires.
*/
unpin(key: string): void {
this.#pinnedFonts.delete(key);
}
@@ -57,18 +63,24 @@ export class FontEvictionPolicy {
return now - lastUsed >= this.#TTL;
}
/** Returns an iterator over all tracked font keys. */
/**
* Returns an iterator over all tracked font keys.
*/
keys(): IterableIterator<string> {
return this.#usageTracker.keys();
}
/** Removes a font key from tracking. Called by the orchestrator after eviction. */
/**
* Removes a font key from tracking. Called by the orchestrator after eviction.
*/
remove(key: string): void {
this.#usageTracker.delete(key);
this.#pinnedFonts.delete(key);
}
/** Clears all usage timestamps and pinned keys. */
/**
* Clears all usage timestamps and pinned keys.
*/
clear(): void {
this.#usageTracker.clear();
this.#pinnedFonts.clear();

View File

@@ -34,22 +34,30 @@ export class FontLoadQueue {
return entries;
}
/** Returns `true` if the key is currently in the queue. */
/**
* Returns `true` if the key is currently in the queue.
*/
has(key: string): boolean {
return this.#queue.has(key);
}
/** Increments the retry count for a font key. */
/**
* Increments the retry count for a font key.
*/
incrementRetry(key: string): void {
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
}
/** Returns `true` if the font has reached or exceeded the maximum retry limit. */
/**
* Returns `true` if the font has reached or exceeded the maximum retry limit.
*/
isMaxRetriesReached(key: string): boolean {
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES;
}
/** Clears all queued fonts and resets all retry counts. */
/**
* Clears all queued fonts and resets all retry counts.
*/
clear(): void {
this.#queue.clear();
this.#retryCounts.clear();

View File

@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import { FontParseError } from '../../errors';
import { loadFont } from './loadFont';

View File

@@ -0,0 +1,93 @@
import { fontKeys } from '$shared/api/queryKeys';
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
import {
fetchFontsByIds,
seedFontCache,
} from '../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../lib/errors/errors';
import type { UnifiedFont } from '../../model/types';
/**
* Internal fetcher that seeds the cache and handles error wrapping.
* Standalone function to avoid 'this' issues during construction.
*/
async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
if (ids.length === 0) {
return [];
}
let response: UnifiedFont[];
try {
response = await fetchFontsByIds(ids);
} catch (cause) {
throw new FontNetworkError(cause);
}
if (!response || !Array.isArray(response)) {
throw new FontResponseError('batchResponse', response);
}
seedFontCache(response);
return response;
}
/**
* Reactive store for fetching and caching batches of fonts by ID.
* Integrates with TanStack Query via BaseQueryStore and handles
* normalized cache seeding.
*/
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> {
constructor(initialIds: string[] = []) {
super({
queryKey: fontKeys.batch(initialIds),
queryFn: () => fetchAndSeed(initialIds),
enabled: initialIds.length > 0,
retry: false,
});
}
/**
* Updates the IDs to fetch. Triggers a new query.
*
* @param ids - Array of font IDs
*/
setIds(ids: string[]): void {
this.updateOptions({
queryKey: fontKeys.batch(ids),
queryFn: () => fetchAndSeed(ids),
enabled: ids.length > 0,
retry: false,
});
}
/**
* Array of fetched fonts
*/
get fonts(): UnifiedFont[] {
return this.result.data ?? [];
}
/**
* Whether the query is currently loading
*/
get isLoading(): boolean {
return this.result.isLoading;
}
/**
* Whether the query encountered an error
*/
get isError(): boolean {
return this.result.isError;
}
/**
* The error object if the query failed
*/
get error(): Error | null {
return (this.result.error as Error) ?? null;
}
}

View File

@@ -0,0 +1,107 @@
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import * as api from '../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../lib/errors/errors';
import { BatchFontStore } from './batchFontStore.svelte';
describe('BatchFontStore', () => {
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
});
describe('Fetch Behavior', () => {
it('should skip fetch when initialized with empty IDs', async () => {
const spy = vi.spyOn(api, 'fetchFontsByIds');
const store = new BatchFontStore([]);
expect(spy).not.toHaveBeenCalled();
expect(store.fonts).toEqual([]);
});
it('should fetch and seed cache for valid IDs', async () => {
const fonts = [{ id: 'a', name: 'A' }] as any[];
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
});
});
describe('Loading States', () => {
it('should transition through loading state', async () => {
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
);
const store = new BatchFontStore(['a']);
expect(store.isLoading).toBe(true);
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
});
});
describe('Error Handling', () => {
it('should wrap network failures in FontNetworkError', async () => {
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontNetworkError);
});
it('should handle malformed API responses with FontResponseError', async () => {
// Mocking a malformed response that the store should validate
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontResponseError);
});
it('should have null error in success state', async () => {
const fonts = [{ id: 'a' }] as any[];
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(store.error).toBeNull();
});
});
describe('Disable Behavior', () => {
it('should return empty fonts and not fetch when setIds is called with empty array', async () => {
const fonts1 = [{ id: 'a' }] as any[];
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
spy.mockClear();
store.setIds([]);
await vi.waitFor(() => expect(store.fonts).toEqual([]), { timeout: 1000 });
expect(spy).not.toHaveBeenCalled();
});
});
describe('Reactivity', () => {
it('should refetch when setIds is called', async () => {
const fonts1 = [{ id: 'a' }] as any[];
const fonts2 = [{ id: 'b' }] as any[];
vi.spyOn(api, 'fetchFontsByIds')
.mockResolvedValueOnce(fonts1)
.mockResolvedValueOnce(fonts2);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
store.setIds(['b']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts2), { timeout: 1000 });
});
});
});

View File

@@ -61,7 +61,6 @@ describe('FontStore', () => {
vi.resetAllMocks();
});
// -----------------------------------------------------------------------
describe('construction', () => {
it('stores initial params', () => {
const store = makeStore({ limit: 20 });
@@ -90,7 +89,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('state after fetch', () => {
it('exposes loaded fonts', async () => {
const store = await fetchedStore({}, generateMockFonts(7));
@@ -129,7 +127,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('error states', () => {
it('isError is false before any fetch', () => {
const store = makeStore();
@@ -178,7 +175,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('font accumulation', () => {
it('replaces fonts when refetching the first page', async () => {
const store = makeStore();
@@ -212,7 +208,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('pagination state', () => {
it('returns zero-value defaults before any fetch', () => {
const store = makeStore();
@@ -248,7 +243,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('setParams', () => {
it('merges updates into existing params', () => {
const store = makeStore({ limit: 10 });
@@ -266,7 +260,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('filter change resets', () => {
it('clears accumulated fonts when a filter changes', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
@@ -302,7 +295,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('staleTime in buildOptions', () => {
it('is 5 minutes with no active filters', () => {
const store = makeStore();
@@ -331,7 +323,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('buildQueryKey', () => {
it('omits empty-string params', () => {
const store = makeStore();
@@ -366,7 +357,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('destroy', () => {
it('does not throw', () => {
const store = makeStore();
@@ -380,7 +370,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('refetch', () => {
it('triggers a fetch', async () => {
const store = makeStore();
@@ -400,7 +389,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('nextPage', () => {
let store: FontStore;
@@ -437,7 +425,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('prevPage and goToPage', () => {
it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
@@ -454,7 +441,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('prefetch', () => {
it('triggers a fetch for the provided params', async () => {
const store = makeStore();
@@ -465,7 +451,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('getCachedData / setQueryData', () => {
it('getCachedData returns undefined before any fetch', () => {
queryClient.clear();
@@ -497,7 +482,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('invalidate', () => {
it('calls invalidateQueries', async () => {
const store = await fetchedStore();
@@ -508,7 +492,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('setLimit', () => {
it('updates the limit param', () => {
const store = makeStore({ limit: 10 });
@@ -518,7 +501,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('filter shortcut methods', () => {
let store: FontStore;
@@ -561,7 +543,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('category getters', () => {
it('each getter returns only fonts of that category', async () => {
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total

View File

@@ -18,7 +18,9 @@ import type { UnifiedFont } from '../../types';
type PageParam = { offset: number };
/** Filter params + limit — offset is managed by TQ as a page param, not a user param. */
/**
* Filter params + limit — offset is managed by TQ as a page param, not a user param.
*/
type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
@@ -44,34 +46,53 @@ export class FontStore {
});
}
// -- Public state --
/**
* Current filter and limit configuration
*/
get params(): FontStoreParams {
return this.#params;
}
/**
* Flattened list of all fonts loaded across all pages (reactive)
*/
get fonts(): UnifiedFont[] {
return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
}
/**
* True if the first page is currently being fetched
*/
get isLoading(): boolean {
return this.#result.isLoading;
}
/**
* True if any background fetch is in progress (initial or pagination)
*/
get isFetching(): boolean {
return this.#result.isFetching;
}
/**
* True if the last fetch attempt resulted in an error
*/
get isError(): boolean {
return this.#result.isError;
}
/**
* Last caught error from the query observer
*/
get error(): Error | null {
return this.#result.error ?? null;
}
// isEmpty is false during loading/fetching so the UI never flashes "no results"
// while a fetch is in progress. The !isFetching guard is specifically for the filter-change
// transition: fonts clear synchronously → isFetching becomes true → isEmpty stays false.
/**
* True if no fonts were found for the current filter criteria
*/
get isEmpty(): boolean {
return !this.isLoading && !this.isFetching && this.fonts.length === 0;
}
/**
* Pagination metadata derived from the last loaded page
*/
get pagination() {
const pages = this.#result.data?.pages;
const last = pages?.at(-1);
@@ -95,45 +116,65 @@ export class FontStore {
};
}
// -- Lifecycle --
/**
* Cleans up subscriptions and destroys the observer
*/
destroy() {
this.#unsubscribe();
this.#observer.destroy();
}
// -- Param management --
/**
* Merge new parameters into existing state and trigger a refetch
*/
setParams(updates: Partial<FontStoreParams>) {
this.#params = { ...this.#params, ...updates };
this.#observer.setOptions(this.buildOptions());
}
/**
* Forcefully invalidate and refetch the current query from the network
*/
invalidate() {
this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) });
}
// -- Async operations --
/**
* Manually trigger a query refetch
*/
async refetch() {
await this.#observer.refetch();
}
/**
* Prime the cache with data for a specific parameter set
*/
async prefetch(params: FontStoreParams) {
await this.#qc.prefetchInfiniteQuery(this.buildOptions(params));
}
/**
* Abort any active network requests for this store
*/
cancel() {
this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) });
}
/**
* Retrieve current font list from cache without triggering a fetch
*/
getCachedData(): UnifiedFont[] | undefined {
const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
this.buildQueryKey(this.#params),
);
if (!data) return undefined;
if (!data) {
return undefined;
}
return data.pages.flatMap(p => p.fonts);
}
/**
* Manually update the cached font data (useful for optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
const key = this.buildQueryKey(this.#params);
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
@@ -164,56 +205,90 @@ export class FontStore {
);
}
// -- Filter shortcuts --
/**
* Shortcut to update provider filters
*/
setProviders(v: ProxyFontsParams['providers']) {
this.setParams({ providers: v });
}
/**
* Shortcut to update category filters
*/
setCategories(v: ProxyFontsParams['categories']) {
this.setParams({ categories: v });
}
/**
* Shortcut to update subset filters
*/
setSubsets(v: ProxyFontsParams['subsets']) {
this.setParams({ subsets: v });
}
/**
* Shortcut to update search query
*/
setSearch(v: string) {
this.setParams({ q: v || undefined });
}
/**
* Shortcut to update sort order
*/
setSort(v: ProxyFontsParams['sort']) {
this.setParams({ sort: v });
}
// -- Pagination navigation --
/**
* Fetch the next page of results if available
*/
async nextPage(): Promise<void> {
await this.#observer.fetchNextPage();
}
prevPage(): void {} // no-op: infinite scroll accumulates forward only; method kept for API compatibility
goToPage(_page: number): void {} // no-op
/**
* Backward pagination (no-op: infinite scroll accumulates forward only)
*/
prevPage(): void {}
/**
* Jump to specific page (no-op for infinite scroll)
*/
goToPage(_page: number): void {}
/**
* Update the number of items fetched per page
*/
setLimit(limit: number) {
this.setParams({ limit });
}
// -- Category views --
/**
* Derived list of sans-serif fonts in the current set
*/
get sansSerifFonts() {
return this.fonts.filter(f => f.category === 'sans-serif');
}
/**
* Derived list of serif fonts in the current set
*/
get serifFonts() {
return this.fonts.filter(f => f.category === 'serif');
}
/**
* Derived list of display fonts in the current set
*/
get displayFonts() {
return this.fonts.filter(f => f.category === 'display');
}
/**
* Derived list of handwriting fonts in the current set
*/
get handwritingFonts() {
return this.fonts.filter(f => f.category === 'handwriting');
}
/**
* Derived list of monospace fonts in the current set
*/
get monospaceFonts() {
return this.fonts.filter(f => f.category === 'monospace');
}
// -- Private helpers (TypeScript-private so tests can spy via `as any`) --
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
const filtered: Record<string, any> = {};
@@ -263,9 +338,15 @@ export class FontStore {
throw new FontNetworkError(cause);
}
if (!response) throw new FontResponseError('response', response);
if (!response.fonts) throw new FontResponseError('response.fonts', response.fonts);
if (!Array.isArray(response.fonts)) throw new FontResponseError('response.fonts', response.fonts);
if (!response) {
throw new FontResponseError('response', response);
}
if (!response.fonts) {
throw new FontResponseError('response.fonts', response.fonts);
}
if (!Array.isArray(response.fonts)) {
throw new FontResponseError('response.fonts', response.fonts);
}
return {
fonts: response.fonts,

View File

@@ -1,5 +1,8 @@
// Applied fonts manager
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
export * from './appliedFontsStore/appliedFontsStore.svelte';
// Batch font store
export { BatchFontStore } from './batchFontStore.svelte';
// Single FontStore
export {

View File

@@ -31,18 +31,28 @@ export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic'
* Combined filter state for font queries
*/
export interface FontFilters {
/** Selected font providers */
/**
* Active font providers to fetch from
*/
providers: FontProvider[];
/** Selected font categories */
/**
* Visual classifications (sans, serif, etc.)
*/
categories: FontCategory[];
/** Selected character subsets */
/**
* Character sets required for the sample text
*/
subsets: FontSubset[];
}
/** Filter group identifier */
/**
* Filter group identifier
*/
export type FilterGroup = 'providers' | 'categories' | 'subsets';
/** Filter type including search query */
/**
* Filter type including search query
*/
export type FilterType = FilterGroup | 'searchQuery';
/**
@@ -80,15 +90,25 @@ export type UnifiedFontVariant = FontVariant;
* Font style URLs
*/
export interface FontStyleUrls {
/** Regular weight URL */
/**
* URL for the regular (400) weight
*/
regular?: string;
/** Italic URL */
/**
* URL for the italic (400) style
*/
italic?: string;
/** Bold weight URL */
/**
* URL for the bold (700) weight
*/
bold?: string;
/** Bold italic URL */
/**
* URL for the bold-italic (700) style
*/
boldItalic?: string;
/** Additional variant mapping */
/**
* Mapping for all other numeric/custom variants
*/
variants?: Partial<Record<UnifiedFontVariant, string>>;
}
@@ -96,19 +116,24 @@ export interface FontStyleUrls {
* Font metadata
*/
export interface FontMetadata {
/** Timestamp when font was cached */
/**
* Epoch timestamp of last successful fetch
*/
cachedAt: number;
/** Font version from provider */
/**
* Semantic version string from upstream
*/
version?: string;
/** Last modified date from provider */
/**
* ISO date string of last remote update
*/
lastModified?: string;
/** Popularity rank (if available from provider) */
/**
* Raw ranking integer from provider
*/
popularity?: number;
/**
* Normalized popularity score (0-100)
*
* Normalized across all fonts for consistent ranking
* Higher values indicate more popular fonts
* Normalized score (0-100) used for global sorting
*/
popularityScore?: number;
}
@@ -117,17 +142,38 @@ export interface FontMetadata {
* Font features (variable fonts, axes, tags)
*/
export interface FontFeatures {
/** Whether this is a variable font */
/**
* Whether the font supports fluid weight/width axes
*/
isVariable?: boolean;
/** Variable font axes (for Fontshare) */
/**
* Definable axes for variable font interpolation
*/
axes?: Array<{
/**
* Human-readable axis name (e.g., 'Weight')
*/
name: string;
/**
* CSS property name (e.g., 'wght')
*/
property: string;
/**
* Default numeric value for the axis
*/
default: number;
/**
* Minimum inclusive bound
*/
min: number;
/**
* Maximum inclusive bound
*/
max: number;
}>;
/** Usage tags (for Fontshare) */
/**
* Descriptive keywords for search indexing
*/
tags?: string[];
}
@@ -138,29 +184,44 @@ export interface FontFeatures {
* for consistent font handling across the application.
*/
export interface UnifiedFont {
/** Unique identifier (Google: family name, Fontshare: slug) */
/**
* Unique ID (family name for Google, slug for Fontshare)
*/
id: string;
/** Font display name */
/**
* Canonical family name for CSS font-family
*/
name: string;
/** Font provider (google | fontshare) */
/**
* Upstream data source
*/
provider: FontProvider;
/**
* Provider badge display name
*
* Human-readable provider name for UI display
* e.g., "Google Fonts" or "Fontshare"
* Display label for provider badges
*/
providerBadge?: string;
/** Font category classification */
/**
* Primary typographic category
*/
category: FontCategory;
/** Supported character subsets */
/**
* All supported character sets
*/
subsets: FontSubset[];
/** Available font variants (weights, styles) */
/**
* List of available weights and styles
*/
variants: UnifiedFontVariant[];
/** URL mapping for font file downloads */
/**
* Remote assets for font loading
*/
styles: FontStyleUrls;
/** Additional metadata */
/**
* Technical metadata and rankings
*/
metadata: FontMetadata;
/** Advanced font features */
/**
* Variable font details and tags
*/
features: FontFeatures;
}

View File

@@ -1,12 +1,3 @@
/**
* ============================================================================
* SINGLE EXPORT POINT
* ============================================================================
*
* This is the single export point for all Font types.
* All imports should use: `import { X } from '$entities/Font/model/types'`
*/
// Font domain and model types
export type {
FilterGroup,
@@ -33,3 +24,4 @@ export type {
} from './store';
export * from './store/appliedFonts';
export * from './typography';

View File

@@ -1,9 +1,3 @@
/**
* ============================================================================
* STORE TYPES
* ============================================================================
*/
import type {
FontCategory,
FontProvider,
@@ -12,37 +6,55 @@ import type {
} from './font';
/**
* Font collection state
* Global state for the local font collection
*/
export interface FontCollectionState {
/** All cached fonts */
/**
* Map of cached fonts indexed by their unique family ID
*/
fonts: Record<string, UnifiedFont>;
/** Active filters */
/**
* Set of active user-defined filters
*/
filters: FontCollectionFilters;
/** Sort configuration */
/**
* Current sorting parameters for the display list
*/
sort: FontCollectionSort;
}
/**
* Font collection filters
* Filter configuration for narrow collections
*/
export interface FontCollectionFilters {
/** Search query */
/**
* Partial family name to match against
*/
searchQuery: string;
/** Filter by providers */
/**
* Data sources (Google, Fontshare) to include
*/
providers?: FontProvider[];
/** Filter by categories */
/**
* Typographic categories (Serif, Sans, etc.) to include
*/
categories?: FontCategory[];
/** Filter by subsets */
/**
* Character sets (Latin, Cyrillic, etc.) to include
*/
subsets?: FontSubset[];
}
/**
* Font collection sort configuration
* Ordering configuration for the font list
*/
export interface FontCollectionSort {
/** Sort field */
/**
* The font property to order by
*/
field: 'name' | 'popularity' | 'category';
/** Sort direction */
/**
* The sort order (Ascending or Descending)
*/
direction: 'asc' | 'desc';
}

View File

@@ -0,0 +1 @@
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';

View File

@@ -0,0 +1,91 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import FontApplicator from './FontApplicator.svelte';
const { Story } = defineMeta({
title: 'Entities/FontApplicator',
component: FontApplicator,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Loads a font and applies it to children. Shows blur/scale loading state until font is ready, then reveals with a smooth transition.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
weight: { control: 'number' },
},
});
</script>
<script lang="ts">
import { mockUnifiedFont } from '$entities/Font/lib/mocks';
import type { ComponentProps } from 'svelte';
const fontUnknown = mockUnifiedFont({ id: 'nonexistent-font-xk92z', name: 'Nonexistent Font Xk92z' });
const fontArial = mockUnifiedFont({ id: 'arial', name: 'Arial' });
const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
</script>
<Story
name="Loading State"
parameters={{
docs: {
description: {
story:
'Font that has never been loaded by appliedFontsManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
},
},
}}
args={{ font: fontUnknown, weight: 400 }}
>
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}>
<p class="text-xl">The quick brown fox jumps over the lazy dog</p>
</FontApplicator>
{/snippet}
</Story>
<Story
name="Loaded State"
parameters={{
docs: {
description: {
story:
'Uses Arial, a system font available in all browsers. Because appliedFontsManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
},
},
}}
args={{ font: fontArial, weight: 400 }}
>
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}>
<p class="text-xl">The quick brown fox jumps over the lazy dog</p>
</FontApplicator>
{/snippet}
</Story>
<Story
name="Custom Weight"
parameters={{
docs: {
description: {
story:
'Demonstrates passing a custom weight (700). The weight is forwarded to appliedFontsManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
},
},
}}
args={{ font: fontArialBold, weight: 700 }}
>
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}>
<p class="text-xl">The quick brown fox jumps over the lazy dog</p>
</FontApplicator>
{/snippet}
</Story>

View File

@@ -6,10 +6,11 @@
- Adds smooth transition when font appears
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import clsx from 'clsx';
import type { Snippet } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion';
import {
DEFAULT_FONT_WEIGHT,
type UnifiedFont,
appliedFontsManager,
} from '../../model';
@@ -36,7 +37,7 @@ interface Props {
let {
font,
weight = 400,
weight = DEFAULT_FONT_WEIGHT,
className,
children,
}: Props = $props();
@@ -63,7 +64,7 @@ const transitionClasses = $derived(
style:font-family={shouldReveal
? `'${font.name}'`
: 'system-ui, -apple-system, sans-serif'}
class={cn(
class={clsx(
transitionClasses,
// If reduced motion is on, we skip the transform/blur entirely
!shouldReveal

View File

@@ -0,0 +1,114 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import FontVirtualList from './FontVirtualList.svelte';
const { Story } = defineMeta({
title: 'Entities/FontVirtualList',
component: FontVirtualList,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Virtualized font list backed by the `fontStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontStore.nextPage()`. Because the component reads directly from the `fontStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
},
story: { inline: false },
},
layout: 'padded',
},
argTypes: {
weight: { control: 'number', description: 'Font weight applied to visible fonts' },
itemHeight: { control: 'number', description: 'Height of each list item in pixels' },
},
});
</script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
</script>
<Story
name="Loading Skeleton"
parameters={{
docs: {
description: {
story:
'Skeleton state shown while `fontStore.fonts` is empty and `fontStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
},
},
}}
args={{ weight: 400, itemHeight: 72 }}
>
{#snippet template(args: ComponentProps<typeof FontVirtualList>)}
<div class="h-[400px] w-full">
<FontVirtualList {...args}>
{#snippet skeleton()}
<div class="flex flex-col gap-2 p-4">
{#each Array(6) as _}
<div class="h-16 animate-pulse rounded bg-neutral-200"></div>
{/each}
</div>
{/snippet}
{#snippet children({ item })}
<div class="border-b border-neutral-100 p-3">{item.name}</div>
{/snippet}
</FontVirtualList>
</div>
{/snippet}
</Story>
<Story
name="Empty State"
parameters={{
docs: {
description: {
story:
'No `skeleton` snippet provided. When `fontStore.fonts` is empty the underlying VirtualList renders its empty state directly.',
},
},
}}
args={{ weight: 400, itemHeight: 72 }}
>
{#snippet template(args: ComponentProps<typeof FontVirtualList>)}
<div class="h-[400px] w-full">
<FontVirtualList {...args}>
{#snippet children({ item })}
<div class="border-b border-neutral-100 p-3">{item.name}</div>
{/snippet}
</FontVirtualList>
</div>
{/snippet}
</Story>
<Story
name="With Item Renderer"
parameters={{
docs: {
description: {
story:
'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
},
},
}}
args={{ weight: 400, itemHeight: 80 }}
>
{#snippet template(args: ComponentProps<typeof FontVirtualList>)}
<div class="h-[400px] w-full">
<FontVirtualList {...args}>
{#snippet skeleton()}
<div class="flex flex-col gap-2 p-4">
{#each Array(6) as _}
<div class="h-16 animate-pulse rounded bg-neutral-200"></div>
{/each}
</div>
{/snippet}
{#snippet children({ item })}
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3">
<span class="text-sm font-medium">{item.name}</span>
<span class="text-xs text-neutral-400">{item.category}</span>
</div>
{/snippet}
</FontVirtualList>
</div>
{/snippet}
</Story>

View File

@@ -18,8 +18,8 @@ import {
type FontLoadRequestConfig,
type UnifiedFont,
appliedFontsManager,
fontStore,
} from '../../model';
import { fontStore } from '../../model/store';
interface Props extends
Omit<
@@ -53,30 +53,44 @@ const isLoading = $derived(
fontStore.isFetching || fontStore.isLoading,
);
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
const configs: FontLoadRequestConfig[] = [];
visibleItems.forEach(item => {
const url = getFontUrl(item, weight);
if (url) {
configs.push({
id: item.id,
name: item.name,
weight,
url,
isVariable: item.features?.isVariable,
});
}
});
// Auto-register fonts with the manager
appliedFontsManager.touch(configs);
let visibleFonts = $state<UnifiedFont[]>([]);
function handleInternalVisibleChange(items: UnifiedFont[]) {
visibleFonts = items;
// Forward the call to any external listener
// onVisibleItemsChange?.(visibleItems);
onVisibleItemsChange?.(items);
}
// Re-touch whenever visible set or weight changes — fixes weight-change gap
$effect(() => {
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
const url = getFontUrl(item, weight);
if (!url) {
return [];
}
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
});
if (configs.length > 0) {
appliedFontsManager.touch(configs);
}
});
// Pin visible fonts so the eviction policy never removes on-screen entries.
// Cleanup captures the snapshot values, so a weight change unpins the old
// weight before pinning the new one.
$effect(() => {
const w = weight;
const fonts = visibleFonts;
for (const f of fonts) {
appliedFontsManager.pin(f.id, w, f.features?.isVariable);
}
return () => {
for (const f of fonts) {
appliedFontsManager.unpin(f.id, w, f.features?.isVariable);
}
};
});
/**
* Load more fonts by moving to the next page
*/

View File

@@ -41,15 +41,25 @@ type ThemeSource = 'system' | 'user';
*/
class ThemeManager {
// Private reactive state
/** Current theme value ('light' or 'dark') */
/**
* Current theme value ('light' or 'dark')
*/
#theme = $state<Theme>('light');
/** Whether theme is controlled by user or follows system */
/**
* Whether theme is controlled by user or follows system
*/
#source = $state<ThemeSource>('system');
/** MediaQueryList for detecting system theme changes */
/**
* MediaQueryList for detecting system theme changes
*/
#mediaQuery: MediaQueryList | null = null;
/** Persistent storage for user's theme preference */
/**
* Persistent storage for user's theme preference
*/
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
/** Bound handler for system theme change events */
/**
* Bound handler for system theme change events
*/
#systemChangeHandler = this.#onSystemChange.bind(this);
constructor() {
@@ -64,22 +74,30 @@ class ThemeManager {
}
}
/** Current theme value */
/**
* Current theme value
*/
get value(): Theme {
return this.#theme;
}
/** Source of current theme ('system' or 'user') */
/**
* Source of current theme ('system' or 'user')
*/
get source(): ThemeSource {
return this.#source;
}
/** Whether dark theme is active */
/**
* Whether dark theme is active
*/
get isDark(): boolean {
return this.#theme === 'dark';
}
/** Whether theme is controlled by user (not following system) */
/**
* Whether theme is controlled by user (not following system)
*/
get isUserControlled(): boolean {
return this.#source === 'user';
}

View File

@@ -1,9 +1,9 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
// ============================================================
// Mock MediaQueryListEvent for system theme change simulations
// Note: Other mocks (ResizeObserver, localStorage, matchMedia) are set up in vitest.setup.unit.ts
// ============================================================
class MockMediaQueryListEvent extends Event {
matches: boolean;
@@ -16,9 +16,7 @@ class MockMediaQueryListEvent extends Event {
}
}
// ============================================================
// NOW IT'S SAFE TO IMPORT
// ============================================================
import {
afterEach,

View File

@@ -0,0 +1,56 @@
import {
fireEvent,
render,
screen,
} from '@testing-library/svelte';
import { themeManager } from '../../model';
import ThemeSwitch from './ThemeSwitch.svelte';
const context = new Map([['responsive', { isMobile: false }]]);
describe('ThemeSwitch', () => {
beforeEach(() => {
themeManager.setTheme('light');
});
describe('Rendering', () => {
it('renders an icon button', () => {
render(ThemeSwitch, { context });
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('has "Toggle theme" title', () => {
render(ThemeSwitch, { context });
expect(screen.getByTitle('Toggle theme')).toBeInTheDocument();
});
it('renders an SVG icon', () => {
const { container } = render(ThemeSwitch, { context });
expect(container.querySelector('svg')).toBeInTheDocument();
});
});
describe('Interaction', () => {
it('toggles theme from light to dark on click', async () => {
render(ThemeSwitch, { context });
expect(themeManager.value).toBe('light');
await fireEvent.click(screen.getByRole('button'));
expect(themeManager.value).toBe('dark');
});
it('toggles theme from dark to light on click', async () => {
themeManager.setTheme('dark');
render(ThemeSwitch, { context });
await fireEvent.click(screen.getByRole('button'));
expect(themeManager.value).toBe('light');
});
it('double click returns to original theme', async () => {
render(ThemeSwitch, { context });
const btn = screen.getByRole('button');
await fireEvent.click(btn);
await fireEvent.click(btn);
expect(themeManager.value).toBe('light');
});
});
});

View File

@@ -35,7 +35,7 @@ const { Story } = defineMeta({
<script lang="ts">
import type { UnifiedFont } from '$entities/Font';
import { controlManager } from '$features/SetupFont';
import type { ComponentProps } from 'svelte';
// Mock fonts for testing
const mockArial: UnifiedFont = {
@@ -89,7 +89,7 @@ const mockGeorgia: UnifiedFont = {
index: 0,
}}
>
{#snippet template(args)}
{#snippet template(args: ComponentProps<typeof FontSampler>)}
<Providers>
<div class="max-w-2xl mx-auto">
<FontSampler {...args} />
@@ -106,7 +106,7 @@ const mockGeorgia: UnifiedFont = {
index: 1,
}}
>
{#snippet template(args)}
{#snippet template(args: ComponentProps<typeof FontSampler>)}
<Providers>
<div class="max-w-2xl mx-auto">
<FontSampler {...args} />

View File

@@ -8,14 +8,13 @@ import {
FontApplicator,
type UnifiedFont,
} from '$entities/Font';
import { controlManager } from '$features/SetupFont';
import { typographySettingsStore } from '$features/SetupFont/model';
import {
Badge,
ContentEditable,
Divider,
Footnote,
Stat,
StatGroup,
} from '$shared/ui';
import { fly } from 'svelte/transition';
@@ -37,11 +36,6 @@ interface Props {
let { font, text = $bindable(), index = 0 }: Props = $props();
const fontWeight = $derived(controlManager.weight);
const fontSize = $derived(controlManager.renderedSize);
const lineHeight = $derived(controlManager.height);
const letterSpacing = $derived(controlManager.spacing);
// Adjust the property name to match your UnifiedFont type
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
@@ -52,10 +46,10 @@ const providerBadge = $derived(
);
const stats = $derived([
{ label: 'SZ', value: `${fontSize}PX` },
{ label: 'WGT', value: `${fontWeight}` },
{ label: 'LH', value: lineHeight?.toFixed(2) },
{ label: 'LTR', value: `${letterSpacing}` },
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
{ label: 'WGT', value: `${typographySettingsStore.weight}` },
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
{ label: 'LTR', value: `${typographySettingsStore.spacing}` },
]);
</script>
@@ -65,7 +59,7 @@ const stats = $derived([
group relative
w-full h-full
bg-paper dark:bg-dark-card
border border-black/5 dark:border-white/10
border border-subtle
hover:border-brand dark:hover:border-brand
hover:shadow-brand/10
hover:shadow-[5px_5px_0px_0px]
@@ -75,20 +69,20 @@ const stats = $derived([
min-h-60
rounded-none
"
style:font-weight={fontWeight}
style:font-weight={typographySettingsStore.weight}
>
<!-- ── Header bar ─────────────────────────────────────────────────── -->
<div
class="
flex items-center justify-between
px-4 sm:px-5 md:px-6 py-3 sm:py-4
border-b border-black/5 dark:border-white/10
border-b border-subtle
bg-paper dark:bg-dark-card
"
>
<!-- Left: index · name · type badge · provider badge -->
<div class="flex items-center gap-2 sm:gap-4 min-w-0 shrink-0">
<span class="font-mono text-[0.625rem] tracking-widest text-neutral-400 uppercase leading-none shrink-0">
<span class="font-mono text-2xs tracking-widest text-neutral-400 uppercase leading-none shrink-0">
{String(index + 1).padStart(2, '0')}
</span>
<Divider orientation="vertical" class="h-3 shrink-0" />
@@ -100,14 +94,14 @@ const stats = $derived([
</span>
{#if fontType}
<Badge size="xs" variant="default" class="text-nowrap font-mono">
<Badge size="xs" variant="default" nowrap>
{fontType}
</Badge>
{/if}
<!-- Provider badge -->
{#if providerBadge}
<Badge size="xs" variant="default" class="text-nowrap font-mono" data-provider={font.provider}>
<Badge size="xs" variant="default" nowrap data-provider={font.provider}>
{providerBadge}
</Badge>
{/if}
@@ -140,20 +134,20 @@ const stats = $derived([
<!-- ── Main content area ──────────────────────────────────────────── -->
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
<FontApplicator {font} weight={fontWeight}>
<FontApplicator {font} weight={typographySettingsStore.weight}>
<ContentEditable
bind:text
{fontSize}
{lineHeight}
{letterSpacing}
fontSize={typographySettingsStore.renderedSize}
lineHeight={typographySettingsStore.height}
letterSpacing={typographySettingsStore.spacing}
/>
</FontApplicator>
</div>
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-black/5 dark:border-white/10 flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-subtle flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
{#each stats as stat, i}
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider {i === 0 ? 'ml-auto' : ''}">
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
{stat.label}:{stat.value}
</Footnote>
{#if i < stats.length - 1}

View File

@@ -15,19 +15,29 @@ const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const;
* Filter metadata type from backend
*/
export interface FilterMetadata {
/** Filter ID (e.g., "providers", "categories", "subsets") */
/**
* Filter ID (e.g., "providers", "categories", "subsets")
*/
id: string;
/** Display name (e.g., "Font Providers", "Categories", "Character Subsets") */
/**
* Display name (e.g., "Font Providers", "Categories", "Character Subsets")
*/
name: string;
/** Filter description */
/**
* Filter description
*/
description: string;
/** Filter type */
/**
* Filter type
*/
type: 'enum' | 'string' | 'array';
/** Available filter options */
/**
* Available filter options
*/
options: FilterOption[];
}
@@ -35,16 +45,24 @@ export interface FilterMetadata {
* Filter option type
*/
export interface FilterOption {
/** Option ID (e.g., "google", "serif", "latin") */
/**
* Option ID (e.g., "google", "serif", "latin")
*/
id: string;
/** Display name (e.g., "Google Fonts", "Serif", "Latin") */
/**
* Display name (e.g., "Google Fonts", "Serif", "Latin")
*/
name: string;
/** Option value (e.g., "google", "serif", "latin") */
/**
* Option value (e.g., "google", "serif", "latin")
*/
value: string;
/** Number of fonts with this value */
/**
* Number of fonts with this value
*/
count: number;
}
@@ -52,7 +70,9 @@ export interface FilterOption {
* Proxy filters API response
*/
export interface ProxyFiltersResponse {
/** Array of filter metadata */
/**
* Array of filter metadata
*/
filters: FilterMetadata[];
}

View File

@@ -1,15 +1,56 @@
export type {
/**
* Top-level configuration for all filters
*/
FilterConfig,
/**
* Configuration for a single grouping of filter properties
*/
FilterGroupConfig,
} from './types/filter';
export { filtersStore } from './state/filters.svelte';
export { filterManager } from './state/manager.svelte';
/**
* Global reactive filter state
*/
export {
/**
* Low-level property selection store
*/
filtersStore,
} from './state/filters.svelte';
/**
* Main filter controller
*/
export {
/**
* High-level manager for syncing search and filters
*/
filterManager,
} from './state/manager.svelte';
/**
* Sorting logic
*/
export {
/**
* Map of human-readable labels to API sort keys
*/
SORT_MAP,
/**
* List of all available sort options for the UI
*/
SORT_OPTIONS,
/**
* Valid sort key values
*/
type SortApiValue,
/**
* UI model for a single sort option
*/
type SortOption,
/**
* Reactive store for the current sort selection
*/
sortStore,
} from './store/sortStore.svelte';

View File

@@ -32,13 +32,19 @@ import {
* Provides reactive access to filter data
*/
class FiltersStore {
/** TanStack Query result state */
/**
* TanStack Query result state
*/
protected result = $state<QueryObserverResult<FilterMetadata[], Error>>({} as any);
/** TanStack Query observer instance */
/**
* TanStack Query observer instance
*/
protected observer: QueryObserver<FilterMetadata[], Error>;
/** Shared query client */
/**
* Shared query client
*/
protected qc = queryClient;
/**

View File

@@ -21,17 +21,23 @@ function createSortStore(initial: SortOption = 'Popularity') {
let current = $state<SortOption>(initial);
return {
/** Current display label (e.g. 'Popularity') */
/**
* Current display label (e.g. 'Popularity')
*/
get value() {
return current;
},
/** Mapped API value (e.g. 'popularity') */
/**
* Mapped API value (e.g. 'popularity')
*/
get apiValue(): SortApiValue {
return SORT_MAP[current];
},
/** Set the active sort option by its display label */
/**
* Set the active sort option by its display label
*/
set(option: SortOption) {
current = option;
},

View File

@@ -1,12 +1,27 @@
import type { Property } from '$shared/lib';
export interface FilterGroupConfig<TValue extends string> {
/**
* Unique identifier for the filter group (e.g. 'categories')
*/
id: string;
/**
* Human-readable label displayed in the UI header
*/
label: string;
/**
* List of toggleable properties within this group
*/
properties: Property<TValue>[];
}
export interface FilterConfig<TValue extends string> {
/**
* Optional string to filter results by name
*/
queryValue?: string;
/**
* Collection of filter groups to display
*/
groups: FilterGroupConfig<TValue>[];
}

View File

@@ -0,0 +1,26 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Filters from './Filters.svelte';
const { Story } = defineMeta({
title: 'Features/Filters',
tags: ['autodocs'],
parameters: {
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.',
},
story: { inline: false },
},
layout: 'padded',
},
argTypes: {},
});
</script>
<Story name="Default">
{#snippet template()}
<Filters />
{/snippet}
</Story>

View File

@@ -0,0 +1,63 @@
import { filterManager } from '$features/GetFonts';
import {
render,
screen,
} from '@testing-library/svelte';
import Filters from './Filters.svelte';
describe('Filters', () => {
beforeEach(() => {
filterManager.setGroups([]);
});
describe('Rendering', () => {
it('renders nothing when filter groups are empty', () => {
const { container } = render(Filters);
expect(container.firstElementChild).toBeNull();
});
it('renders a label for each filter group', () => {
filterManager.setGroups([
{ id: 'cat', label: 'Category', properties: [] },
{ id: 'prov', label: 'Provider', properties: [] },
]);
render(Filters);
expect(screen.getByText('Category')).toBeInTheDocument();
expect(screen.getByText('Provider')).toBeInTheDocument();
});
it('renders filter properties within groups', () => {
filterManager.setGroups([
{
id: 'cat',
label: 'Category',
properties: [
{ id: 'serif', name: 'Serif', value: 'serif', selected: false },
{ id: 'sans', name: 'Sans-Serif', value: 'sans-serif', selected: false },
],
},
]);
render(Filters);
expect(screen.getByText('Serif')).toBeInTheDocument();
expect(screen.getByText('Sans-Serif')).toBeInTheDocument();
});
it('renders multiple groups with their properties', () => {
filterManager.setGroups([
{
id: 'cat',
label: 'Category',
properties: [{ id: 'mono', name: 'Monospace', value: 'monospace', selected: false }],
},
{
id: 'prov',
label: 'Provider',
properties: [{ id: 'google', name: 'Google', value: 'google', selected: false }],
},
]);
render(Filters);
expect(screen.getByText('Monospace')).toBeInTheDocument();
expect(screen.getByText('Google')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,39 @@
<script module>
import Providers from '$shared/lib/storybook/Providers.svelte';
import { defineMeta } from '@storybook/addon-svelte-csf';
import FilterControls from './FilterControls.svelte';
const { Story } = defineMeta({
title: 'Features/FilterControls',
tags: ['autodocs'],
parameters: {
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.',
},
story: { inline: false },
},
layout: 'padded',
},
argTypes: {},
});
</script>
<Story name="Default">
{#snippet template()}
<Providers>
<FilterControls />
</Providers>
{/snippet}
</Story>
<Story name="Mobile layout">
{#snippet template()}
<Providers>
<div style="width: 375px;">
<FilterControls />
</div>
</Providers>
{/snippet}
</Story>

View File

@@ -6,10 +6,10 @@
<script lang="ts">
import { fontStore } from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
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,
@@ -45,7 +45,7 @@ function handleReset() {
</script>
<div
class={cn(
class={clsx(
'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',
@@ -61,13 +61,10 @@ function handleReset() {
{#each SORT_OPTIONS as option}
<Button
variant="ghost"
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
active={sortStore.value === option}
onclick={() => sortStore.set(option)}
class={cn(
'font-bold uppercase tracking-wide font-primary, px-0',
isMobileOrTabletPortrait ? 'text-[0.5625rem]' : 'text-[0.625rem]',
)}
class="tracking-wide px-0"
>
{option}
</Button>
@@ -78,12 +75,9 @@ function handleReset() {
<!-- Reset_Filters -->
<Button
variant="ghost"
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
onclick={handleReset}
class={cn(
'group text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest text-neutral-400',
isMobileOrTabletPortrait && 'px-0',
)}
class={clsx('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
iconPosition="left"
>
{#snippet icon()}

View File

@@ -1,28 +1,6 @@
export { TypographyMenu } from './ui';
export {
type ControlId,
controlManager,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from './model';
export {
createTypographyControlManager,
type TypographyControlManager,
createTypographySettingsManager,
type TypographySettingsManager,
} from './lib';
export { typographySettingsStore } from './model';
export { TypographyMenu } from './ui';

View File

@@ -1,4 +1,4 @@
export {
createTypographyControlManager,
type TypographyControlManager,
} from './controlManager/controlManager.svelte';
createTypographySettingsManager,
type TypographySettingsManager,
} from './settingsManager/settingsManager.svelte';

View File

@@ -10,6 +10,13 @@
* when displaying/editing, but the base size is what's stored.
*/
import {
type ControlId,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
} from '$entities/Font';
import {
type ControlDataModel,
type ControlModel,
@@ -19,20 +26,16 @@ import {
createTypographyControl,
} from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity';
import {
type ControlId,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
} from '../../model';
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
/**
* A control with its instance
* A control with its associated instance
*/
export interface Control extends ControlOnlyFields<ControlId> {
/**
* The reactive typography control instance
*/
instance: TypographyControl;
}
@@ -40,9 +43,21 @@ export interface Control extends ControlOnlyFields<ControlId> {
* Storage schema for typography settings
*/
export interface TypographySettings {
/**
* Base font size (User preference, unscaled)
*/
fontSize: number;
/**
* Numeric font weight (100-900)
*/
fontWeight: number;
/**
* Line height multiplier (e.g. 1.5)
*/
lineHeight: number;
/**
* Letter spacing in em/px
*/
letterSpacing: number;
}
@@ -52,14 +67,22 @@ export interface TypographySettings {
* Manages multiple typography controls with persistent storage and
* responsive scaling support for font size.
*/
export class TypographyControlManager {
/** Map of controls keyed by ID */
export class TypographySettingsManager {
/**
* Internal map of reactive controls keyed by their identifier
*/
#controls = new SvelteMap<string, Control>();
/** Responsive multiplier for font size display */
/**
* Global multiplier for responsive font size scaling
*/
#multiplier = $state(1);
/** Persistent storage for settings */
/**
* LocalStorage-backed storage for persistence
*/
#storage: PersistentStore<TypographySettings>;
/** Base font size (user preference, unscaled) */
/**
* The underlying font size before responsive scaling is applied
*/
#baseSize = $state(DEFAULT_FONT_SIZE);
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
@@ -105,7 +128,9 @@ export class TypographyControlManager {
// This handles the "Multiplier" logic specifically for the Font Size Control
$effect(() => {
const ctrl = this.#controls.get('font_size')?.instance;
if (!ctrl) return;
if (!ctrl) {
return;
}
// If the user moves the slider/clicks buttons in the UI:
// We update the baseSize (User Intent)
@@ -124,26 +149,35 @@ export class TypographyControlManager {
* Gets initial value for a control from storage or defaults
*/
#getInitialValue(id: string, saved: TypographySettings): number {
if (id === 'font_size') return saved.fontSize * this.#multiplier;
if (id === 'font_weight') return saved.fontWeight;
if (id === 'line_height') return saved.lineHeight;
if (id === 'letter_spacing') return saved.letterSpacing;
if (id === 'font_size') {
return saved.fontSize * this.#multiplier;
}
if (id === 'font_weight') {
return saved.fontWeight;
}
if (id === 'line_height') {
return saved.lineHeight;
}
if (id === 'letter_spacing') {
return saved.letterSpacing;
}
return 0;
}
/** Current multiplier for responsive scaling */
/**
* Active scaling factor for the rendered font size
*/
get multiplier() {
return this.#multiplier;
}
/**
* Set the multiplier and update font size display
*
* When multiplier changes, the font size control's display value
* is updated to reflect the new scale while preserving base size.
* Updates the multiplier and recalculates dependent control values
*/
set multiplier(value: number) {
if (this.#multiplier === value) return;
if (this.#multiplier === value) {
return;
}
this.#multiplier = value;
// When multiplier changes, we must update the Font Size Control's display value
@@ -154,14 +188,15 @@ export class TypographyControlManager {
}
/**
* The scaled size for CSS usage
* Returns baseSize * multiplier for actual rendering
* The actual pixel value for CSS font-size (baseSize * multiplier)
*/
get renderedSize() {
return this.#baseSize * this.#multiplier;
}
/** The base size (User Preference) */
/**
* The raw font size preference before scaling
*/
get baseSize() {
return this.#baseSize;
}
@@ -169,49 +204,69 @@ export class TypographyControlManager {
set baseSize(val: number) {
this.#baseSize = val;
const ctrl = this.#controls.get('font_size')?.instance;
if (ctrl) ctrl.value = val * this.#multiplier;
if (ctrl) {
ctrl.value = val * this.#multiplier;
}
}
/**
* Getters for controls
* List of all managed typography controls
*/
get controls() {
return Array.from(this.#controls.values());
}
/**
* Reactive instance for weight manipulation
*/
get weightControl() {
return this.#controls.get('font_weight')?.instance;
}
/**
* Reactive instance for size manipulation
*/
get sizeControl() {
return this.#controls.get('font_size')?.instance;
}
/**
* Reactive instance for line-height manipulation
*/
get heightControl() {
return this.#controls.get('line_height')?.instance;
}
/**
* Reactive instance for letter-spacing manipulation
*/
get spacingControl() {
return this.#controls.get('letter_spacing')?.instance;
}
/**
* Getters for values (besides font-size)
* Current numeric font weight (reactive)
*/
get weight() {
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
}
/**
* Current numeric line height (reactive)
*/
get height() {
return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
}
/**
* Current numeric letter spacing (reactive)
*/
get spacing() {
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
}
/**
* Reset all controls to default values
* Reset all controls to project-defined defaults
*/
reset() {
this.#storage.clear();
@@ -227,9 +282,15 @@ export class TypographyControlManager {
// Map storage key to control id
const key = c.id.replace('_', '') as keyof TypographySettings;
// Simplified for brevity, you'd map these properly:
if (c.id === 'font_weight') c.instance.value = defaults.fontWeight;
if (c.id === 'line_height') c.instance.value = defaults.lineHeight;
if (c.id === 'letter_spacing') c.instance.value = defaults.letterSpacing;
if (c.id === 'font_weight') {
c.instance.value = defaults.fontWeight;
}
if (c.id === 'line_height') {
c.instance.value = defaults.lineHeight;
}
if (c.id === 'letter_spacing') {
c.instance.value = defaults.letterSpacing;
}
}
});
}
@@ -242,7 +303,7 @@ export class TypographyControlManager {
* @param storageId - Persistent storage identifier
* @returns Typography control manager instance
*/
export function createTypographyControlManager(
export function createTypographySettingsManager(
configs: ControlModel<ControlId>[],
storageId: string = 'glyphdiff:typography',
) {
@@ -252,5 +313,5 @@ export function createTypographyControlManager(
lineHeight: DEFAULT_LINE_HEIGHT,
letterSpacing: DEFAULT_LETTER_SPACING,
});
return new TypographyControlManager(configs, storage);
return new TypographySettingsManager(configs, storage);
}

View File

@@ -1,6 +1,14 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} from '$entities/Font';
import {
afterEach,
beforeEach,
describe,
expect,
@@ -8,21 +16,14 @@ import {
vi,
} from 'vitest';
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} from '../../model';
import {
TypographyControlManager,
type TypographySettings,
} from './controlManager.svelte';
TypographySettingsManager,
} from './settingsManager.svelte';
/**
* Test Strategy for TypographyControlManager
* Test Strategy for TypographySettingsManager
*
* This test suite validates the TypographyControlManager state management logic.
* This test suite validates the TypographySettingsManager state management logic.
* These are unit tests for the manager logic, separate from component rendering.
*
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
@@ -45,7 +46,7 @@ async function flushEffects() {
await Promise.resolve();
}
describe('TypographyControlManager - Unit Tests', () => {
describe('TypographySettingsManager - Unit Tests', () => {
let mockStorage: TypographySettings;
let mockPersistentStore: {
value: TypographySettings;
@@ -85,7 +86,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Initialization', () => {
it('creates manager with default values from storage', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -105,7 +106,7 @@ describe('TypographyControlManager - Unit Tests', () => {
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -117,7 +118,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -126,7 +127,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('returns all controls via controls getter', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -142,7 +143,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('returns individual controls via specific getters', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -160,7 +161,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('control instances have expected interface', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -179,7 +180,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Multiplier System', () => {
it('has default multiplier of 1', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -188,7 +189,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('updates multiplier when set', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -201,7 +202,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('does not update multiplier if set to same value', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -217,7 +218,7 @@ describe('TypographyControlManager - Unit Tests', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -241,7 +242,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('updates font size control display value when multiplier increases', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -262,7 +263,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Base Size Setter', () => {
it('updates baseSize when set directly', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -273,7 +274,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('updates size control value when baseSize is set', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -284,7 +285,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('applies multiplier to size control when baseSize is set', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -298,7 +299,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Rendered Size Calculation', () => {
it('calculates renderedSize as baseSize * multiplier', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -307,7 +308,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('updates renderedSize when multiplier changes', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -320,7 +321,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('updates renderedSize when baseSize changes', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -340,7 +341,7 @@ describe('TypographyControlManager - Unit Tests', () => {
// proxy effect behavior should be tested in E2E tests.
it('does NOT immediately update baseSize from control change (effect is async)', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -355,7 +356,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('updates baseSize via direct setter (synchronous)', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -380,7 +381,7 @@ describe('TypographyControlManager - Unit Tests', () => {
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -393,7 +394,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('syncs to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -409,7 +410,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('syncs control changes to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -422,7 +423,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('syncs height control changes to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -434,7 +435,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('syncs spacing control changes to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -448,7 +449,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Control Value Getters', () => {
it('returns current weight value', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -460,7 +461,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('returns current height value', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -472,7 +473,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('returns current spacing value', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -485,7 +486,7 @@ describe('TypographyControlManager - Unit Tests', () => {
it('returns default value when control is not found', () => {
// Create a manager with empty configs (no controls)
const manager = new TypographyControlManager([], mockPersistentStore);
const manager = new TypographySettingsManager([], mockPersistentStore);
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
@@ -503,7 +504,7 @@ describe('TypographyControlManager - Unit Tests', () => {
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -536,7 +537,7 @@ describe('TypographyControlManager - Unit Tests', () => {
clear: clearSpy,
};
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -547,7 +548,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('respects multiplier when resetting font size control', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -565,7 +566,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Complex Scenarios', () => {
it('handles changing multiplier then modifying baseSize', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -586,7 +587,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('maintains correct renderedSize throughout changes', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -608,7 +609,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('handles multiple control changes in sequence', async () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -633,7 +634,7 @@ describe('TypographyControlManager - Unit Tests', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -645,7 +646,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('handles very small multiplier', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -658,7 +659,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('handles large base size with multiplier', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -671,7 +672,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('handles floating point precision in multiplier', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -690,7 +691,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('handles control methods (increase/decrease)', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -704,7 +705,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('handles control boundary conditions', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);

View File

@@ -1,24 +1 @@
export {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from './const/const';
export {
type ControlId,
controlManager,
} from './state/manager.svelte';
export { typographySettingsStore } from './state/typographySettingsStore';

View File

@@ -1,6 +0,0 @@
import { createTypographyControlManager } from '../../lib';
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../const/const';
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
export const controlManager = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA);

View File

@@ -0,0 +1,7 @@
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '$entities/Font';
import { createTypographySettingsManager } from '../../lib';
export const typographySettingsStore = createTypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
'glyphdiff:comparison:typography',
);

View File

@@ -0,0 +1,45 @@
<script module>
import Providers from '$shared/lib/storybook/Providers.svelte';
import { defineMeta } from '@storybook/addon-svelte-csf';
import TypographyMenu from './TypographyMenu.svelte';
const { Story } = defineMeta({
title: 'Features/TypographyMenu',
component: TypographyMenu,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Floating typography controls. Mobile/tablet: settings button that opens a popover. Desktop: inline bar with combo controls.',
},
story: { inline: false },
},
layout: 'centered',
storyStage: { maxWidth: 'max-w-xl' },
},
argTypes: {
hidden: { control: 'boolean' },
},
});
</script>
<Story name="Desktop">
{#snippet template()}
<Providers>
<div class="relative h-20 flex items-end justify-center p-4">
<TypographyMenu />
</div>
</Providers>
{/snippet}
</Story>
<Story name="Hidden">
{#snippet template()}
<Providers>
<div class="relative h-20 flex items-end justify-center p-4">
<TypographyMenu hidden={true} />
</div>
</Providers>
{/snippet}
</Story>

View File

@@ -1,13 +1,16 @@
<!--
Component: TypographyMenu
Floating controls bar for typography settings.
Warm surface, sharp corners, Settings icon header, dividers between units.
Mobile: popover with slider controls anchored to settings button.
Desktop: inline bar with combo controls.
-->
<script lang="ts">
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
Button,
ComboControl,
@@ -17,15 +20,11 @@ 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';
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
controlManager,
} from '../../model';
import { typographySettingsStore } from '../../model';
interface Props {
/**
@@ -37,67 +36,62 @@ interface Props {
* @default false
*/
hidden?: boolean;
/**
* Bindable popover open state
* @default false
*/
open?: boolean;
}
const { class: className, hidden = false }: Props = $props();
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
let isOpen = $state(false);
/**
* Sets the common font size multiplier based on the current responsive state.
*/
$effect(() => {
if (!responsive) return;
if (!responsive) {
return;
}
switch (true) {
case responsive.isMobile:
controlManager.multiplier = MULTIPLIER_S;
typographySettingsStore.multiplier = MULTIPLIER_S;
break;
case responsive.isTablet:
controlManager.multiplier = MULTIPLIER_M;
typographySettingsStore.multiplier = MULTIPLIER_M;
break;
case responsive.isDesktop:
controlManager.multiplier = MULTIPLIER_L;
typographySettingsStore.multiplier = MULTIPLIER_L;
break;
default:
controlManager.multiplier = MULTIPLIER_L;
typographySettingsStore.multiplier = MULTIPLIER_L;
}
});
</script>
{#if !hidden}
{#if responsive.isMobile}
<Popover.Root bind:open={isOpen}>
{#if responsive.isMobileOrTablet}
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<button
{...props}
class={cn(
'inline-flex items-center justify-center',
'size-8 p-0',
'border border-transparent rounded-none',
'transition-colors duration-150',
'hover:bg-white/50 dark:hover:bg-white/5',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
isOpen && 'bg-paper dark:bg-dark-card border-black/5 dark:border-white/10 shadow-sm',
className,
)}
>
<Settings2Icon class="size-4" />
</button>
<Button class={className} variant="primary" {...props}>
{#snippet icon()}
<Settings2Icon class="size-4" />
{/snippet}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="top"
align="start"
align="end"
sideOffset={8}
class={cn(
class={clsx(
'z-50 w-72',
'bg-surface dark:bg-dark-card',
'border border-black/5 dark:border-white/10',
'border border-subtle',
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
'rounded-none p-4',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
@@ -110,11 +104,11 @@ $effect(() => {
escapeKeydownBehavior="close"
>
<!-- Header -->
<div class="flex items-center justify-between mb-3 pb-3 border-b border-black/5 dark:border-white/10">
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
<div class="flex items-center gap-1.5">
<Settings2Icon size={12} class="text-swiss-red" />
<span
class="text-[0.5625rem] font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
class="text-3xs font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
>
CONTROLS
</span>
@@ -133,7 +127,7 @@ $effect(() => {
</div>
<!-- Controls -->
{#each controlManager.controls as control (control.id)}
{#each typographySettingsStore.controls as control (control.id)}
<ControlGroup label={control.controlLabel ?? ''}>
<Slider
bind:value={control.instance.value}
@@ -148,33 +142,33 @@ $effect(() => {
</Popover.Root>
{:else}
<div
class={cn('w-full md:w-auto', className)}
class={clsx('w-full md:w-auto', className)}
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
>
<div
class={cn(
class={clsx(
'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-black/5 dark:border-white/10',
'border border-subtle',
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
)}
>
<!-- Header: icon + label -->
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-black/5 dark:border-white/10 mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-subtle mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
<Settings2Icon
size={14}
class="text-swiss-red"
/>
<span
class="text-[0.5625rem] md:text-[0.625rem] font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
class="text-3xs md:text-2xs font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
>
GLOBAL_CONTROLS
</span>
</div>
<!-- Controls with dividers between each -->
{#each controlManager.controls as control, i (control.id)}
{#each typographySettingsStore.controls as control, i (control.id)}
{#if i > 0}
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
{/if}

View File

@@ -1,3 +1,9 @@
/**
* Application entry point
*
* Mounts the main App component to the DOM and initializes
* global styles.
*/
import App from '$app/App.svelte';
import { mount } from 'svelte';
import '$app/styles/app.css';

View File

@@ -3,10 +3,7 @@
Description: The main page component of the application.
-->
<script lang="ts">
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
import { ComparisonView } from '$widgets/ComparisonView';
import { FontSearchSection } from '$widgets/FontSearch';
import { SampleListSection } from '$widgets/SampleList';
import { cubicIn } from 'svelte/easing';
import { fade } from 'svelte/transition';
</script>
@@ -18,8 +15,4 @@ import { fade } from 'svelte/transition';
<section class="w-auto">
<ComparisonView />
</section>
<main class="w-full pt-0 pb-10 sm:px-6 sm:pt-16 sm:pb-12 md:px-8 md:pt-32 md:pb-16 lg:px-10 lg:pt-48 lg:pb-20 xl:px-16">
<FontSearchSection />
<SampleListSection index={1} />
</main>
</div>

View File

@@ -41,10 +41,14 @@ export class ApiError extends Error {
* @param response - Original fetch Response object
*/
constructor(
/** HTTP status code */
/**
* HTTP status code
*/
public status: number,
message: string,
/** Original Response object for inspection */
/**
* Original Response object for inspection
*/
public response?: Response,
) {
super(message);

View File

@@ -15,15 +15,25 @@ import { QueryClient } from '@tanstack/query-core';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
/** Data remains fresh for 5 minutes after fetch */
/**
* Data remains fresh for 5 minutes after fetch
*/
staleTime: 5 * 60 * 1000,
/** Unused cache entries are removed after 10 minutes */
/**
* Unused cache entries are removed after 10 minutes
*/
gcTime: 10 * 60 * 1000,
/** Don't refetch when window regains focus */
/**
* Don't refetch when window regains focus
*/
refetchOnWindowFocus: false,
/** Refetch on mount if data is stale */
/**
* Refetch on mount if data is stale
*/
refetchOnMount: true,
/** Retry failed requests up to 3 times */
/**
* Retry failed requests up to 3 times
*/
retry: 3,
/**
* Exponential backoff for retries

View File

@@ -0,0 +1,73 @@
import {
describe,
expect,
it,
} from 'vitest';
import { fontKeys } from './queryKeys';
describe('fontKeys', () => {
describe('Hierarchy', () => {
it('should generate base keys', () => {
expect(fontKeys.all).toEqual(['fonts']);
expect(fontKeys.lists()).toEqual(['fonts', 'list']);
expect(fontKeys.batches()).toEqual(['fonts', 'batch']);
expect(fontKeys.details()).toEqual(['fonts', 'detail']);
});
});
describe('Batch Keys (Stability & Sorting)', () => {
it('should sort IDs for stable serialization', () => {
const key1 = fontKeys.batch(['b', 'a', 'c']);
const key2 = fontKeys.batch(['c', 'b', 'a']);
const expected = ['fonts', 'batch', ['a', 'b', 'c']];
expect(key1).toEqual(expected);
expect(key2).toEqual(expected);
});
it('should handle empty ID arrays', () => {
expect(fontKeys.batch([])).toEqual(['fonts', 'batch', []]);
});
it('should not mutate the input array when sorting', () => {
const ids = ['c', 'b', 'a'];
fontKeys.batch(ids);
expect(ids).toEqual(['c', 'b', 'a']);
});
it('batch key should be rooted in batches() base', () => {
const key = fontKeys.batch(['a']);
expect(key.slice(0, 2)).toEqual(fontKeys.batches());
});
});
describe('List Keys (Parameters)', () => {
it('should include parameters in list keys', () => {
const params = { provider: 'google' };
expect(fontKeys.list(params)).toEqual(['fonts', 'list', params]);
});
it('should handle empty parameters', () => {
expect(fontKeys.list({})).toEqual(['fonts', 'list', {}]);
});
it('list key should be rooted in lists() base', () => {
const key = fontKeys.list({ provider: 'google' });
expect(key.slice(0, 2)).toEqual(fontKeys.lists());
});
});
describe('Detail Keys', () => {
it('should generate unique detail keys per ID', () => {
expect(fontKeys.detail('roboto')).toEqual(['fonts', 'detail', 'roboto']);
});
it('should generate different keys for different IDs', () => {
expect(fontKeys.detail('roboto')).not.toEqual(fontKeys.detail('open-sans'));
});
it('detail key should be rooted in details() base', () => {
const key = fontKeys.detail('roboto');
expect(key.slice(0, 2)).toEqual(fontKeys.details());
});
});
});

View File

@@ -0,0 +1,37 @@
/**
* Stable query key factory for font-related queries.
* Ensures consistent serialization for batch requests by sorting IDs.
*/
export const fontKeys = {
/**
* Base key for all font queries
*/
all: ['fonts'] as const,
/**
* Keys for font list queries
*/
lists: () => [...fontKeys.all, 'list'] as const,
/**
* Specific font list key with filter parameters
*/
list: (params: object) => [...fontKeys.lists(), params] as const,
/**
* Keys for font batch queries
*/
batches: () => [...fontKeys.all, 'batch'] as const,
/**
* Specific batch key, sorted for stability
*/
batch: (ids: string[]) => [...fontKeys.batches(), [...ids].sort()] as const,
/**
* Keys for font detail queries
*/
details: () => [...fontKeys.all, 'detail'] as const,
/**
* Specific font detail key by ID
*/
detail: (id: string) => [...fontKeys.details(), id] as const,
} as const;

View File

@@ -0,0 +1,51 @@
import { queryClient } from '$shared/api/queryClient';
import {
QueryObserver,
type QueryObserverOptions,
type QueryObserverResult,
} from '@tanstack/query-core';
/**
* Abstract base class for reactive Svelte 5 stores backed by TanStack Query.
*
* Provides a unified way to use TanStack Query observers within Svelte 5 classes
* using runes for reactivity. Handles subscription lifecycle automatically.
*
* @template TData - The type of data returned by the query.
* @template TError - The type of error that can be thrown.
*/
export abstract class BaseQueryStore<TData, TError = Error> {
#result = $state<QueryObserverResult<TData, TError>>({} as QueryObserverResult<TData, TError>);
#observer: QueryObserver<TData, TError>;
#unsubscribe: () => void;
constructor(options: QueryObserverOptions<TData, TError, TData, any, any>) {
this.#observer = new QueryObserver(queryClient, options);
this.#unsubscribe = this.#observer.subscribe(result => {
this.#result = result;
});
}
/**
* Current query result (reactive)
*/
protected get result(): QueryObserverResult<TData, TError> {
return this.#result;
}
/**
* Updates observer options dynamically.
* Use this when query parameters or dependencies change.
*/
protected updateOptions(options: QueryObserverOptions<TData, TError, TData, any, any>): void {
this.#observer.setOptions(options);
}
/**
* Cleans up the observer subscription.
* Should be called when the store is no longer needed.
*/
destroy(): void {
this.#unsubscribe();
}
}

View File

@@ -0,0 +1,91 @@
import { queryClient } from '$shared/api/queryClient';
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { BaseQueryStore } from './BaseQueryStore.svelte';
class TestStore extends BaseQueryStore<string> {
constructor(key = ['test'], fn = () => Promise.resolve('ok')) {
super({
queryKey: key,
queryFn: fn,
retry: false, // Disable retries for faster error testing
});
}
get data() {
return this.result.data;
}
get isLoading() {
return this.result.isLoading;
}
get isError() {
return this.result.isError;
}
update(newKey: string[], newFn?: () => Promise<string>) {
this.updateOptions({
queryKey: newKey,
queryFn: newFn ?? (() => Promise.resolve('ok')),
retry: false,
});
}
}
import * as tq from '@tanstack/query-core';
// ... (TestStore remains same)
describe('BaseQueryStore', () => {
beforeEach(() => {
queryClient.clear();
});
describe('Lifecycle & Fetching', () => {
it('should transition from loading to success', async () => {
const store = new TestStore();
expect(store.isLoading).toBe(true);
await vi.waitFor(() => expect(store.data).toBe('ok'), { timeout: 1000 });
expect(store.isLoading).toBe(false);
});
it('should have undefined data and no error in initial loading state', () => {
const store = new TestStore(['initial-state'], () => new Promise(r => setTimeout(() => r('late'), 500)));
expect(store.data).toBeUndefined();
expect(store.isError).toBe(false);
});
});
describe('Error Handling', () => {
it('should handle query failures', async () => {
const store = new TestStore(['fail'], () => Promise.reject(new Error('fail')));
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
});
});
describe('Reactivity', () => {
it('should refetch and update data when options change', async () => {
const store = new TestStore(['key1'], () => Promise.resolve('val1'));
await vi.waitFor(() => expect(store.data).toBe('val1'), { timeout: 1000 });
store.update(['key2'], () => Promise.resolve('val2'));
await vi.waitFor(() => expect(store.data).toBe('val2'), { timeout: 1000 });
});
});
describe('Cleanup', () => {
it('should unsubscribe observer on destroy', () => {
const unsubscribe = vi.fn();
const subscribeSpy = vi.spyOn(tq.QueryObserver.prototype, 'subscribe').mockReturnValue(unsubscribe);
const store = new TestStore();
store.destroy();
expect(unsubscribe).toHaveBeenCalled();
subscribeSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,301 @@
import {
type PreparedTextWithSegments,
layoutWithLines,
prepareWithSegments,
} from '@chenglou/pretext';
/**
* A single laid-out line produced by dual-font comparison layout.
*
* Line breaking is determined by the unified worst-case widths, so both fonts
* always break at identical positions. Per-character `xA`/`xB` offsets reflect
* each font's actual advance widths independently.
*/
export interface ComparisonLine {
/**
* Full text of this line as returned by pretext.
*/
text: string;
/**
* Rendered width of this line in pixels — maximum across font A and font B.
*/
width: number;
/**
* Individual character metadata for both fonts in this line
*/
chars: Array<{
/**
* The grapheme cluster string (may be >1 code unit for emoji, etc.).
*/
char: string;
/**
* X offset from the start of the line in font A, in pixels.
*/
xA: number;
/**
* Advance width of this grapheme in font A, in pixels.
*/
widthA: number;
/**
* X offset from the start of the line in font B, in pixels.
*/
xB: number;
/**
* Advance width of this grapheme in font B, in pixels.
*/
widthB: number;
}>;
}
/**
* Aggregated output of a dual-font layout pass.
*/
export interface ComparisonResult {
/**
* Per-line grapheme data for both fonts. Empty when input text is empty.
*/
lines: ComparisonLine[];
/**
* Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee).
*/
totalHeight: number;
}
/**
* Dual-font text layout engine backed by `@chenglou/pretext`.
*
* Computes identical line breaks for two fonts simultaneously by constructing a
* "unified" prepared-text object whose per-glyph widths are the worst-case maximum
* of font A and font B. This guarantees that both fonts wrap at exactly the same
* positions, making side-by-side or slider comparison visually coherent.
*
* **Two-level caching strategy**
* 1. Font-change cache (`#preparedA`, `#preparedB`, `#unifiedPrepared`): rebuilt only
* when `text`, `fontA`, or `fontB` changes. `prepareWithSegments` is expensive
* (canvas measurement), so this avoids re-measuring during slider interaction.
* 2. Layout cache (`#lastResult`): rebuilt when `width` or `lineHeight` changes but
* the fonts have not changed. Line-breaking is cheap relative to measurement, but
* still worth skipping on every render tick.
*
* **`as any` casts:** `PreparedTextWithSegments` exposes only the `segments` field in
* its public TypeScript type. The numeric arrays (`widths`, `breakableFitAdvances`,
* `lineEndFitAdvances`, `lineEndPaintAdvances`) are internal implementation details of
* pretext that are not part of the published type signature. The casts are required to
* access these fields; they are verified against the pretext source at
* `node_modules/@chenglou/pretext/src/layout.ts`.
*/
export class CharacterComparisonEngine {
#segmenter: Intl.Segmenter;
// Cached prepared data
#preparedA: PreparedTextWithSegments | null = null;
#preparedB: PreparedTextWithSegments | null = null;
#unifiedPrepared: PreparedTextWithSegments | null = null;
#lastText = '';
#lastFontA = '';
#lastFontB = '';
// Cached layout results
#lastWidth = -1;
#lastLineHeight = -1;
#lastResult: ComparisonResult | null = null;
constructor(locale?: string) {
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
}
/**
* Lay out `text` using both fonts within `width` pixels.
*
* Line breaks are determined by the worst-case (maximum) glyph widths across
* both fonts, so both fonts always wrap at identical positions.
*
* @param text Raw text to lay out.
* @param fontA CSS font string for the first font: `"weight sizepx \"family\""`.
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
* @param width Available line width in pixels.
* @param lineHeight Line height in pixels (passed directly to pretext).
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
*/
layout(
text: string,
fontA: string,
fontB: string,
width: number,
lineHeight: number,
): ComparisonResult {
if (!text) {
return { lines: [], totalHeight: 0 };
}
const isFontChange = text !== this.#lastText || fontA !== this.#lastFontA || fontB !== this.#lastFontB;
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
if (!isFontChange && !isLayoutChange && this.#lastResult) {
return this.#lastResult;
}
// 1. Prepare (or use cache)
if (isFontChange) {
this.#preparedA = prepareWithSegments(text, fontA);
this.#preparedB = prepareWithSegments(text, fontB);
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB);
this.#lastText = text;
this.#lastFontA = fontA;
this.#lastFontB = fontB;
}
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
return { lines: [], totalHeight: 0 };
}
// 2. Layout using the unified widths.
// `PreparedTextWithSegments` only exposes `segments` in its public type; cast to `any`
// so pretext's layoutWithLines can read the internal numeric arrays at runtime.
const { lines, height } = layoutWithLines(this.#unifiedPrepared as any, width, lineHeight);
// 3. Map results back to both fonts
const resultLines: ComparisonLine[] = lines.map(line => {
const chars: ComparisonLine['chars'] = [];
let currentXA = 0;
let currentXB = 0;
const start = line.start;
const end = line.end;
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
const intA = this.#preparedA as any;
const intB = this.#preparedB as any;
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
const segmentText = this.#preparedA!.segments[sIdx];
if (segmentText === undefined) {
continue;
}
// PERFORMANCE: Reuse segmenter results if possible, but for now just optimize the loop
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
const advA = intA.breakableFitAdvances[sIdx];
const advB = intB.breakableFitAdvances[sIdx];
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
const char = graphemes[gIdx];
const wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
const wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
chars.push({
char,
xA: currentXA,
widthA: wA,
xB: currentXB,
widthB: wB,
});
currentXA += wA;
currentXB += wB;
}
}
return {
text: line.text,
width: line.width,
chars,
};
});
this.#lastWidth = width;
this.#lastLineHeight = lineHeight;
this.#lastResult = {
lines: resultLines,
totalHeight: height,
};
return this.#lastResult;
}
/**
* Calculates character proximity and direction relative to a slider position.
*
* Uses the most recent `layout()` result — must be called after `layout()`.
* No DOM calls are made; all geometry is derived from cached layout data.
*
* @param lineIndex Zero-based index of the line within the last layout result.
* @param charIndex Zero-based index of the character within that line's `chars` array.
* @param sliderPos Current slider position as a percentage (0100) of `containerWidth`.
* @param containerWidth Total container width in pixels, used to convert pixel offsets to %.
* @returns `proximity` in [0, 1] (1 = slider exactly over char center) and
* `isPast` (true when the slider has already passed the char center).
*/
getCharState(
lineIndex: number,
charIndex: number,
sliderPos: number,
containerWidth: number,
): { proximity: number; isPast: boolean } {
if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) {
return { proximity: 0, isPast: false };
}
const line = this.#lastResult.lines[lineIndex];
const char = line.chars[charIndex];
if (!char) {
return { proximity: 0, isPast: false };
}
// Center the comparison on the unified width
// In the UI, lines are centered. So we need to calculate the global X.
const lineXOffset = (containerWidth - line.width) / 2;
const charCenterX = lineXOffset + char.xA + (char.widthA / 2);
const charGlobalPercent = (charCenterX / containerWidth) * 100;
const distance = Math.abs(sliderPos - charGlobalPercent);
const range = 5;
const proximity = Math.max(0, 1 - distance / range);
const isPast = sliderPos > charGlobalPercent;
return { proximity, isPast };
}
/**
* Internal helper to merge two prepared texts into a "worst-case" unified version
*/
#createUnifiedPrepared(a: PreparedTextWithSegments, b: PreparedTextWithSegments): PreparedTextWithSegments {
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
const intA = a as any;
const intB = b as any;
const unified = { ...intA };
unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]));
unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) =>
Math.max(w, intB.lineEndFitAdvances[i])
);
unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) =>
Math.max(w, intB.lineEndPaintAdvances[i])
);
unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => {
const advB = intB.breakableFitAdvances[i];
if (!advA && !advB) {
return null;
}
if (!advA) {
return advB;
}
if (!advB) {
return advA;
}
return advA.map((w: number, j: number) => Math.max(w, advB[j]));
});
return unified;
}
}

View File

@@ -0,0 +1,164 @@
// @vitest-environment jsdom
import { clearCache } from '@chenglou/pretext';
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import { installCanvasMock } from '../__mocks__/canvas';
import { CharacterComparisonEngine } from './CharacterComparisonEngine.svelte';
// FontA: 10px per character. FontB: 15px per character.
// The mock dispatches on whether the font string contains 'FontA' or 'FontB'.
const FONT_A_WIDTH = 10;
const FONT_B_WIDTH = 15;
function fontWidthFactory(font: string, text: string): number {
const perChar = font.includes('FontA') ? FONT_A_WIDTH : FONT_B_WIDTH;
return text.length * perChar;
}
describe('CharacterComparisonEngine', () => {
let engine: CharacterComparisonEngine;
beforeEach(() => {
installCanvasMock(fontWidthFactory);
clearCache();
engine = new CharacterComparisonEngine();
});
it('returns empty result for empty string', () => {
const result = engine.layout('', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(result.lines).toHaveLength(0);
expect(result.totalHeight).toBe(0);
});
it('uses worst-case width across both fonts to determine line breaks', () => {
// 'AB CD' — two 2-char words separated by a space.
// FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total.
// FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '.
// Unified must use FontB widths — so it must wrap at the same place FontB wraps.
const result = engine.layout('AB CD', '400 16px "FontA"', '400 16px "FontB"', 35, 20);
expect(result.lines.length).toBeGreaterThan(1);
// First line text must not include both words.
expect(result.lines[0].text).not.toContain('CD');
});
it('provides xA and xB offsets for both fonts on a single line', () => {
// 'ABC' fits in 500px for both fonts.
// FontA: A@0(w=10), B@10(w=10), C@20(w=10)
// FontB: A@0(w=15), B@15(w=15), C@30(w=15)
const result = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const chars = result.lines[0].chars;
expect(chars).toHaveLength(3);
expect(chars[0].xA).toBe(0);
expect(chars[0].widthA).toBe(FONT_A_WIDTH);
expect(chars[0].xB).toBe(0);
expect(chars[0].widthB).toBe(FONT_B_WIDTH);
expect(chars[1].xA).toBe(FONT_A_WIDTH); // 10
expect(chars[1].widthA).toBe(FONT_A_WIDTH);
expect(chars[1].xB).toBe(FONT_B_WIDTH); // 15
expect(chars[1].widthB).toBe(FONT_B_WIDTH);
expect(chars[2].xA).toBe(FONT_A_WIDTH * 2); // 20
expect(chars[2].xB).toBe(FONT_B_WIDTH * 2); // 30
});
it('xA positions are monotonically increasing', () => {
const result = engine.layout('ABCDE', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const chars = result.lines[0].chars;
for (let i = 1; i < chars.length; i++) {
expect(chars[i].xA).toBeGreaterThan(chars[i - 1].xA);
}
});
it('xB positions are monotonically increasing', () => {
const result = engine.layout('ABCDE', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const chars = result.lines[0].chars;
for (let i = 1; i < chars.length; i++) {
expect(chars[i].xB).toBeGreaterThan(chars[i - 1].xB);
}
});
it('returns cached result when called again with same arguments', () => {
const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).toBe(r1); // strict reference equality — same object
});
it('re-computes when text changes', () => {
const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = engine.layout('DEF', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).not.toBe(r1);
expect(r2.lines[0].text).not.toBe(r1.lines[0].text);
});
it('re-computes when width changes', () => {
const r1 = engine.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = engine.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 60, 20);
expect(r2).not.toBe(r1);
});
it('re-computes when fontA changes', () => {
const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = engine.layout('ABC', '400 24px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).not.toBe(r1);
});
it('getCharState returns proximity 1 when slider is exactly over char center', () => {
// 'A' only: FontA width=10. Container=500px. Line centered.
// lineXOffset = (500 - maxWidth) / 2. maxWidth = max(10, 15) = 15 (FontB is wider).
// charCenterX = lineXOffset + xA + widthA/2.
// Using xA=0, widthA=10: charCenterX = (500-15)/2 + 0 + 5 = 247.5 + 5 = 252.5
// charGlobalPercent = (252.5 / 500) * 100 = 50.5
// distance = |50.5 - 50.5| = 0 => proximity = 1
const containerWidth = 500;
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
// Recalculate expected percent manually:
const lineWidth = Math.max(FONT_A_WIDTH, FONT_B_WIDTH); // 15 (unified worst-case)
const lineXOffset = (containerWidth - lineWidth) / 2;
const charCenterX = lineXOffset + 0 + FONT_A_WIDTH / 2;
const charPercent = (charCenterX / containerWidth) * 100;
const state = engine.getCharState(0, 0, charPercent, containerWidth);
expect(state.proximity).toBe(1);
expect(state.isPast).toBe(false);
});
it('getCharState returns proximity 0 when slider is far from char', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
// Slider at 0%, char is near 50% — distance > 5 range => proximity = 0
const state = engine.getCharState(0, 0, 0, 500);
expect(state.proximity).toBe(0);
});
it('getCharState isPast is true when slider has passed char center', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const state = engine.getCharState(0, 0, 100, 500);
expect(state.isPast).toBe(true);
});
it('getCharState returns safe default for out-of-range lineIndex', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const state = engine.getCharState(99, 0, 50, 500);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
});
it('getCharState returns safe default for out-of-range charIndex', () => {
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const state = engine.getCharState(0, 99, 50, 500);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
});
it('getCharState returns safe default before layout() has been called', () => {
const state = engine.getCharState(0, 0, 50, 500);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
});
});

View File

@@ -0,0 +1,175 @@
import {
layoutWithLines,
prepareWithSegments,
} from '@chenglou/pretext';
/**
* A single laid-out line of text, with per-grapheme x offsets and widths.
*
* `chars` is indexed by grapheme cluster (not UTF-16 code unit), so emoji
* sequences and combining characters each produce exactly one entry.
*/
export interface LayoutLine {
/**
* Full text of this line as returned by pretext.
*/
text: string;
/**
* Rendered width of this line in pixels.
*/
width: number;
/**
* Individual character metadata for this line
*/
chars: Array<{
/**
* The grapheme cluster string (may be >1 code unit for emoji, etc.).
*/
char: string;
/**
* X offset from the start of the line, in pixels.
*/
x: number;
/**
* Advance width of this grapheme, in pixels.
*/
width: number;
}>;
}
/**
* Aggregated output of a single-font layout pass.
*/
export interface LayoutResult {
/**
* Per-line grapheme data. Empty when input text is empty.
*/
lines: LayoutLine[];
/**
* Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee).
*/
totalHeight: number;
}
/**
* Single-font text layout engine backed by `@chenglou/pretext`.
*
* Replaces the canvas-DOM hybrid `createCharacterComparison` for cases where
* only one font is needed. For dual-font comparison use `CharacterComparisonEngine`.
*
* **Usage**
* ```ts
* const engine = new TextLayoutEngine();
* const result = engine.layout('Hello World', '400 16px "Inter"', 320, 24);
* // result.lines[0].chars → [{ char: 'H', x: 0, width: 9 }, ...]
* ```
*
* **Font string format:** `"${weight} ${size}px \"${family}\""` — e.g. `'400 16px "Inter"'`.
* This matches what SliderArea constructs from `typography.weight` and `typography.renderedSize`.
*
* **Canvas requirement:** pretext calls `document.createElement('canvas').getContext('2d')` on
* first use and caches the context for the process lifetime. Tests must install a canvas mock
* (see `__mocks__/canvas.ts`) before the first `layout()` call.
*/
export class TextLayoutEngine {
/**
* Grapheme segmenter used to split segment text into individual clusters.
*
* Pretext maintains its own internal segmenter for line-breaking decisions.
* We keep a separate one here so we can iterate graphemes in `layout()`
* without depending on pretext internals — the two segmenters produce
* identical boundaries because both use `{ granularity: 'grapheme' }`.
*/
#segmenter: Intl.Segmenter;
/**
* @param locale BCP 47 language tag passed to Intl.Segmenter. Defaults to the runtime locale.
*/
constructor(locale?: string) {
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
}
/**
* Lay out `text` in the given `font` within `width` pixels.
*
* @param text Raw text to lay out.
* @param font CSS font string: `"weight sizepx \"family\""`.
* @param width Available line width in pixels.
* @param lineHeight Line height in pixels (passed directly to pretext).
* @returns Per-line grapheme data. Empty `lines` when `text` is empty.
*/
layout(text: string, font: string, width: number, lineHeight: number): LayoutResult {
if (!text) {
return { lines: [], totalHeight: 0 };
}
// prepareWithSegments measures the text and builds the segment data structure
// (widths, breakableFitAdvances, etc.) that the line-walker consumes.
const prepared = prepareWithSegments(text, font);
const { lines, height } = layoutWithLines(prepared, width, lineHeight);
// `PreparedTextWithSegments` has these fields in its public type definition
// but the TypeScript signature only exposes `segments`. We cast to `any` to
// access the parallel numeric arrays — they are documented in the plan and
// verified against the pretext source at node_modules/@chenglou/pretext/src/layout.ts.
const internal = prepared as any;
const breakableFitAdvances = internal.breakableFitAdvances as (number[] | null)[];
const widths = internal.widths as number[];
const resultLines: LayoutLine[] = lines.map(line => {
const chars: LayoutLine['chars'] = [];
let currentX = 0;
const start = line.start;
const end = line.end;
// Walk every segment that falls within this line's [start, end] cursors.
// Both cursors are grapheme-level: start is inclusive, end is exclusive.
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
const segmentText = prepared.segments[sIdx];
if (segmentText === undefined) {
continue;
}
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
const advances = breakableFitAdvances[sIdx];
// For the first and last segments of the line the cursor may point
// into the middle of the segment — respect those boundaries.
// All intermediate segments are walked in full (gStart=0, gEnd=length).
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
const char = graphemes[gIdx];
// `breakableFitAdvances[sIdx]` is an array of per-grapheme advance
// widths when the segment has >1 grapheme (multi-character words).
// It is `null` for single-grapheme segments (spaces, punctuation,
// emoji, etc.) — in that case the entire segment width is attributed
// to this single grapheme.
const charWidth = advances != null ? advances[gIdx]! : widths[sIdx]!;
chars.push({
char,
x: currentX,
width: charWidth,
});
currentX += charWidth;
}
}
return {
text: line.text,
width: line.width,
chars,
};
});
return {
lines: resultLines,
// pretext guarantees height === lineCount * lineHeight (see layout.ts source).
totalHeight: height,
};
}
}

View File

@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { clearCache } from '@chenglou/pretext';
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import { installCanvasMock } from '../__mocks__/canvas';
import { TextLayoutEngine } from './TextLayoutEngine.svelte';
// Fixed-width mock: every segment is measured as (text.length * 10) px.
// This is font-independent so we can reason about wrapping precisely.
const CHAR_WIDTH = 10;
describe('TextLayoutEngine', () => {
let engine: TextLayoutEngine;
beforeEach(() => {
// Install mock BEFORE any prepareWithSegments call.
// clearMeasurementCaches resets pretext's cached canvas context
// and segment metric caches so each test gets a clean slate.
installCanvasMock((_font, text) => text.length * CHAR_WIDTH);
clearCache();
engine = new TextLayoutEngine();
});
it('returns empty result for empty string', () => {
const result = engine.layout('', '400 16px "Inter"', 500, 20);
expect(result.lines).toHaveLength(0);
expect(result.totalHeight).toBe(0);
});
it('returns a single line when text fits within width', () => {
// 'ABC' = 3 chars × 10px = 30px, fits in 500px
const result = engine.layout('ABC', '400 16px "Inter"', 500, 20);
expect(result.lines).toHaveLength(1);
expect(result.lines[0].text).toBe('ABC');
});
it('breaks text into multiple lines when it exceeds width', () => {
// 'Hello World' — pretext will split at the space.
// 'Hello' = 50px, ' ' hangs, 'World' = 50px. Width = 60px forces wrap after 'Hello '.
const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20);
expect(result.lines.length).toBeGreaterThan(1);
// First line must not exceed the container width.
expect(result.lines[0].width).toBeLessThanOrEqual(60);
});
it('assigns correct x positions to characters on a single line', () => {
// 'ABC': A=10px, B=10px, C=10px; all on one line in 500px container.
const result = engine.layout('ABC', '400 16px "Inter"', 500, 20);
const chars = result.lines[0].chars;
expect(chars).toHaveLength(3);
expect(chars[0].char).toBe('A');
expect(chars[0].x).toBe(0);
expect(chars[0].width).toBe(CHAR_WIDTH);
expect(chars[1].char).toBe('B');
expect(chars[1].x).toBe(CHAR_WIDTH);
expect(chars[1].width).toBe(CHAR_WIDTH);
expect(chars[2].char).toBe('C');
expect(chars[2].x).toBe(CHAR_WIDTH * 2);
expect(chars[2].width).toBe(CHAR_WIDTH);
});
it('x positions are monotonically increasing across a line', () => {
const result = engine.layout('ABCDE', '400 16px "Inter"', 500, 20);
const chars = result.lines[0].chars;
for (let i = 1; i < chars.length; i++) {
expect(chars[i].x).toBeGreaterThan(chars[i - 1].x);
}
});
it('each line has at least one char', () => {
const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20);
for (const line of result.lines) {
expect(line.chars.length).toBeGreaterThan(0);
}
});
it('totalHeight equals lineCount * lineHeight', () => {
const lineHeight = 24;
const result = engine.layout('Hello World', '400 16px "Inter"', 60, lineHeight);
expect(result.totalHeight).toBe(result.lines.length * lineHeight);
});
});

View File

@@ -0,0 +1,29 @@
// src/shared/lib/helpers/__mocks__/canvas.ts
//
// Call installCanvasMock(fn) before any pretext import to control measureText.
// The factory receives the current ctx.font string and the text to measure.
import { vi } from 'vitest';
export type MeasureFactory = (font: string, text: string) => number;
export function installCanvasMock(factory: MeasureFactory): void {
let currentFont = '';
const mockCtx = {
get font() {
return currentFont;
},
set font(f: string) {
currentFont = f;
},
measureText: vi.fn((text: string) => ({ width: factory(currentFont, text) })),
};
// HTMLCanvasElement.prototype.getContext is the entry point pretext uses in DOM environments.
// OffscreenCanvas takes priority in pretext; jsdom does not define it so DOM path is used.
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
configurable: true,
writable: true,
value: vi.fn(() => mockCtx),
});
}

View File

@@ -1,374 +0,0 @@
/**
* Character-by-character font comparison helper
*
* Creates utilities for comparing two fonts character by character.
* Used by the ComparisonView widget to render morphing text effects
* where characters transition between font A and font B based on
* slider position.
*
* Features:
* - Responsive text measurement using canvas
* - Binary search for optimal line breaking
* - Character proximity calculation for morphing effects
* - Handles CSS transforms correctly (uses offsetWidth)
*
* @example
* ```svelte
* <script lang="ts">
* import { createCharacterComparison } from '$shared/lib/helpers';
*
* const comparison = createCharacterComparison(
* () => text,
* () => fontA,
* () => fontB,
* () => weight,
* () => size
* );
*
* $: lines = comparison.lines;
* </script>
*
* <canvas bind:this={measureCanvas} hidden></canvas>
* <div bind:this={container}>
* {#each lines as line}
* <span>{line.text}</span>
* {/each}
* </div>
* ```
*/
/**
* Represents a single line of text with its measured width
*/
export interface LineData {
/** The text content of the line */
text: string;
/** Maximum width between both fonts in pixels */
width: number;
}
/**
* Creates a character comparison helper for morphing text effects
*
* Measures text in both fonts to determine line breaks and calculates
* character-level proximity for morphing animations.
*
* @param text - Getter for the text to compare
* @param fontA - Getter for the first font (left/top side)
* @param fontB - Getter for the second font (right/bottom side)
* @param weight - Getter for the current font weight
* @param size - Getter for the controlled font size
* @returns Character comparison instance with lines and proximity calculations
*
* @example
* ```ts
* const comparison = createCharacterComparison(
* () => $sampleText,
* () => $selectedFontA,
* () => $selectedFontB,
* () => $fontWeight,
* () => $fontSize
* );
*
* // Call when DOM is ready
* comparison.breakIntoLines(container, canvas);
*
* // Get character state for morphing
* const state = comparison.getCharState(5, sliderPosition, lineEl, container);
* // state.proximity: 0-1 value for opacity/interpolation
* // state.isPast: true if slider is past this character
* ```
*/
export function createCharacterComparison<
T extends { name: string; id: string } | undefined = undefined,
>(
text: () => string,
fontA: () => T,
fontB: () => T,
weight: () => number,
size: () => number,
) {
let lines = $state<LineData[]>([]);
let containerWidth = $state(0);
/**
* Type guard to check if a font is defined
*/
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
return font !== undefined;
}
/**
* Measures text width using canvas 2D context
*
* @param ctx - Canvas rendering context
* @param text - Text string to measure
* @param fontSize - Font size in pixels
* @param fontWeight - Font weight (100-900)
* @param fontFamily - Font family name (optional, returns 0 if missing)
* @returns Width of text in pixels
*/
function measureText(
ctx: CanvasRenderingContext2D,
text: string,
fontSize: number,
fontWeight: number,
fontFamily?: string,
): number {
if (!fontFamily) return 0;
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
return ctx.measureText(text).width;
}
/**
* Gets responsive font size based on viewport width
*
* Matches Tailwind breakpoints used in the component:
* - < 640px: 64px
* - 640-767px: 80px
* - 768-1023px: 96px
* - >= 1024px: 112px
*/
function getFontSize() {
if (typeof window === 'undefined') {
return 64;
}
return window.innerWidth >= 1024
? 112
: window.innerWidth >= 768
? 96
: window.innerWidth >= 640
? 80
: 64;
}
/**
* Breaks text into lines based on container width
*
* Measures text in BOTH fonts and uses the wider width to prevent
* layout shifts. Uses binary search for efficient word breaking.
*
* @param container - Container element to measure width from
* @param measureCanvas - Hidden canvas element for text measurement
*/
function breakIntoLines(
container: HTMLElement | undefined,
measureCanvas: HTMLCanvasElement | undefined,
) {
if (!container || !measureCanvas || !fontA() || !fontB()) {
return;
}
// Use offsetWidth to avoid CSS transform scaling issues
// getBoundingClientRect() includes transform scale which breaks calculations
const width = container.offsetWidth;
containerWidth = width;
const padding = window.innerWidth < 640 ? 48 : 96;
const availableWidth = width - padding;
const ctx = measureCanvas.getContext('2d');
if (!ctx) {
return;
}
const controlledFontSize = size();
const fontSize = getFontSize();
const currentWeight = weight();
const words = text().split(' ');
const newLines: LineData[] = [];
let currentLineWords: string[] = [];
/**
* Adds a line to the output using the wider font's width
*/
function pushLine(words: string[]) {
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
return;
}
const lineText = words.join(' ');
const widthA = measureText(
ctx!,
lineText,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontA()?.name,
);
const widthB = measureText(
ctx!,
lineText,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontB()?.name,
);
const maxWidth = Math.max(widthA, widthB);
newLines.push({ text: lineText, width: maxWidth });
}
for (const word of words) {
const testLine = currentLineWords.length > 0
? currentLineWords.join(' ') + ' ' + word
: word;
// Measure with both fonts - use wider to prevent shifts
const widthA = measureText(
ctx,
testLine,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontA()?.name,
);
const widthB = measureText(
ctx,
testLine,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontB()?.name,
);
const maxWidth = Math.max(widthA, widthB);
const isContainerOverflown = maxWidth > availableWidth;
if (isContainerOverflown) {
if (currentLineWords.length > 0) {
pushLine(currentLineWords);
currentLineWords = [];
}
// Check if word alone fits
const wordWidthA = measureText(
ctx,
word,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontA()?.name,
);
const wordWidthB = measureText(
ctx,
word,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontB()?.name,
);
const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
if (wordAloneWidth <= availableWidth) {
currentLineWords = [word];
} else {
// Word doesn't fit - binary search to find break point
let remainingWord = word;
while (remainingWord.length > 0) {
let low = 1;
let high = remainingWord.length;
let bestBreak = 1;
// Binary search for maximum characters that fit
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const testFragment = remainingWord.slice(0, mid);
const wA = measureText(
ctx,
testFragment,
fontSize,
currentWeight,
fontA()?.name,
);
const wB = measureText(
ctx,
testFragment,
fontSize,
currentWeight,
fontB()?.name,
);
if (Math.max(wA, wB) <= availableWidth) {
bestBreak = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
pushLine([remainingWord.slice(0, bestBreak)]);
remainingWord = remainingWord.slice(bestBreak);
}
}
} else if (maxWidth > availableWidth && currentLineWords.length > 0) {
pushLine(currentLineWords);
currentLineWords = [word];
} else {
currentLineWords.push(word);
}
}
if (currentLineWords.length > 0) {
pushLine(currentLineWords);
}
lines = newLines;
}
/**
* Calculates character proximity to slider position
*
* Used for morphing effects - returns how close a character is to
* the slider and whether it's on the "past" side.
*
* @param charIndex - Index of character within its line
* @param sliderPos - Slider position (0-100, percent across container)
* @param lineElement - The line element containing the character
* @param container - The container element for position calculations
* @returns Proximity (0-1, 1 = at slider) and isPast (true = right of slider)
*/
function getCharState(
charIndex: number,
sliderPos: number,
lineElement?: HTMLElement,
container?: HTMLElement,
) {
if (!containerWidth || !container) {
return {
proximity: 0,
isPast: false,
};
}
const charElement = lineElement?.children[charIndex] as HTMLElement;
if (!charElement) {
return { proximity: 0, isPast: false };
}
// Get character bounding box relative to container
const charRect = charElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
// Calculate character center as percentage of container width
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
const charGlobalPercent = (charCenter / containerWidth) * 100;
// Calculate proximity (1.0 = at slider, 0.0 = 5% away)
const distance = Math.abs(sliderPos - charGlobalPercent);
const range = 5;
const proximity = Math.max(0, 1 - distance / range);
const isPast = sliderPos > charGlobalPercent;
return { proximity, isPast };
}
return {
/** Reactive array of broken lines */
get lines() {
return lines;
},
/** Container width in pixels */
get containerWidth() {
return containerWidth;
},
/** Break text into lines based on current container and fonts */
breakIntoLines,
/** Get character state for morphing calculations */
getCharState,
};
}
/**
* Type representing a character comparison instance
*/
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;

View File

@@ -1,312 +0,0 @@
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { createCharacterComparison } from './createCharacterComparison.svelte';
type Font = { name: string; id: string };
const fontA: Font = { name: 'Roboto', id: 'roboto' };
const fontB: Font = { name: 'Open Sans', id: 'open-sans' };
function createMockCanvas(charWidth = 10): HTMLCanvasElement {
return {
getContext: () => ({
font: '',
measureText: (text: string) => ({ width: text.length * charWidth }),
}),
} as unknown as HTMLCanvasElement;
}
function createMockContainer(offsetWidth = 500): HTMLElement {
return {
offsetWidth,
getBoundingClientRect: () => ({
left: 0,
width: offsetWidth,
top: 0,
right: offsetWidth,
bottom: 0,
height: 0,
}),
} as unknown as HTMLElement;
}
describe('createCharacterComparison', () => {
beforeEach(() => {
// Mock window.innerWidth for getFontSize and padding calculations
Object.defineProperty(globalThis, 'window', {
value: { innerWidth: 1024 },
writable: true,
configurable: true,
});
});
describe('Initial State', () => {
it('should initialize with empty lines and zero container width', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
expect(comparison.lines).toEqual([]);
expect(comparison.containerWidth).toBe(0);
});
});
describe('breakIntoLines', () => {
it('should not break lines when container or canvas is undefined', () => {
const comparison = createCharacterComparison(
() => 'Hello world',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(undefined, undefined);
expect(comparison.lines).toEqual([]);
comparison.breakIntoLines(createMockContainer(), undefined);
expect(comparison.lines).toEqual([]);
});
it('should not break lines when fonts are undefined', () => {
const comparison = createCharacterComparison(
() => 'Hello world',
() => undefined,
() => undefined,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(), createMockCanvas());
expect(comparison.lines).toEqual([]);
});
it('should produce a single line when text fits within container', () => {
// charWidth=10, padding=96 (innerWidth>=640), availableWidth=500-96=404
// "Hello" = 5 chars * 10 = 50px, fits easily
const comparison = createCharacterComparison(
() => 'Hello',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
expect(comparison.lines).toHaveLength(1);
expect(comparison.lines[0].text).toBe('Hello');
});
it('should break text into multiple lines when it overflows', () => {
// charWidth=10, container=200, padding=96, availableWidth=104
// "Hello world test" => "Hello" (50px), "Hello world" (110px > 104)
// So "Hello" goes on line 1, "world" (50px) fits, "world test" (100px) fits
const comparison = createCharacterComparison(
() => 'Hello world test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(200), createMockCanvas(10));
expect(comparison.lines.length).toBeGreaterThan(1);
// All original text should be preserved across lines
const reconstructed = comparison.lines.map(l => l.text).join(' ');
expect(reconstructed).toBe('Hello world test');
});
it('should update containerWidth after breaking lines', () => {
const comparison = createCharacterComparison(
() => 'Hi',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(750), createMockCanvas(10));
expect(comparison.containerWidth).toBe(750);
});
it('should use smaller padding on narrow viewports', () => {
Object.defineProperty(globalThis, 'window', {
value: { innerWidth: 500 },
writable: true,
configurable: true,
});
// container=150, padding=48 (innerWidth<640), availableWidth=102
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, fits in 102
const comparison = createCharacterComparison(
() => 'ABCDEFGHIJ',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
expect(comparison.lines).toHaveLength(1);
expect(comparison.lines[0].text).toBe('ABCDEFGHIJ');
});
it('should break a single long word using binary search', () => {
// container=150, padding=96, availableWidth=54
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, doesn't fit as single word
// Binary search should split it
const comparison = createCharacterComparison(
() => 'ABCDEFGHIJ',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
expect(comparison.lines.length).toBeGreaterThan(1);
const reconstructed = comparison.lines.map(l => l.text).join('');
expect(reconstructed).toBe('ABCDEFGHIJ');
});
it('should store max width between both fonts for each line', () => {
// Use a canvas where measureText returns text.length * charWidth
// Both fonts measure the same, so width = text.length * charWidth
const comparison = createCharacterComparison(
() => 'Hi',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
expect(comparison.lines[0].width).toBe(20); // "Hi" = 2 chars * 10
});
});
describe('getCharState', () => {
it('should return zero proximity and isPast=false when containerWidth is 0', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
const state = comparison.getCharState(0, 50, undefined, undefined);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
});
it('should return zero proximity when charElement is not found', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
// First break lines to set containerWidth
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
const lineEl = { children: [] } as unknown as HTMLElement;
const container = createMockContainer(500);
const state = comparison.getCharState(0, 50, lineEl, container);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
});
it('should calculate proximity based on distance from slider', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
// Character centered at 250px in a 500px container = 50%
const charEl = {
getBoundingClientRect: () => ({ left: 240, width: 20 }),
};
const lineEl = { children: [charEl] } as unknown as HTMLElement;
const container = createMockContainer(500);
// Slider at 50% => charCenter at 250px => charGlobalPercent = 50%
// distance = |50 - 50| = 0, proximity = max(0, 1 - 0/5) = 1
const state = comparison.getCharState(0, 50, lineEl, container);
expect(state.proximity).toBe(1);
expect(state.isPast).toBe(false);
});
it('should return isPast=true when slider is past the character', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
// Character centered at 100px => 20% of 500px
const charEl = {
getBoundingClientRect: () => ({ left: 90, width: 20 }),
};
const lineEl = { children: [charEl] } as unknown as HTMLElement;
const container = createMockContainer(500);
// Slider at 80% => past the character at 20%
const state = comparison.getCharState(0, 80, lineEl, container);
expect(state.isPast).toBe(true);
});
it('should return zero proximity when character is far from slider', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
// Character at 10% of container, slider at 90% => distance = 80%, range = 5%
const charEl = {
getBoundingClientRect: () => ({ left: 45, width: 10 }),
};
const lineEl = { children: [charEl] } as unknown as HTMLElement;
const container = createMockContainer(500);
const state = comparison.getCharState(0, 90, lineEl, container);
expect(state.proximity).toBe(0);
});
});
});

View File

@@ -32,7 +32,9 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
}, wait);
return {
/** Current value with immediate updates (for UI binding) */
/**
* Current value with immediate updates (for UI binding)
*/
get immediate() {
return immediate;
},
@@ -41,7 +43,9 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
// Manually trigger the debounce on write
updateDebounced(value);
},
/** Current value with debounced updates (for logic/operations) */
/**
* Current value with debounced updates (for logic/operations)
*/
get debounced() {
return debounced;
},

View File

@@ -28,7 +28,9 @@ import { SvelteMap } from 'svelte/reactivity';
* Base entity interface requiring an ID field
*/
export interface Entity {
/** Unique identifier for the entity */
/**
* Unique identifier for the entity
*/
id: string;
}
@@ -39,7 +41,9 @@ export interface Entity {
* triggers updates when entities are added, removed, or modified.
*/
export class EntityStore<T extends Entity> {
/** Reactive map of entities keyed by ID */
/**
* Reactive map of entities keyed by ID
*/
#entities = new SvelteMap<string, T>();
/**

View File

@@ -29,13 +29,21 @@
* @template TValue - The type of the property value (typically string)
*/
export interface Property<TValue extends string> {
/** Unique identifier for the property */
/**
* Unique string identifier for the filterable property
*/
id: string;
/** Human-readable display name */
/**
* Human-readable label for UI display
*/
name: string;
/** Underlying value for filtering logic */
/**
* Underlying machine-readable value used for filtering logic
*/
value: TValue;
/** Whether the property is currently selected */
/**
* Current selection status (reactive)
*/
selected?: boolean;
}
@@ -45,7 +53,9 @@ export interface Property<TValue extends string> {
* @template TValue - The type of property values
*/
export interface FilterModel<TValue extends string> {
/** Array of filterable properties */
/**
* Collection of properties that can be toggled in this filter
*/
properties: Property<TValue>[];
}

View File

@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import {
afterEach,
beforeEach,

View File

@@ -32,19 +32,33 @@ import { Spring } from 'svelte/motion';
* Configuration options for perspective effects
*/
export interface PerspectiveConfig {
/** Z-axis translation per level in pixels */
/**
* Z-axis translation per level in pixels
*/
depthStep?: number;
/** Scale reduction per level (0-1) */
/**
* Scale reduction per level (0-1)
*/
scaleStep?: number;
/** Blur amount per level in pixels */
/**
* Blur amount per level in pixels
*/
blurStep?: number;
/** Opacity reduction per level (0-1) */
/**
* Opacity reduction per level (0-1)
*/
opacityStep?: number;
/** Parallax movement intensity per level */
/**
* Parallax movement intensity per level
*/
parallaxIntensity?: number;
/** Horizontal offset - positive for right, negative for left */
/**
* Horizontal offset - positive for right, negative for left
*/
horizontalOffset?: number;
/** Layout mode: 'center' for centered, 'split' for side-by-side */
/**
* Layout mode: 'center' for centered, 'split' for side-by-side
*/
layoutMode?: 'center' | 'split';
}

View File

@@ -39,15 +39,25 @@
* Customize to match your design system's breakpoints.
*/
export interface Breakpoints {
/** Mobile devices - default 640px */
/**
* Mobile devices - default 640px
*/
mobile: number;
/** Tablet portrait - default 768px */
/**
* Tablet portrait - default 768px
*/
tabletPortrait: number;
/** Tablet landscape - default 1024px */
/**
* Tablet landscape - default 1024px
*/
tablet: number;
/** Desktop - default 1280px */
/**
* Desktop - default 1280px
*/
desktop: number;
/** Large desktop - default 1536px */
/**
* Large desktop - default 1536px
*/
desktopLarge: number;
}
@@ -140,7 +150,9 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
* @returns Cleanup function to remove listeners
*/
function init() {
if (typeof window === 'undefined') return;
if (typeof window === 'undefined') {
return;
}
const handleResize = () => {
width = window.innerWidth;
@@ -206,66 +218,108 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
);
return {
/** Viewport width in pixels */
/**
* Current viewport width in pixels (reactive)
*/
get width() {
return width;
},
/** Viewport height in pixels */
/**
* Current viewport height in pixels (reactive)
*/
get height() {
return height;
},
// Standard breakpoints
/**
* True if viewport width is below the mobile threshold
*/
get isMobile() {
return isMobile;
},
/**
* True if viewport width is between mobile and tablet portrait thresholds
*/
get isTabletPortrait() {
return isTabletPortrait;
},
/**
* True if viewport width is between tablet portrait and desktop thresholds
*/
get isTablet() {
return isTablet;
},
/**
* True if viewport width is between desktop and large desktop thresholds
*/
get isDesktop() {
return isDesktop;
},
/**
* True if viewport width is at or above the large desktop threshold
*/
get isDesktopLarge() {
return isDesktopLarge;
},
// Convenience groupings
/**
* True if viewport width is below the desktop threshold
*/
get isMobileOrTablet() {
return isMobileOrTablet;
},
/**
* True if viewport width is at or above the tablet portrait threshold
*/
get isTabletOrDesktop() {
return isTabletOrDesktop;
},
// Orientation
/**
* Current screen orientation (portrait | landscape)
*/
get orientation() {
return orientation;
},
/**
* True if screen height is greater than width
*/
get isPortrait() {
return isPortrait;
},
/**
* True if screen width is greater than height
*/
get isLandscape() {
return isLandscape;
},
// Device capabilities
/**
* True if the device supports touch interaction
*/
get isTouchDevice() {
return isTouchDevice;
},
// Current breakpoint
/**
* Name of the currently active breakpoint (reactive)
*/
get currentBreakpoint() {
return currentBreakpoint;
},
// Methods
/**
* Initialization function to start event listeners
*/
init,
/**
* Helper to check for custom width ranges
*/
matches,
// Breakpoint values (for custom logic)
/**
* Underlying breakpoint pixel values
*/
breakpoints,
};
}

View File

@@ -34,13 +34,21 @@ import {
* Defines the bounds and stepping behavior for a control
*/
export interface ControlDataModel {
/** Current numeric value */
/**
* Initial or current numeric value
*/
value: number;
/** Minimum allowed value (inclusive) */
/**
* Lower inclusive bound
*/
min: number;
/** Maximum allowed value (inclusive) */
/**
* Upper inclusive bound
*/
max: number;
/** Step size for increment/decrement operations */
/**
* Precision for increment/decrement operations
*/
step: number;
}
@@ -50,13 +58,21 @@ export interface ControlDataModel {
* @template T - Type for the control identifier
*/
export interface ControlModel<T extends string = string> extends ControlDataModel {
/** Unique identifier for the control */
/**
* Unique string identifier for the control
*/
id: T;
/** ARIA label for the increase button */
/**
* Label used by screen readers for the increase button
*/
increaseLabel?: string;
/** ARIA label for the decrease button */
/**
* Label used by screen readers for the decrease button
*/
decreaseLabel?: string;
/** ARIA label for the control area */
/**
* Overall label describing the control's purpose
*/
controlLabel?: string;
}
@@ -109,8 +125,7 @@ export function createTypographyControl<T extends ControlDataModel>(
return {
/**
* Current control value (getter/setter)
* Setting automatically clamps to bounds and rounds to step precision
* Clamped and rounded control value (reactive)
*/
get value() {
return value;
@@ -122,27 +137,37 @@ export function createTypographyControl<T extends ControlDataModel>(
}
},
/** Maximum allowed value */
/**
* Upper limit for the control value
*/
get max() {
return max;
},
/** Minimum allowed value */
/**
* Lower limit for the control value
*/
get min() {
return min;
},
/** Step increment size */
/**
* Configured step increment
*/
get step() {
return step;
},
/** Whether the value is at or exceeds the maximum */
/**
* True if current value is equal to or greater than max
*/
get isAtMax() {
return isAtMax;
},
/** Whether the value is at or below the minimum */
/**
* True if current value is equal to or less than min
*/
get isAtMin() {
return isAtMin;
},

View File

@@ -45,14 +45,27 @@ export interface VirtualItem {
* Options are reactive - pass them through a function getter to enable updates.
*/
export interface VirtualizerOptions {
/** Total number of items in the data array */
/**
* Total number of items in the underlying data array
*/
count: number;
/**
* Function to estimate the size of an item at a given index.
* Used for initial layout before actual measurements are available.
*
* Called inside a `$derived.by` block. Any `$state` or `$derived` value
* read within this function is automatically tracked as a dependency —
* when those values change, `offsets` and `totalSize` recompute instantly.
*
* For font preview rows, pass a closure that reads
* `appliedFontsManager.statuses` so the virtualizer recalculates heights
* as fonts finish loading, eliminating the DOM-measurement snap on load.
*/
estimateSize: (index: number) => number;
/** Number of extra items to render outside viewport for smoother scrolling (default: 5) */
/**
* Number of extra items to render outside viewport for smoother scrolling
* @default 5
*/
overscan?: number;
/**
* Function to get the key of an item at a given index.
@@ -71,6 +84,18 @@ export interface VirtualizerOptions {
useWindowScroll?: boolean;
}
/**
* A height resolver for a single virtual-list row.
*
* When this function reads reactive state (e.g. `SvelteMap.get()`), calling
* it inside a `$derived.by` block automatically subscribes to that state.
* Return `fallbackHeight` whenever the true height is not yet known.
*
* @param rowIndex Zero-based row index within the data array.
* @returns Row height in pixels, excluding the list gap.
*/
export type ItemSizeResolver = (rowIndex: number) => number;
/**
* Creates a reactive virtualizer for efficiently rendering large lists by only rendering visible items.
*
@@ -150,7 +175,9 @@ export function createVirtualizer<T>(
const { count, data } = options;
// Implicit dependency
const v = _version;
if (count === 0 || containerHeight === 0 || !data) return [];
if (count === 0 || containerHeight === 0 || !data) {
return [];
}
const overscan = options.overscan ?? 5;
@@ -239,7 +266,9 @@ export function createVirtualizer<T>(
containerHeight = window.innerHeight;
const handleScroll = () => {
if (rafId !== null) return;
if (rafId !== null) {
return;
}
rafId = requestAnimationFrame(() => {
// Get current position of element relative to viewport
@@ -298,7 +327,9 @@ export function createVirtualizer<T>(
};
const resizeObserver = new ResizeObserver(([entry]) => {
if (entry) containerHeight = entry.contentRect.height;
if (entry) {
containerHeight = entry.contentRect.height;
}
});
node.addEventListener('scroll', handleScroll, { passive: true });
@@ -398,7 +429,9 @@ export function createVirtualizer<T>(
* ```
*/
function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') {
if (!elementRef || index < 0 || index >= options.count) return;
if (!elementRef || index < 0 || index >= options.count) {
return;
}
const itemStart = offsets[index];
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
@@ -406,16 +439,24 @@ export function createVirtualizer<T>(
const { useWindowScroll } = optionsGetter();
if (useWindowScroll) {
if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2;
if (align === 'end') target = itemStart - window.innerHeight + itemSize;
if (align === 'center') {
target = itemStart - window.innerHeight / 2 + itemSize / 2;
}
if (align === 'end') {
target = itemStart - window.innerHeight + itemSize;
}
// Add container offset to target to get absolute document position
const absoluteTarget = target + elementOffsetTop;
window.scrollTo({ top: absoluteTarget, behavior: 'smooth' });
} else {
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
if (align === 'end') target = itemStart - containerHeight + itemSize;
if (align === 'center') {
target = itemStart - containerHeight / 2 + itemSize / 2;
}
if (align === 'end') {
target = itemStart - containerHeight + itemSize;
}
elementRef.scrollTo({ top: target, behavior: 'smooth' });
}
@@ -444,27 +485,45 @@ export function createVirtualizer<T>(
}
return {
/**
* Current vertical scroll position in pixels (reactive)
*/
get scrollOffset() {
return scrollOffset;
},
/**
* Measured height of the visible container area (reactive)
*/
get containerHeight() {
return containerHeight;
},
/** Computed array of visible items to render (reactive) */
/**
* Computed array of visible items to render (reactive)
*/
get items() {
return items;
},
/** Total height of all items in pixels (reactive) */
/**
* Total height of all items in pixels (reactive)
*/
get totalSize() {
return totalSize;
},
/** Svelte action for the scrollable container element */
/**
* Svelte action for the scrollable container element
*/
container,
/** Svelte action for measuring individual item elements */
/**
* Svelte action for measuring individual item elements
*/
measureElement,
/** Programmatic scroll method to scroll to a specific item */
/**
* Programmatic scroll method to scroll to a specific item
*/
scrollToIndex,
/** Programmatic scroll method to scroll to a specific pixel offset */
/**
* Programmatic scroll method to scroll to a specific pixel offset
*/
scrollToOffset,
};
}

View File

@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import {
afterEach,
describe,

View File

@@ -22,53 +22,178 @@
* ```
*/
/**
* Filter management
*/
export {
/**
* Reactive filter factory
*/
createFilter,
/**
* Filter instance type
*/
type Filter,
/**
* Initial state model
*/
type FilterModel,
/**
* Filterable property definition
*/
type Property,
} from './createFilter/createFilter.svelte';
/**
* Bounded numeric controls
*/
export {
/**
* Base numeric configuration
*/
type ControlDataModel,
/**
* Extended model with labels
*/
type ControlModel,
/**
* Reactive control factory
*/
createTypographyControl,
/**
* Control instance type
*/
type TypographyControl,
} from './createTypographyControl/createTypographyControl.svelte';
/**
* List virtualization
*/
export {
/**
* Reactive virtualizer factory
*/
createVirtualizer,
/**
* Rendered item layout data
*/
type VirtualItem,
/**
* Virtualizer instance type
*/
type Virtualizer,
/**
* Configuration options
*/
type VirtualizerOptions,
} from './createVirtualizer/createVirtualizer.svelte';
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';
/**
* UI State
*/
export {
/**
* Immediate/debounced state factory
*/
createDebouncedState,
} from './createDebouncedState/createDebouncedState.svelte';
/**
* Entity collections
*/
export {
/**
* Reactive entity store factory
*/
createEntityStore,
/**
* Base entity requirement
*/
type Entity,
/**
* Entity store instance type
*/
type EntityStore,
} from './createEntityStore/createEntityStore.svelte';
/**
* Comparison logic
*/
export {
type CharacterComparison,
createCharacterComparison,
type LineData,
} from './createCharacterComparison/createCharacterComparison.svelte';
/**
* Character-by-character comparison utility
*/
CharacterComparisonEngine,
/**
* Single line of comparison results
*/
type ComparisonLine,
/**
* Full comparison output
*/
type ComparisonResult,
} from './CharacterComparisonEngine/CharacterComparisonEngine.svelte';
/**
* Text layout
*/
export {
/**
* Single line layout information
*/
type LayoutLine as TextLayoutLine,
/**
* Full multi-line layout information
*/
type LayoutResult as TextLayoutResult,
/**
* High-level text measurement engine
*/
TextLayoutEngine,
} from './TextLayoutEngine/TextLayoutEngine.svelte';
/**
* Persistence
*/
export {
/**
* LocalStorage-backed reactive store factory
*/
createPersistentStore,
/**
* Persistent store instance type
*/
type PersistentStore,
} from './createPersistentStore/createPersistentStore.svelte';
/**
* Responsive design
*/
export {
/**
* Breakpoint tracking factory
*/
createResponsiveManager,
/**
* Responsive manager instance type
*/
type ResponsiveManager,
/**
* Singleton manager for global usage
*/
responsiveManager,
} from './createResponsiveManager/createResponsiveManager.svelte';
/**
* 3D Perspectives
*/
export {
/**
* Motion-aware perspective factory
*/
createPerspectiveManager,
/**
* Perspective manager instance type
*/
type PerspectiveManager,
} from './createPerspectiveManager/createPerspectiveManager.svelte';

View File

@@ -5,10 +5,11 @@
*/
export {
type CharacterComparison,
CharacterComparisonEngine,
type ComparisonLine,
type ComparisonResult,
type ControlDataModel,
type ControlModel,
createCharacterComparison,
createDebouncedState,
createEntityStore,
createFilter,
@@ -21,12 +22,14 @@ export {
type EntityStore,
type Filter,
type FilterModel,
type LineData,
type PersistentStore,
type PerspectiveManager,
type Property,
type ResponsiveManager,
responsiveManager,
TextLayoutEngine,
type TextLayoutLine,
type TextLayoutResult,
type TypographyControl,
type VirtualItem,
type Virtualizer,

View File

@@ -7,7 +7,7 @@
correctly via the HTML element's class attribute.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import clsx from 'clsx';
import type {
Component,
Snippet,
@@ -32,7 +32,7 @@ let { icon: Icon, class: className, attrs = {} }: Props = $props();
</script>
{#if Icon}
{@const __iconClass__ = cn('size-4', className)}
{@const __iconClass__ = clsx('size-4', className)}
<!-- Render icon component dynamically with class prop -->
<Icon
class={__iconClass__}

Some files were not shown because too many files have changed in this diff Show More