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 run: yarn lint
- name: Type Check - name: Type Check
run: yarn check:shadcn-excluded run: yarn check
publish: publish:
needs: build # Only runs if tests/lint pass needs: build # Only runs if tests/lint pass

View File

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

View File

@@ -1,14 +1,18 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
children: import('svelte').Snippet; 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> </script>
<div class="flex min-h-screen w-full items-center justify-center bg-background p-8"> <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"> <div class="relative flex justify-center items-center text-foreground">
{@render children()} {@render children()}
</div> </div>

View File

@@ -5,68 +5,6 @@ import ThemeDecorator from './ThemeDecorator.svelte';
import '../src/app/styles/app.css'; import '../src/app/styles/app.css';
const preview: Preview = { 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: { parameters: {
layout: 'padded', layout: 'padded',
controls: { controls: {
@@ -195,10 +133,11 @@ const preview: Preview = {
}, },
}), }),
// Wrap with StoryStage for presentation styling // Wrap with StoryStage for presentation styling
story => ({ (story, context) => ({
Component: StoryStage, Component: StoryStage,
props: { props: {
children: story(), 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 - **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 - **Advanced Filtering**: Filter by category, provider, character subsets, and weight
- **Virtual Scrolling**: Fast, smooth browsing of thousands of fonts - **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 - **Type-Safe**: Full TypeScript coverage with strict mode enabled
## Tech Stack ## Tech Stack
- **Framework**: Svelte 5 with reactive primitives (runes) - **Framework**: Svelte 5 with reactive primitives (runes)
- **Styling**: Tailwind CSS v4 - **Styling**: Tailwind CSS v4
- **Components**: shadcn-svelte (via bits-ui) - **Components**: Bits UI primitives
- **State Management**: TanStack Query for async data - **State Management**: TanStack Query for async data
- **Architecture**: Feature-Sliced Design (FSD) - **Architecture**: Feature-Sliced Design (FSD)
- **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks) - **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.forceMultiLine": "whenMultiple",
"importDeclaration.forceSingleLine": false, "importDeclaration.forceSingleLine": false,
"exportDeclaration.forceMultiLine": "whenMultiple", "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": { "json": {
"indentWidth": 2, "indentWidth": 2,

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
/* Base font size */ /* Base font size */
--font-size: 16px; --font-size: 16px;
/* GLYPHDIFF Swiss Design System */ /* GLYPHDIFF Design System */
/* Primary Colors */ /* Primary Colors */
--swiss-beige: #f3f0e9; --swiss-beige: #f3f0e9;
--swiss-red: #ff3b30; --swiss-red: #ff3b30;
@@ -91,7 +91,6 @@
--space-4xl: 4rem; --space-4xl: 4rem;
/* Typography Scale */ /* Typography Scale */
--text-2xs: 0.625rem;
--text-xs: 0.75rem; --text-xs: 0.75rem;
--text-sm: 0.875rem; --text-sm: 0.875rem;
--text-base: 1rem; --text-base: 1rem;
@@ -205,6 +204,14 @@
--font-mono: 'Space Mono', monospace; --font-mono: 'Space Mono', monospace;
--font-primary: 'Space Grotesk', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif; --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; --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 { @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 */ /* Global utility - useful across your app */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { * {

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */ /**
* @vitest-environment jsdom
*/
import { import {
afterEach, afterEach,
beforeEach, beforeEach,
@@ -24,7 +26,9 @@ class MockIntersectionObserver implements IntersectionObserver {
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.callbacks.push(callback); this.callbacks.push(callback);
if (options?.rootMargin) this.rootMargin = options.rootMargin; if (options?.rootMargin) {
this.rootMargin = options.rootMargin;
}
if (options?.threshold) { if (options?.threshold) {
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold]; this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold];
} }
@@ -118,7 +122,9 @@ describe('ScrollBreadcrumbsStore', () => {
(event: string, listener: EventListenerOrEventListenerObject) => { (event: string, listener: EventListenerOrEventListenerObject) => {
if (event === 'scroll') { if (event === 'scroll') {
const index = scrollListeners.indexOf(listener as () => void); const index = scrollListeners.indexOf(listener as () => void);
if (index > -1) scrollListeners.splice(index, 1); if (index > -1) {
scrollListeners.splice(index, 1);
}
} }
return undefined; 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 flex items-center justify-between
z-40 z-40
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md 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"> <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 { api } from '$shared/api/api';
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import { import {
fetchFontsByIds, fetchFontsByIds,
fetchProxyFontById, fetchProxyFontById,
fetchProxyFonts, fetchProxyFonts,
seedFontCache,
} from './proxyFonts'; } from './proxyFonts';
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts'; const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
@@ -46,6 +49,7 @@ function mockApiGet<T>(data: T) {
describe('proxyFonts', () => { describe('proxyFonts', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(api.get).mockReset(); vi.mocked(api.get).mockReset();
queryClient.clear();
}); });
describe('fetchProxyFonts', () => { describe('fetchProxyFonts', () => {
@@ -168,4 +172,33 @@ describe('proxyFonts', () => {
expect(result).toEqual([]); 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 { api } from '$shared/api/api';
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import { buildQueryString } from '$shared/lib/utils'; import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils'; import type { QueryParams } from '$shared/lib/utils';
import type { UnifiedFont } from '../../model/types'; import type { UnifiedFont } from '../../model/types';
import type {
FontCategory, /**
FontSubset, * Normalizes cache by seeding individual font entries from collection responses.
} from '../../model/types'; * 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 * Proxy API base URL
@@ -87,16 +97,24 @@ export interface ProxyFontsParams extends QueryParams {
* Includes pagination metadata alongside font data * Includes pagination metadata alongside font data
*/ */
export interface ProxyFontsResponse { export interface ProxyFontsResponse {
/** Array of unified font objects */ /**
* List of font objects returned by the proxy
*/
fonts: UnifiedFont[]; fonts: UnifiedFont[];
/** Total number of fonts matching the query */ /**
* Total number of matching fonts (ignoring limit/offset)
*/
total: number; total: number;
/** Limit used for this request */ /**
* Page size used for the request
*/
limit: number; limit: number;
/** Offset used for this request */ /**
* Start index for the result set
*/
offset: number; offset: number;
} }
@@ -179,7 +197,9 @@ export async function fetchProxyFontById(
* @returns Promise resolving to an array of fonts * @returns Promise resolving to an array of fonts
*/ */
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> { export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
if (ids.length === 0) return []; if (ids.length === 0) {
return [];
}
const queryString = ids.join(','); const queryString = ids.join(',');
const url = `${PROXY_API_URL}/batch?ids=${queryString}`; const url = `${PROXY_API_URL}/batch?ids=${queryString}`;

View File

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

View File

@@ -3,7 +3,9 @@ import type {
UnifiedFont, UnifiedFont,
} from '../../model'; } 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]; const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
/** /**

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
/** @vitest-environment jsdom */ /**
* @vitest-environment jsdom
*/
import { AppliedFontsManager } from './appliedFontsStore.svelte'; import { AppliedFontsManager } from './appliedFontsStore.svelte';
import { FontFetchError } from './errors'; import { FontFetchError } from './errors';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy'; import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
// ── Fake collaborators ────────────────────────────────────────────────────────
class FakeBufferCache { class FakeBufferCache {
async get(_url: string): Promise<ArrayBuffer> { async get(_url: string): Promise<ArrayBuffer> {
return new ArrayBuffer(8); return new ArrayBuffer(8);
@@ -13,7 +13,9 @@ class FakeBufferCache {
clear(): void {} clear(): void {}
} }
/** Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure. */ /**
* Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure.
*/
class FailingBufferCache { class FailingBufferCache {
async get(url: string): Promise<never> { async get(url: string): Promise<never> {
throw new FontFetchError(url, new Error('network error'), 500); throw new FontFetchError(url, new Error('network error'), 500);
@@ -22,8 +24,6 @@ class FailingBufferCache {
clear(): void {} clear(): void {}
} }
// ── Helpers ───────────────────────────────────────────────────────────────────
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
id, id,
name: id, name: id,
@@ -32,8 +32,6 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
...overrides, ...overrides,
}); });
// ── Suite ─────────────────────────────────────────────────────────────────────
describe('AppliedFontsManager', () => { describe('AppliedFontsManager', () => {
let manager: AppliedFontsManager; let manager: AppliedFontsManager;
let eviction: FontEvictionPolicy; let eviction: FontEvictionPolicy;
@@ -66,8 +64,6 @@ describe('AppliedFontsManager', () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
// ── touch() ───────────────────────────────────────────────────────────────
describe('touch()', () => { describe('touch()', () => {
it('queues and loads a new font', async () => { it('queues and loads a new font', async () => {
manager.touch([makeConfig('roboto')]); manager.touch([makeConfig('roboto')]);
@@ -131,8 +127,6 @@ describe('AppliedFontsManager', () => {
}); });
}); });
// ── queue processing ──────────────────────────────────────────────────────
describe('queue processing', () => { describe('queue processing', () => {
it('filters non-critical weights in data-saver mode', async () => { it('filters non-critical weights in data-saver mode', async () => {
(navigator as any).connection = { saveData: true }; (navigator as any).connection = { saveData: true };
@@ -163,8 +157,6 @@ describe('AppliedFontsManager', () => {
}); });
}); });
// ── Phase 1: fetch ────────────────────────────────────────────────────────
describe('Phase 1 — fetch', () => { describe('Phase 1 — fetch', () => {
it('sets status to error on fetch failure', async () => { it('sets status to error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -209,8 +201,6 @@ describe('AppliedFontsManager', () => {
}); });
}); });
// ── Phase 2: parse ────────────────────────────────────────────────────────
describe('Phase 2 — parse', () => { describe('Phase 2 — parse', () => {
it('sets status to error on parse failure', async () => { it('sets status to error on parse failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -241,8 +231,6 @@ describe('AppliedFontsManager', () => {
}); });
}); });
// ── #purgeUnused ──────────────────────────────────────────────────────────
describe('#purgeUnused', () => { describe('#purgeUnused', () => {
it('evicts fonts after TTL expires', async () => { it('evicts fonts after TTL expires', async () => {
manager.touch([makeConfig('ephemeral')]); manager.touch([makeConfig('ephemeral')]);
@@ -300,8 +288,6 @@ describe('AppliedFontsManager', () => {
}); });
}); });
// ── destroy() ─────────────────────────────────────────────────────────────
describe('destroy()', () => { describe('destroy()', () => {
it('clears all statuses', async () => { it('clears all statuses', async () => {
manager.touch([makeConfig('roboto')]); 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 { #shouldDeferNonCritical(): boolean {
return (navigator as any).connection?.saveData === true; return (navigator as any).connection?.saveData === true;
} }
@@ -188,13 +190,11 @@ export class AppliedFontsManager {
const concurrency = getEffectiveConcurrency(); const concurrency = getEffectiveConcurrency();
const buffers = new Map<string, ArrayBuffer>(); const buffers = new Map<string, ArrayBuffer>();
// ==================== PHASE 1: Concurrent Fetching ====================
// Fetch multiple font files in parallel since network I/O is non-blocking // Fetch multiple font files in parallel since network I/O is non-blocking
for (let i = 0; i < entries.length; i += concurrency) { for (let i = 0; i < entries.length; i += concurrency) {
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers); 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 // Parse buffers one at a time with periodic yields to avoid blocking UI
const hasInputPending = !!(navigator as any).scheduling?.isInputPending; const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
let lastYield = performance.now(); let lastYield = performance.now();
@@ -246,12 +246,16 @@ export class AppliedFontsManager {
); );
for (const result of results) { for (const result of results) {
if (result.ok) continue; if (result.ok) {
continue;
}
const { key, config, reason } = result; const { key, config, reason } = result;
const isAbort = reason instanceof FontFetchError const isAbort = reason instanceof FontFetchError
&& reason.cause instanceof Error && reason.cause instanceof Error
&& reason.cause.name === 'AbortError'; && reason.cause.name === 'AbortError';
if (isAbort) continue; if (isAbort) {
continue;
}
if (reason instanceof FontFetchError) { if (reason instanceof FontFetchError) {
console.error(`Font fetch failed: ${config.name}`, reason); 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() { #purgeUnused() {
const now = Date.now(); const now = Date.now();
// Iterate through all tracked font keys // Iterate through all tracked font keys
@@ -291,7 +297,9 @@ export class AppliedFontsManager {
// Remove FontFace from document to free memory // Remove FontFace from document to free memory
const font = this.#loadedFonts.get(key); 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 // Evict from cache and cleanup URL mapping
const url = this.#urlByKey.get(key); 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) { getFontStatus(id: string, weight: number, isVariable = false) {
try { try {
return this.statuses.get(generateFontKey({ id, weight, isVariable })); 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 { pin(id: string, weight: number, isVariable = false): void {
this.#eviction.pin(generateFontKey({ id, weight, isVariable })); 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 { unpin(id: string, weight: number, isVariable = false): void {
this.#eviction.unpin(generateFontKey({ id, weight, isVariable })); 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> { async ready(): Promise<void> {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
return; return;
@@ -336,7 +352,9 @@ export class AppliedFontsManager {
} catch { /* document unloaded */ } } 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() { destroy() {
// Abort all in-flight network requests // Abort all in-flight network requests
this.#abortController.abort(); 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(); export const appliedFontsManager = new AppliedFontsManager();

View File

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

View File

@@ -3,9 +3,13 @@ import { FontFetchError } from '../../errors';
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>; type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
interface FontBufferCacheOptions { 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; fetcher?: Fetcher;
/** Cache API cache name. Defaults to `'font-cache-v1'`. */ /**
* Cache API cache name. Defaults to `'font-cache-v1'`.
*/
cacheName?: string; cacheName?: string;
} }
@@ -85,12 +89,16 @@ export class FontBufferCache {
return buffer; 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 { evict(url: string): void {
this.#buffersByUrl.delete(url); this.#buffersByUrl.delete(url);
} }
/** Clears all in-memory cached buffers. */ /**
* Clears all in-memory cached buffers.
*/
clear(): void { clear(): void {
this.#buffersByUrl.clear(); this.#buffersByUrl.clear();
} }

View File

@@ -1,5 +1,7 @@
interface FontEvictionPolicyOptions { interface FontEvictionPolicyOptions {
/** TTL in milliseconds. Defaults to 5 minutes. */ /**
* TTL in milliseconds. Defaults to 5 minutes.
*/
ttl?: number; ttl?: number;
} }
@@ -28,12 +30,16 @@ export class FontEvictionPolicy {
this.#usageTracker.set(key, now); 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 { pin(key: string): void {
this.#pinnedFonts.add(key); 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 { unpin(key: string): void {
this.#pinnedFonts.delete(key); this.#pinnedFonts.delete(key);
} }
@@ -57,18 +63,24 @@ export class FontEvictionPolicy {
return now - lastUsed >= this.#TTL; return now - lastUsed >= this.#TTL;
} }
/** Returns an iterator over all tracked font keys. */ /**
* Returns an iterator over all tracked font keys.
*/
keys(): IterableIterator<string> { keys(): IterableIterator<string> {
return this.#usageTracker.keys(); 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 { remove(key: string): void {
this.#usageTracker.delete(key); this.#usageTracker.delete(key);
this.#pinnedFonts.delete(key); this.#pinnedFonts.delete(key);
} }
/** Clears all usage timestamps and pinned keys. */ /**
* Clears all usage timestamps and pinned keys.
*/
clear(): void { clear(): void {
this.#usageTracker.clear(); this.#usageTracker.clear();
this.#pinnedFonts.clear(); this.#pinnedFonts.clear();

View File

@@ -34,22 +34,30 @@ export class FontLoadQueue {
return entries; 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 { has(key: string): boolean {
return this.#queue.has(key); 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 { incrementRetry(key: string): void {
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1); 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 { isMaxRetriesReached(key: string): boolean {
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES; 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 { clear(): void {
this.#queue.clear(); this.#queue.clear();
this.#retryCounts.clear(); this.#retryCounts.clear();

View File

@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */ /**
* @vitest-environment jsdom
*/
import { FontParseError } from '../../errors'; import { FontParseError } from '../../errors';
import { loadFont } from './loadFont'; 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(); vi.resetAllMocks();
}); });
// -----------------------------------------------------------------------
describe('construction', () => { describe('construction', () => {
it('stores initial params', () => { it('stores initial params', () => {
const store = makeStore({ limit: 20 }); const store = makeStore({ limit: 20 });
@@ -90,7 +89,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('state after fetch', () => { describe('state after fetch', () => {
it('exposes loaded fonts', async () => { it('exposes loaded fonts', async () => {
const store = await fetchedStore({}, generateMockFonts(7)); const store = await fetchedStore({}, generateMockFonts(7));
@@ -129,7 +127,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('error states', () => { describe('error states', () => {
it('isError is false before any fetch', () => { it('isError is false before any fetch', () => {
const store = makeStore(); const store = makeStore();
@@ -178,7 +175,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('font accumulation', () => { describe('font accumulation', () => {
it('replaces fonts when refetching the first page', async () => { it('replaces fonts when refetching the first page', async () => {
const store = makeStore(); const store = makeStore();
@@ -212,7 +208,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('pagination state', () => { describe('pagination state', () => {
it('returns zero-value defaults before any fetch', () => { it('returns zero-value defaults before any fetch', () => {
const store = makeStore(); const store = makeStore();
@@ -248,7 +243,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('setParams', () => { describe('setParams', () => {
it('merges updates into existing params', () => { it('merges updates into existing params', () => {
const store = makeStore({ limit: 10 }); const store = makeStore({ limit: 10 });
@@ -266,7 +260,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('filter change resets', () => { describe('filter change resets', () => {
it('clears accumulated fonts when a filter changes', async () => { it('clears accumulated fonts when a filter changes', async () => {
const store = await fetchedStore({}, generateMockFonts(5)); const store = await fetchedStore({}, generateMockFonts(5));
@@ -302,7 +295,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('staleTime in buildOptions', () => { describe('staleTime in buildOptions', () => {
it('is 5 minutes with no active filters', () => { it('is 5 minutes with no active filters', () => {
const store = makeStore(); const store = makeStore();
@@ -331,7 +323,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('buildQueryKey', () => { describe('buildQueryKey', () => {
it('omits empty-string params', () => { it('omits empty-string params', () => {
const store = makeStore(); const store = makeStore();
@@ -366,7 +357,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('destroy', () => { describe('destroy', () => {
it('does not throw', () => { it('does not throw', () => {
const store = makeStore(); const store = makeStore();
@@ -380,7 +370,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('refetch', () => { describe('refetch', () => {
it('triggers a fetch', async () => { it('triggers a fetch', async () => {
const store = makeStore(); const store = makeStore();
@@ -400,7 +389,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('nextPage', () => { describe('nextPage', () => {
let store: FontStore; let store: FontStore;
@@ -437,7 +425,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('prevPage and goToPage', () => { describe('prevPage and goToPage', () => {
it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => { it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => {
const store = await fetchedStore({}, generateMockFonts(5)); const store = await fetchedStore({}, generateMockFonts(5));
@@ -454,7 +441,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('prefetch', () => { describe('prefetch', () => {
it('triggers a fetch for the provided params', async () => { it('triggers a fetch for the provided params', async () => {
const store = makeStore(); const store = makeStore();
@@ -465,7 +451,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('getCachedData / setQueryData', () => { describe('getCachedData / setQueryData', () => {
it('getCachedData returns undefined before any fetch', () => { it('getCachedData returns undefined before any fetch', () => {
queryClient.clear(); queryClient.clear();
@@ -497,7 +482,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('invalidate', () => { describe('invalidate', () => {
it('calls invalidateQueries', async () => { it('calls invalidateQueries', async () => {
const store = await fetchedStore(); const store = await fetchedStore();
@@ -508,7 +492,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('setLimit', () => { describe('setLimit', () => {
it('updates the limit param', () => { it('updates the limit param', () => {
const store = makeStore({ limit: 10 }); const store = makeStore({ limit: 10 });
@@ -518,7 +501,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('filter shortcut methods', () => { describe('filter shortcut methods', () => {
let store: FontStore; let store: FontStore;
@@ -561,7 +543,6 @@ describe('FontStore', () => {
}); });
}); });
// -----------------------------------------------------------------------
describe('category getters', () => { describe('category getters', () => {
it('each getter returns only fonts of that category', async () => { it('each getter returns only fonts of that category', async () => {
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total 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 }; 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 FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>; type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
@@ -44,34 +46,53 @@ export class FontStore {
}); });
} }
// -- Public state -- /**
* Current filter and limit configuration
*/
get params(): FontStoreParams { get params(): FontStoreParams {
return this.#params; return this.#params;
} }
/**
* Flattened list of all fonts loaded across all pages (reactive)
*/
get fonts(): UnifiedFont[] { get fonts(): UnifiedFont[] {
return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? []; return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
} }
/**
* True if the first page is currently being fetched
*/
get isLoading(): boolean { get isLoading(): boolean {
return this.#result.isLoading; return this.#result.isLoading;
} }
/**
* True if any background fetch is in progress (initial or pagination)
*/
get isFetching(): boolean { get isFetching(): boolean {
return this.#result.isFetching; return this.#result.isFetching;
} }
/**
* True if the last fetch attempt resulted in an error
*/
get isError(): boolean { get isError(): boolean {
return this.#result.isError; return this.#result.isError;
} }
/**
* Last caught error from the query observer
*/
get error(): Error | null { get error(): Error | null {
return this.#result.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 * True if no fonts were found for the current filter criteria
// transition: fonts clear synchronously → isFetching becomes true → isEmpty stays false. */
get isEmpty(): boolean { get isEmpty(): boolean {
return !this.isLoading && !this.isFetching && this.fonts.length === 0; return !this.isLoading && !this.isFetching && this.fonts.length === 0;
} }
/**
* Pagination metadata derived from the last loaded page
*/
get pagination() { get pagination() {
const pages = this.#result.data?.pages; const pages = this.#result.data?.pages;
const last = pages?.at(-1); const last = pages?.at(-1);
@@ -95,45 +116,65 @@ export class FontStore {
}; };
} }
// -- Lifecycle -- /**
* Cleans up subscriptions and destroys the observer
*/
destroy() { destroy() {
this.#unsubscribe(); this.#unsubscribe();
this.#observer.destroy(); this.#observer.destroy();
} }
// -- Param management -- /**
* Merge new parameters into existing state and trigger a refetch
*/
setParams(updates: Partial<FontStoreParams>) { setParams(updates: Partial<FontStoreParams>) {
this.#params = { ...this.#params, ...updates }; this.#params = { ...this.#params, ...updates };
this.#observer.setOptions(this.buildOptions()); this.#observer.setOptions(this.buildOptions());
} }
/**
* Forcefully invalidate and refetch the current query from the network
*/
invalidate() { invalidate() {
this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) }); this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) });
} }
// -- Async operations -- /**
* Manually trigger a query refetch
*/
async refetch() { async refetch() {
await this.#observer.refetch(); await this.#observer.refetch();
} }
/**
* Prime the cache with data for a specific parameter set
*/
async prefetch(params: FontStoreParams) { async prefetch(params: FontStoreParams) {
await this.#qc.prefetchInfiniteQuery(this.buildOptions(params)); await this.#qc.prefetchInfiniteQuery(this.buildOptions(params));
} }
/**
* Abort any active network requests for this store
*/
cancel() { cancel() {
this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) }); this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) });
} }
/**
* Retrieve current font list from cache without triggering a fetch
*/
getCachedData(): UnifiedFont[] | undefined { getCachedData(): UnifiedFont[] | undefined {
const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>( const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
this.buildQueryKey(this.#params), this.buildQueryKey(this.#params),
); );
if (!data) return undefined; if (!data) {
return undefined;
}
return data.pages.flatMap(p => p.fonts); return data.pages.flatMap(p => p.fonts);
} }
/**
* Manually update the cached font data (useful for optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) { setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
const key = this.buildQueryKey(this.#params); const key = this.buildQueryKey(this.#params);
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>( this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
@@ -164,56 +205,90 @@ export class FontStore {
); );
} }
// -- Filter shortcuts -- /**
* Shortcut to update provider filters
*/
setProviders(v: ProxyFontsParams['providers']) { setProviders(v: ProxyFontsParams['providers']) {
this.setParams({ providers: v }); this.setParams({ providers: v });
} }
/**
* Shortcut to update category filters
*/
setCategories(v: ProxyFontsParams['categories']) { setCategories(v: ProxyFontsParams['categories']) {
this.setParams({ categories: v }); this.setParams({ categories: v });
} }
/**
* Shortcut to update subset filters
*/
setSubsets(v: ProxyFontsParams['subsets']) { setSubsets(v: ProxyFontsParams['subsets']) {
this.setParams({ subsets: v }); this.setParams({ subsets: v });
} }
/**
* Shortcut to update search query
*/
setSearch(v: string) { setSearch(v: string) {
this.setParams({ q: v || undefined }); this.setParams({ q: v || undefined });
} }
/**
* Shortcut to update sort order
*/
setSort(v: ProxyFontsParams['sort']) { setSort(v: ProxyFontsParams['sort']) {
this.setParams({ sort: v }); this.setParams({ sort: v });
} }
// -- Pagination navigation -- /**
* Fetch the next page of results if available
*/
async nextPage(): Promise<void> { async nextPage(): Promise<void> {
await this.#observer.fetchNextPage(); 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) { setLimit(limit: number) {
this.setParams({ limit }); this.setParams({ limit });
} }
// -- Category views -- /**
* Derived list of sans-serif fonts in the current set
*/
get sansSerifFonts() { get sansSerifFonts() {
return this.fonts.filter(f => f.category === 'sans-serif'); return this.fonts.filter(f => f.category === 'sans-serif');
} }
/**
* Derived list of serif fonts in the current set
*/
get serifFonts() { get serifFonts() {
return this.fonts.filter(f => f.category === 'serif'); return this.fonts.filter(f => f.category === 'serif');
} }
/**
* Derived list of display fonts in the current set
*/
get displayFonts() { get displayFonts() {
return this.fonts.filter(f => f.category === 'display'); return this.fonts.filter(f => f.category === 'display');
} }
/**
* Derived list of handwriting fonts in the current set
*/
get handwritingFonts() { get handwritingFonts() {
return this.fonts.filter(f => f.category === 'handwriting'); return this.fonts.filter(f => f.category === 'handwriting');
} }
/**
* Derived list of monospace fonts in the current set
*/
get monospaceFonts() { get monospaceFonts() {
return this.fonts.filter(f => f.category === 'monospace'); 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[] { private buildQueryKey(params: FontStoreParams): readonly unknown[] {
const filtered: Record<string, any> = {}; const filtered: Record<string, any> = {};
@@ -263,9 +338,15 @@ export class FontStore {
throw new FontNetworkError(cause); throw new FontNetworkError(cause);
} }
if (!response) throw new FontResponseError('response', response); if (!response) {
if (!response.fonts) throw new FontResponseError('response.fonts', response.fonts); throw new FontResponseError('response', response);
if (!Array.isArray(response.fonts)) throw new FontResponseError('response.fonts', response.fonts); }
if (!response.fonts) {
throw new FontResponseError('response.fonts', response.fonts);
}
if (!Array.isArray(response.fonts)) {
throw new FontResponseError('response.fonts', response.fonts);
}
return { return {
fonts: response.fonts, fonts: response.fonts,

View File

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

View File

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

View File

@@ -1,9 +1,3 @@
/**
* ============================================================================
* STORE TYPES
* ============================================================================
*/
import type { import type {
FontCategory, FontCategory,
FontProvider, FontProvider,
@@ -12,37 +6,55 @@ import type {
} from './font'; } from './font';
/** /**
* Font collection state * Global state for the local font collection
*/ */
export interface FontCollectionState { export interface FontCollectionState {
/** All cached fonts */ /**
* Map of cached fonts indexed by their unique family ID
*/
fonts: Record<string, UnifiedFont>; fonts: Record<string, UnifiedFont>;
/** Active filters */ /**
* Set of active user-defined filters
*/
filters: FontCollectionFilters; filters: FontCollectionFilters;
/** Sort configuration */ /**
* Current sorting parameters for the display list
*/
sort: FontCollectionSort; sort: FontCollectionSort;
} }
/** /**
* Font collection filters * Filter configuration for narrow collections
*/ */
export interface FontCollectionFilters { export interface FontCollectionFilters {
/** Search query */ /**
* Partial family name to match against
*/
searchQuery: string; searchQuery: string;
/** Filter by providers */ /**
* Data sources (Google, Fontshare) to include
*/
providers?: FontProvider[]; providers?: FontProvider[];
/** Filter by categories */ /**
* Typographic categories (Serif, Sans, etc.) to include
*/
categories?: FontCategory[]; categories?: FontCategory[];
/** Filter by subsets */ /**
* Character sets (Latin, Cyrillic, etc.) to include
*/
subsets?: FontSubset[]; subsets?: FontSubset[];
} }
/** /**
* Font collection sort configuration * Ordering configuration for the font list
*/ */
export interface FontCollectionSort { export interface FontCollectionSort {
/** Sort field */ /**
* The font property to order by
*/
field: 'name' | 'popularity' | 'category'; field: 'name' | 'popularity' | 'category';
/** Sort direction */ /**
* The sort order (Ascending or Descending)
*/
direction: 'asc' | 'desc'; 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 - Adds smooth transition when font appears
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import clsx from 'clsx';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion'; import { prefersReducedMotion } from 'svelte/motion';
import { import {
DEFAULT_FONT_WEIGHT,
type UnifiedFont, type UnifiedFont,
appliedFontsManager, appliedFontsManager,
} from '../../model'; } from '../../model';
@@ -36,7 +37,7 @@ interface Props {
let { let {
font, font,
weight = 400, weight = DEFAULT_FONT_WEIGHT,
className, className,
children, children,
}: Props = $props(); }: Props = $props();
@@ -63,7 +64,7 @@ const transitionClasses = $derived(
style:font-family={shouldReveal style:font-family={shouldReveal
? `'${font.name}'` ? `'${font.name}'`
: 'system-ui, -apple-system, sans-serif'} : 'system-ui, -apple-system, sans-serif'}
class={cn( class={clsx(
transitionClasses, transitionClasses,
// If reduced motion is on, we skip the transform/blur entirely // If reduced motion is on, we skip the transform/blur entirely
!shouldReveal !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 FontLoadRequestConfig,
type UnifiedFont, type UnifiedFont,
appliedFontsManager, appliedFontsManager,
fontStore,
} from '../../model'; } from '../../model';
import { fontStore } from '../../model/store';
interface Props extends interface Props extends
Omit< Omit<
@@ -53,30 +53,44 @@ const isLoading = $derived(
fontStore.isFetching || fontStore.isLoading, fontStore.isFetching || fontStore.isLoading,
); );
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) { let visibleFonts = $state<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);
function handleInternalVisibleChange(items: UnifiedFont[]) {
visibleFonts = items;
// Forward the call to any external listener // 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 * Load more fonts by moving to the next page
*/ */

View File

@@ -41,15 +41,25 @@ type ThemeSource = 'system' | 'user';
*/ */
class ThemeManager { class ThemeManager {
// Private reactive state // Private reactive state
/** Current theme value ('light' or 'dark') */ /**
* Current theme value ('light' or 'dark')
*/
#theme = $state<Theme>('light'); #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'); #source = $state<ThemeSource>('system');
/** MediaQueryList for detecting system theme changes */ /**
* MediaQueryList for detecting system theme changes
*/
#mediaQuery: MediaQueryList | null = null; #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); #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); #systemChangeHandler = this.#onSystemChange.bind(this);
constructor() { constructor() {
@@ -64,22 +74,30 @@ class ThemeManager {
} }
} }
/** Current theme value */ /**
* Current theme value
*/
get value(): Theme { get value(): Theme {
return this.#theme; return this.#theme;
} }
/** Source of current theme ('system' or 'user') */ /**
* Source of current theme ('system' or 'user')
*/
get source(): ThemeSource { get source(): ThemeSource {
return this.#source; return this.#source;
} }
/** Whether dark theme is active */ /**
* Whether dark theme is active
*/
get isDark(): boolean { get isDark(): boolean {
return this.#theme === 'dark'; 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 { get isUserControlled(): boolean {
return this.#source === 'user'; return this.#source === 'user';
} }

View File

@@ -1,9 +1,9 @@
/** @vitest-environment jsdom */ /**
* @vitest-environment jsdom
*/
// ============================================================
// Mock MediaQueryListEvent for system theme change simulations // Mock MediaQueryListEvent for system theme change simulations
// Note: Other mocks (ResizeObserver, localStorage, matchMedia) are set up in vitest.setup.unit.ts // Note: Other mocks (ResizeObserver, localStorage, matchMedia) are set up in vitest.setup.unit.ts
// ============================================================
class MockMediaQueryListEvent extends Event { class MockMediaQueryListEvent extends Event {
matches: boolean; matches: boolean;
@@ -16,9 +16,7 @@ class MockMediaQueryListEvent extends Event {
} }
} }
// ============================================================
// NOW IT'S SAFE TO IMPORT // NOW IT'S SAFE TO IMPORT
// ============================================================
import { import {
afterEach, 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"> <script lang="ts">
import type { UnifiedFont } from '$entities/Font'; import type { UnifiedFont } from '$entities/Font';
import { controlManager } from '$features/SetupFont'; import type { ComponentProps } from 'svelte';
// Mock fonts for testing // Mock fonts for testing
const mockArial: UnifiedFont = { const mockArial: UnifiedFont = {
@@ -89,7 +89,7 @@ const mockGeorgia: UnifiedFont = {
index: 0, index: 0,
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof FontSampler>)}
<Providers> <Providers>
<div class="max-w-2xl mx-auto"> <div class="max-w-2xl mx-auto">
<FontSampler {...args} /> <FontSampler {...args} />
@@ -106,7 +106,7 @@ const mockGeorgia: UnifiedFont = {
index: 1, index: 1,
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof FontSampler>)}
<Providers> <Providers>
<div class="max-w-2xl mx-auto"> <div class="max-w-2xl mx-auto">
<FontSampler {...args} /> <FontSampler {...args} />

View File

@@ -8,14 +8,13 @@ import {
FontApplicator, FontApplicator,
type UnifiedFont, type UnifiedFont,
} from '$entities/Font'; } from '$entities/Font';
import { controlManager } from '$features/SetupFont'; import { typographySettingsStore } from '$features/SetupFont/model';
import { import {
Badge, Badge,
ContentEditable, ContentEditable,
Divider, Divider,
Footnote, Footnote,
Stat, Stat,
StatGroup,
} from '$shared/ui'; } from '$shared/ui';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
@@ -37,11 +36,6 @@ interface Props {
let { font, text = $bindable(), index = 0 }: Props = $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 // Adjust the property name to match your UnifiedFont type
const fontType = $derived((font as any).type ?? (font as any).category ?? ''); const fontType = $derived((font as any).type ?? (font as any).category ?? '');
@@ -52,10 +46,10 @@ const providerBadge = $derived(
); );
const stats = $derived([ const stats = $derived([
{ label: 'SZ', value: `${fontSize}PX` }, { label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
{ label: 'WGT', value: `${fontWeight}` }, { label: 'WGT', value: `${typographySettingsStore.weight}` },
{ label: 'LH', value: lineHeight?.toFixed(2) }, { label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
{ label: 'LTR', value: `${letterSpacing}` }, { label: 'LTR', value: `${typographySettingsStore.spacing}` },
]); ]);
</script> </script>
@@ -65,7 +59,7 @@ const stats = $derived([
group relative group relative
w-full h-full w-full h-full
bg-paper dark:bg-dark-card 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:border-brand dark:hover:border-brand
hover:shadow-brand/10 hover:shadow-brand/10
hover:shadow-[5px_5px_0px_0px] hover:shadow-[5px_5px_0px_0px]
@@ -75,20 +69,20 @@ const stats = $derived([
min-h-60 min-h-60
rounded-none rounded-none
" "
style:font-weight={fontWeight} style:font-weight={typographySettingsStore.weight}
> >
<!-- ── Header bar ─────────────────────────────────────────────────── --> <!-- ── Header bar ─────────────────────────────────────────────────── -->
<div <div
class=" class="
flex items-center justify-between flex items-center justify-between
px-4 sm:px-5 md:px-6 py-3 sm:py-4 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 bg-paper dark:bg-dark-card
" "
> >
<!-- Left: index · name · type badge · provider badge --> <!-- Left: index · name · type badge · provider badge -->
<div class="flex items-center gap-2 sm:gap-4 min-w-0 shrink-0"> <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')} {String(index + 1).padStart(2, '0')}
</span> </span>
<Divider orientation="vertical" class="h-3 shrink-0" /> <Divider orientation="vertical" class="h-3 shrink-0" />
@@ -100,14 +94,14 @@ const stats = $derived([
</span> </span>
{#if fontType} {#if fontType}
<Badge size="xs" variant="default" class="text-nowrap font-mono"> <Badge size="xs" variant="default" nowrap>
{fontType} {fontType}
</Badge> </Badge>
{/if} {/if}
<!-- Provider badge --> <!-- Provider badge -->
{#if providerBadge} {#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} {providerBadge}
</Badge> </Badge>
{/if} {/if}
@@ -140,20 +134,20 @@ const stats = $derived([
<!-- ── Main content area ──────────────────────────────────────────── --> <!-- ── 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"> <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 <ContentEditable
bind:text bind:text
{fontSize} fontSize={typographySettingsStore.renderedSize}
{lineHeight} lineHeight={typographySettingsStore.height}
{letterSpacing} letterSpacing={typographySettingsStore.spacing}
/> />
</FontApplicator> </FontApplicator>
</div> </div>
<!-- ── Mobile stats footer (md:hidden — header stats take over above) --> <!-- ── 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} {#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} {stat.label}:{stat.value}
</Footnote> </Footnote>
{#if i < stats.length - 1} {#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 * Filter metadata type from backend
*/ */
export interface FilterMetadata { export interface FilterMetadata {
/** Filter ID (e.g., "providers", "categories", "subsets") */ /**
* Filter ID (e.g., "providers", "categories", "subsets")
*/
id: string; id: string;
/** Display name (e.g., "Font Providers", "Categories", "Character Subsets") */ /**
* Display name (e.g., "Font Providers", "Categories", "Character Subsets")
*/
name: string; name: string;
/** Filter description */ /**
* Filter description
*/
description: string; description: string;
/** Filter type */ /**
* Filter type
*/
type: 'enum' | 'string' | 'array'; type: 'enum' | 'string' | 'array';
/** Available filter options */ /**
* Available filter options
*/
options: FilterOption[]; options: FilterOption[];
} }
@@ -35,16 +45,24 @@ export interface FilterMetadata {
* Filter option type * Filter option type
*/ */
export interface FilterOption { export interface FilterOption {
/** Option ID (e.g., "google", "serif", "latin") */ /**
* Option ID (e.g., "google", "serif", "latin")
*/
id: string; id: string;
/** Display name (e.g., "Google Fonts", "Serif", "Latin") */ /**
* Display name (e.g., "Google Fonts", "Serif", "Latin")
*/
name: string; name: string;
/** Option value (e.g., "google", "serif", "latin") */ /**
* Option value (e.g., "google", "serif", "latin")
*/
value: string; value: string;
/** Number of fonts with this value */ /**
* Number of fonts with this value
*/
count: number; count: number;
} }
@@ -52,7 +70,9 @@ export interface FilterOption {
* Proxy filters API response * Proxy filters API response
*/ */
export interface ProxyFiltersResponse { export interface ProxyFiltersResponse {
/** Array of filter metadata */ /**
* Array of filter metadata
*/
filters: FilterMetadata[]; filters: FilterMetadata[];
} }

View File

@@ -1,15 +1,56 @@
export type { export type {
/**
* Top-level configuration for all filters
*/
FilterConfig, FilterConfig,
/**
* Configuration for a single grouping of filter properties
*/
FilterGroupConfig, FilterGroupConfig,
} from './types/filter'; } from './types/filter';
export { filtersStore } from './state/filters.svelte'; /**
export { filterManager } from './state/manager.svelte'; * Global reactive filter state
*/
export { 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, SORT_MAP,
/**
* List of all available sort options for the UI
*/
SORT_OPTIONS, SORT_OPTIONS,
/**
* Valid sort key values
*/
type SortApiValue, type SortApiValue,
/**
* UI model for a single sort option
*/
type SortOption, type SortOption,
/**
* Reactive store for the current sort selection
*/
sortStore, sortStore,
} from './store/sortStore.svelte'; } from './store/sortStore.svelte';

View File

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

View File

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

View File

@@ -1,12 +1,27 @@
import type { Property } from '$shared/lib'; import type { Property } from '$shared/lib';
export interface FilterGroupConfig<TValue extends string> { export interface FilterGroupConfig<TValue extends string> {
/**
* Unique identifier for the filter group (e.g. 'categories')
*/
id: string; id: string;
/**
* Human-readable label displayed in the UI header
*/
label: string; label: string;
/**
* List of toggleable properties within this group
*/
properties: Property<TValue>[]; properties: Property<TValue>[];
} }
export interface FilterConfig<TValue extends string> { export interface FilterConfig<TValue extends string> {
/**
* Optional string to filter results by name
*/
queryValue?: string; queryValue?: string;
/**
* Collection of filter groups to display
*/
groups: FilterGroupConfig<TValue>[]; 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"> <script lang="ts">
import { fontStore } from '$entities/Font'; import { fontStore } from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Button } from '$shared/ui'; import { Button } from '$shared/ui';
import { Label } from '$shared/ui'; import { Label } from '$shared/ui';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import clsx from 'clsx';
import { import {
getContext, getContext,
untrack, untrack,
@@ -45,7 +45,7 @@ function handleReset() {
</script> </script>
<div <div
class={cn( class={clsx(
'flex flex-col md:flex-row justify-between items-start md:items-center', 'flex flex-col md:flex-row justify-between items-start md:items-center',
'gap-1 md:gap-6', 'gap-1 md:gap-6',
'pt-6 mt-6 md:pt-8 md:mt-8', 'pt-6 mt-6 md:pt-8 md:mt-8',
@@ -61,13 +61,10 @@ function handleReset() {
{#each SORT_OPTIONS as option} {#each SORT_OPTIONS as option}
<Button <Button
variant="ghost" variant="ghost"
size={isMobileOrTabletPortrait ? 'sm' : 'md'} size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
active={sortStore.value === option} active={sortStore.value === option}
onclick={() => sortStore.set(option)} onclick={() => sortStore.set(option)}
class={cn( class="tracking-wide px-0"
'font-bold uppercase tracking-wide font-primary, px-0',
isMobileOrTabletPortrait ? 'text-[0.5625rem]' : 'text-[0.625rem]',
)}
> >
{option} {option}
</Button> </Button>
@@ -78,12 +75,9 @@ function handleReset() {
<!-- Reset_Filters --> <!-- Reset_Filters -->
<Button <Button
variant="ghost" variant="ghost"
size={isMobileOrTabletPortrait ? 'sm' : 'md'} size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
onclick={handleReset} onclick={handleReset}
class={cn( class={clsx('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
'group text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest text-neutral-400',
isMobileOrTabletPortrait && 'px-0',
)}
iconPosition="left" iconPosition="left"
> >
{#snippet icon()} {#snippet icon()}

View File

@@ -1,28 +1,6 @@
export { TypographyMenu } from './ui';
export { export {
type ControlId, createTypographySettingsManager,
controlManager, type TypographySettingsManager,
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,
} from './lib'; } from './lib';
export { typographySettingsStore } from './model';
export { TypographyMenu } from './ui';

View File

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

View File

@@ -10,6 +10,13 @@
* when displaying/editing, but the base size is what's stored. * 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 { import {
type ControlDataModel, type ControlDataModel,
type ControlModel, type ControlModel,
@@ -19,20 +26,16 @@ import {
createTypographyControl, createTypographyControl,
} from '$shared/lib'; } from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity'; 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>; 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> { export interface Control extends ControlOnlyFields<ControlId> {
/**
* The reactive typography control instance
*/
instance: TypographyControl; instance: TypographyControl;
} }
@@ -40,9 +43,21 @@ export interface Control extends ControlOnlyFields<ControlId> {
* Storage schema for typography settings * Storage schema for typography settings
*/ */
export interface TypographySettings { export interface TypographySettings {
/**
* Base font size (User preference, unscaled)
*/
fontSize: number; fontSize: number;
/**
* Numeric font weight (100-900)
*/
fontWeight: number; fontWeight: number;
/**
* Line height multiplier (e.g. 1.5)
*/
lineHeight: number; lineHeight: number;
/**
* Letter spacing in em/px
*/
letterSpacing: number; letterSpacing: number;
} }
@@ -52,14 +67,22 @@ export interface TypographySettings {
* Manages multiple typography controls with persistent storage and * Manages multiple typography controls with persistent storage and
* responsive scaling support for font size. * responsive scaling support for font size.
*/ */
export class TypographyControlManager { export class TypographySettingsManager {
/** Map of controls keyed by ID */ /**
* Internal map of reactive controls keyed by their identifier
*/
#controls = new SvelteMap<string, Control>(); #controls = new SvelteMap<string, Control>();
/** Responsive multiplier for font size display */ /**
* Global multiplier for responsive font size scaling
*/
#multiplier = $state(1); #multiplier = $state(1);
/** Persistent storage for settings */ /**
* LocalStorage-backed storage for persistence
*/
#storage: PersistentStore<TypographySettings>; #storage: PersistentStore<TypographySettings>;
/** Base font size (user preference, unscaled) */ /**
* The underlying font size before responsive scaling is applied
*/
#baseSize = $state(DEFAULT_FONT_SIZE); #baseSize = $state(DEFAULT_FONT_SIZE);
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) { 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 // This handles the "Multiplier" logic specifically for the Font Size Control
$effect(() => { $effect(() => {
const ctrl = this.#controls.get('font_size')?.instance; 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: // If the user moves the slider/clicks buttons in the UI:
// We update the baseSize (User Intent) // We update the baseSize (User Intent)
@@ -124,26 +149,35 @@ export class TypographyControlManager {
* Gets initial value for a control from storage or defaults * Gets initial value for a control from storage or defaults
*/ */
#getInitialValue(id: string, saved: TypographySettings): number { #getInitialValue(id: string, saved: TypographySettings): number {
if (id === 'font_size') return saved.fontSize * this.#multiplier; if (id === 'font_size') {
if (id === 'font_weight') return saved.fontWeight; return saved.fontSize * this.#multiplier;
if (id === 'line_height') return saved.lineHeight; }
if (id === 'letter_spacing') return saved.letterSpacing; if (id === 'font_weight') {
return saved.fontWeight;
}
if (id === 'line_height') {
return saved.lineHeight;
}
if (id === 'letter_spacing') {
return saved.letterSpacing;
}
return 0; return 0;
} }
/** Current multiplier for responsive scaling */ /**
* Active scaling factor for the rendered font size
*/
get multiplier() { get multiplier() {
return this.#multiplier; return this.#multiplier;
} }
/** /**
* Set the multiplier and update font size display * Updates the multiplier and recalculates dependent control values
*
* When multiplier changes, the font size control's display value
* is updated to reflect the new scale while preserving base size.
*/ */
set multiplier(value: number) { set multiplier(value: number) {
if (this.#multiplier === value) return; if (this.#multiplier === value) {
return;
}
this.#multiplier = value; this.#multiplier = value;
// When multiplier changes, we must update the Font Size Control's display 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 * The actual pixel value for CSS font-size (baseSize * multiplier)
* Returns baseSize * multiplier for actual rendering
*/ */
get renderedSize() { get renderedSize() {
return this.#baseSize * this.#multiplier; return this.#baseSize * this.#multiplier;
} }
/** The base size (User Preference) */ /**
* The raw font size preference before scaling
*/
get baseSize() { get baseSize() {
return this.#baseSize; return this.#baseSize;
} }
@@ -169,49 +204,69 @@ export class TypographyControlManager {
set baseSize(val: number) { set baseSize(val: number) {
this.#baseSize = val; this.#baseSize = val;
const ctrl = this.#controls.get('font_size')?.instance; 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() { get controls() {
return Array.from(this.#controls.values()); return Array.from(this.#controls.values());
} }
/**
* Reactive instance for weight manipulation
*/
get weightControl() { get weightControl() {
return this.#controls.get('font_weight')?.instance; return this.#controls.get('font_weight')?.instance;
} }
/**
* Reactive instance for size manipulation
*/
get sizeControl() { get sizeControl() {
return this.#controls.get('font_size')?.instance; return this.#controls.get('font_size')?.instance;
} }
/**
* Reactive instance for line-height manipulation
*/
get heightControl() { get heightControl() {
return this.#controls.get('line_height')?.instance; return this.#controls.get('line_height')?.instance;
} }
/**
* Reactive instance for letter-spacing manipulation
*/
get spacingControl() { get spacingControl() {
return this.#controls.get('letter_spacing')?.instance; return this.#controls.get('letter_spacing')?.instance;
} }
/** /**
* Getters for values (besides font-size) * Current numeric font weight (reactive)
*/ */
get weight() { get weight() {
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT; return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
} }
/**
* Current numeric line height (reactive)
*/
get height() { get height() {
return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT; return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
} }
/**
* Current numeric letter spacing (reactive)
*/
get spacing() { get spacing() {
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_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() { reset() {
this.#storage.clear(); this.#storage.clear();
@@ -227,9 +282,15 @@ export class TypographyControlManager {
// Map storage key to control id // Map storage key to control id
const key = c.id.replace('_', '') as keyof TypographySettings; const key = c.id.replace('_', '') as keyof TypographySettings;
// Simplified for brevity, you'd map these properly: // Simplified for brevity, you'd map these properly:
if (c.id === 'font_weight') c.instance.value = defaults.fontWeight; if (c.id === 'font_weight') {
if (c.id === 'line_height') c.instance.value = defaults.lineHeight; c.instance.value = defaults.fontWeight;
if (c.id === 'letter_spacing') c.instance.value = defaults.letterSpacing; }
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 * @param storageId - Persistent storage identifier
* @returns Typography control manager instance * @returns Typography control manager instance
*/ */
export function createTypographyControlManager( export function createTypographySettingsManager(
configs: ControlModel<ControlId>[], configs: ControlModel<ControlId>[],
storageId: string = 'glyphdiff:typography', storageId: string = 'glyphdiff:typography',
) { ) {
@@ -252,5 +313,5 @@ export function createTypographyControlManager(
lineHeight: DEFAULT_LINE_HEIGHT, lineHeight: DEFAULT_LINE_HEIGHT,
letterSpacing: DEFAULT_LETTER_SPACING, 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 { import {
afterEach,
beforeEach, beforeEach,
describe, describe,
expect, expect,
@@ -8,21 +16,14 @@ import {
vi, vi,
} from 'vitest'; } from 'vitest';
import { import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} from '../../model';
import {
TypographyControlManager,
type TypographySettings, 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. * 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 * NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
@@ -45,7 +46,7 @@ async function flushEffects() {
await Promise.resolve(); await Promise.resolve();
} }
describe('TypographyControlManager - Unit Tests', () => { describe('TypographySettingsManager - Unit Tests', () => {
let mockStorage: TypographySettings; let mockStorage: TypographySettings;
let mockPersistentStore: { let mockPersistentStore: {
value: TypographySettings; value: TypographySettings;
@@ -85,7 +86,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Initialization', () => { describe('Initialization', () => {
it('creates manager with default values from storage', () => { it('creates manager with default values from storage', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -105,7 +106,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}; };
mockPersistentStore = createMockPersistentStore(mockStorage); mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -117,7 +118,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('initializes font size control with base size multiplied by current multiplier (1)', () => { 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, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -126,7 +127,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('returns all controls via controls getter', () => { it('returns all controls via controls getter', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -142,7 +143,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('returns individual controls via specific getters', () => { it('returns individual controls via specific getters', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -160,7 +161,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('control instances have expected interface', () => { it('control instances have expected interface', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -179,7 +180,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Multiplier System', () => { describe('Multiplier System', () => {
it('has default multiplier of 1', () => { it('has default multiplier of 1', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -188,7 +189,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('updates multiplier when set', () => { it('updates multiplier when set', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -201,7 +202,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('does not update multiplier if set to same value', () => { it('does not update multiplier if set to same value', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -217,7 +218,7 @@ describe('TypographyControlManager - Unit Tests', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 }; mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage); mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -241,7 +242,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('updates font size control display value when multiplier increases', () => { it('updates font size control display value when multiplier increases', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -262,7 +263,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Base Size Setter', () => { describe('Base Size Setter', () => {
it('updates baseSize when set directly', () => { it('updates baseSize when set directly', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -273,7 +274,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('updates size control value when baseSize is set', () => { it('updates size control value when baseSize is set', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -284,7 +285,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('applies multiplier to size control when baseSize is set', () => { it('applies multiplier to size control when baseSize is set', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -298,7 +299,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Rendered Size Calculation', () => { describe('Rendered Size Calculation', () => {
it('calculates renderedSize as baseSize * multiplier', () => { it('calculates renderedSize as baseSize * multiplier', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -307,7 +308,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('updates renderedSize when multiplier changes', () => { it('updates renderedSize when multiplier changes', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -320,7 +321,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('updates renderedSize when baseSize changes', () => { it('updates renderedSize when baseSize changes', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -340,7 +341,7 @@ describe('TypographyControlManager - Unit Tests', () => {
// proxy effect behavior should be tested in E2E tests. // proxy effect behavior should be tested in E2E tests.
it('does NOT immediately update baseSize from control change (effect is async)', () => { it('does NOT immediately update baseSize from control change (effect is async)', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -355,7 +356,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('updates baseSize via direct setter (synchronous)', () => { it('updates baseSize via direct setter (synchronous)', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -380,7 +381,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}; };
mockPersistentStore = createMockPersistentStore(mockStorage); mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -393,7 +394,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('syncs to storage after effect flush (async)', async () => { it('syncs to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -409,7 +410,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('syncs control changes to storage after effect flush (async)', async () => { it('syncs control changes to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -422,7 +423,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('syncs height control changes to storage after effect flush (async)', async () => { it('syncs height control changes to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -434,7 +435,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('syncs spacing control changes to storage after effect flush (async)', async () => { it('syncs spacing control changes to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -448,7 +449,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Control Value Getters', () => { describe('Control Value Getters', () => {
it('returns current weight value', () => { it('returns current weight value', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -460,7 +461,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('returns current height value', () => { it('returns current height value', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -472,7 +473,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('returns current spacing value', () => { it('returns current spacing value', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -485,7 +486,7 @@ describe('TypographyControlManager - Unit Tests', () => {
it('returns default value when control is not found', () => { it('returns default value when control is not found', () => {
// Create a manager with empty configs (no controls) // 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.weight).toBe(DEFAULT_FONT_WEIGHT);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT); expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
@@ -503,7 +504,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}; };
mockPersistentStore = createMockPersistentStore(mockStorage); mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -536,7 +537,7 @@ describe('TypographyControlManager - Unit Tests', () => {
clear: clearSpy, clear: clearSpy,
}; };
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -547,7 +548,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('respects multiplier when resetting font size control', () => { it('respects multiplier when resetting font size control', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -565,7 +566,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Complex Scenarios', () => { describe('Complex Scenarios', () => {
it('handles changing multiplier then modifying baseSize', () => { it('handles changing multiplier then modifying baseSize', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -586,7 +587,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('maintains correct renderedSize throughout changes', () => { it('maintains correct renderedSize throughout changes', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -608,7 +609,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('handles multiple control changes in sequence', async () => { it('handles multiple control changes in sequence', async () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -633,7 +634,7 @@ describe('TypographyControlManager - Unit Tests', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 }; mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage); mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -645,7 +646,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('handles very small multiplier', () => { it('handles very small multiplier', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -658,7 +659,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('handles large base size with multiplier', () => { it('handles large base size with multiplier', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -671,7 +672,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('handles floating point precision in multiplier', () => { it('handles floating point precision in multiplier', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -690,7 +691,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('handles control methods (increase/decrease)', () => { it('handles control methods (increase/decrease)', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );
@@ -704,7 +705,7 @@ describe('TypographyControlManager - Unit Tests', () => {
}); });
it('handles control boundary conditions', () => { it('handles control boundary conditions', () => {
const manager = new TypographyControlManager( const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA, DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore, mockPersistentStore,
); );

View File

@@ -1,24 +1 @@
export { export { typographySettingsStore } from './state/typographySettingsStore';
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';

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 Component: TypographyMenu
Floating controls bar for typography settings. 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. Mobile: popover with slider controls anchored to settings button.
Desktop: inline bar with combo controls. Desktop: inline bar with combo controls.
--> -->
<script lang="ts"> <script lang="ts">
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { import {
Button, Button,
ComboControl, ComboControl,
@@ -17,15 +20,11 @@ import {
import Settings2Icon from '@lucide/svelte/icons/settings-2'; import Settings2Icon from '@lucide/svelte/icons/settings-2';
import XIcon from '@lucide/svelte/icons/x'; import XIcon from '@lucide/svelte/icons/x';
import { Popover } from 'bits-ui'; import { Popover } from 'bits-ui';
import clsx from 'clsx';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { import { typographySettingsStore } from '../../model';
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
controlManager,
} from '../../model';
interface Props { interface Props {
/** /**
@@ -37,67 +36,62 @@ interface Props {
* @default false * @default false
*/ */
hidden?: boolean; 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'); const responsive = getContext<ResponsiveManager>('responsive');
let isOpen = $state(false);
/** /**
* Sets the common font size multiplier based on the current responsive state. * Sets the common font size multiplier based on the current responsive state.
*/ */
$effect(() => { $effect(() => {
if (!responsive) return; if (!responsive) {
return;
}
switch (true) { switch (true) {
case responsive.isMobile: case responsive.isMobile:
controlManager.multiplier = MULTIPLIER_S; typographySettingsStore.multiplier = MULTIPLIER_S;
break; break;
case responsive.isTablet: case responsive.isTablet:
controlManager.multiplier = MULTIPLIER_M; typographySettingsStore.multiplier = MULTIPLIER_M;
break; break;
case responsive.isDesktop: case responsive.isDesktop:
controlManager.multiplier = MULTIPLIER_L; typographySettingsStore.multiplier = MULTIPLIER_L;
break; break;
default: default:
controlManager.multiplier = MULTIPLIER_L; typographySettingsStore.multiplier = MULTIPLIER_L;
} }
}); });
</script> </script>
{#if !hidden} {#if !hidden}
{#if responsive.isMobile} {#if responsive.isMobileOrTablet}
<Popover.Root bind:open={isOpen}> <Popover.Root bind:open>
<Popover.Trigger> <Popover.Trigger>
{#snippet child({ props })} {#snippet child({ props })}
<button <Button class={className} variant="primary" {...props}>
{...props} {#snippet icon()}
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" /> <Settings2Icon class="size-4" />
</button> {/snippet}
</Button>
{/snippet} {/snippet}
</Popover.Trigger> </Popover.Trigger>
<Popover.Portal> <Popover.Portal>
<Popover.Content <Popover.Content
side="top" side="top"
align="start" align="end"
sideOffset={8} sideOffset={8}
class={cn( class={clsx(
'z-50 w-72', 'z-50 w-72',
'bg-surface dark:bg-dark-card', '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)]', 'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
'rounded-none p-4', 'rounded-none p-4',
'data-[state=open]:animate-in data-[state=closed]:animate-out', 'data-[state=open]:animate-in data-[state=closed]:animate-out',
@@ -110,11 +104,11 @@ $effect(() => {
escapeKeydownBehavior="close" escapeKeydownBehavior="close"
> >
<!-- Header --> <!-- 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"> <div class="flex items-center gap-1.5">
<Settings2Icon size={12} class="text-swiss-red" /> <Settings2Icon size={12} class="text-swiss-red" />
<span <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 CONTROLS
</span> </span>
@@ -133,7 +127,7 @@ $effect(() => {
</div> </div>
<!-- Controls --> <!-- Controls -->
{#each controlManager.controls as control (control.id)} {#each typographySettingsStore.controls as control (control.id)}
<ControlGroup label={control.controlLabel ?? ''}> <ControlGroup label={control.controlLabel ?? ''}>
<Slider <Slider
bind:value={control.instance.value} bind:value={control.instance.value}
@@ -148,33 +142,33 @@ $effect(() => {
</Popover.Root> </Popover.Root>
{:else} {:else}
<div <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 }} transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
> >
<div <div
class={cn( class={clsx(
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2', 'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl', 'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
'border border-black/5 dark:border-white/10', 'border border-subtle',
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]', 'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
'rounded-none ring-1 ring-black/5 dark:ring-white/5', 'rounded-none ring-1 ring-black/5 dark:ring-white/5',
)} )}
> >
<!-- Header: icon + label --> <!-- 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 <Settings2Icon
size={14} size={14}
class="text-swiss-red" class="text-swiss-red"
/> />
<span <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 GLOBAL_CONTROLS
</span> </span>
</div> </div>
<!-- Controls with dividers between each --> <!-- Controls with dividers between each -->
{#each controlManager.controls as control, i (control.id)} {#each typographySettingsStore.controls as control, i (control.id)}
{#if i > 0} {#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> <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} {/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 App from '$app/App.svelte';
import { mount } from 'svelte'; import { mount } from 'svelte';
import '$app/styles/app.css'; import '$app/styles/app.css';

View File

@@ -3,10 +3,7 @@
Description: The main page component of the application. Description: The main page component of the application.
--> -->
<script lang="ts"> <script lang="ts">
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
import { ComparisonView } from '$widgets/ComparisonView'; import { ComparisonView } from '$widgets/ComparisonView';
import { FontSearchSection } from '$widgets/FontSearch';
import { SampleListSection } from '$widgets/SampleList';
import { cubicIn } from 'svelte/easing'; import { cubicIn } from 'svelte/easing';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
</script> </script>
@@ -18,8 +15,4 @@ import { fade } from 'svelte/transition';
<section class="w-auto"> <section class="w-auto">
<ComparisonView /> <ComparisonView />
</section> </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> </div>

View File

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

View File

@@ -15,15 +15,25 @@ import { QueryClient } from '@tanstack/query-core';
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
/** Data remains fresh for 5 minutes after fetch */ /**
* Data remains fresh for 5 minutes after fetch
*/
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
/** Unused cache entries are removed after 10 minutes */ /**
* Unused cache entries are removed after 10 minutes
*/
gcTime: 10 * 60 * 1000, gcTime: 10 * 60 * 1000,
/** Don't refetch when window regains focus */ /**
* Don't refetch when window regains focus
*/
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
/** Refetch on mount if data is stale */ /**
* Refetch on mount if data is stale
*/
refetchOnMount: true, refetchOnMount: true,
/** Retry failed requests up to 3 times */ /**
* Retry failed requests up to 3 times
*/
retry: 3, retry: 3,
/** /**
* Exponential backoff for retries * 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); }, wait);
return { return {
/** Current value with immediate updates (for UI binding) */ /**
* Current value with immediate updates (for UI binding)
*/
get immediate() { get immediate() {
return immediate; return immediate;
}, },
@@ -41,7 +43,9 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
// Manually trigger the debounce on write // Manually trigger the debounce on write
updateDebounced(value); updateDebounced(value);
}, },
/** Current value with debounced updates (for logic/operations) */ /**
* Current value with debounced updates (for logic/operations)
*/
get debounced() { get debounced() {
return debounced; return debounced;
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,14 +45,27 @@ export interface VirtualItem {
* Options are reactive - pass them through a function getter to enable updates. * Options are reactive - pass them through a function getter to enable updates.
*/ */
export interface VirtualizerOptions { export interface VirtualizerOptions {
/** Total number of items in the data array */ /**
* Total number of items in the underlying data array
*/
count: number; count: number;
/** /**
* Function to estimate the size of an item at a given index. * Function to estimate the size of an item at a given index.
* Used for initial layout before actual measurements are available. * 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; 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; overscan?: number;
/** /**
* Function to get the key of an item at a given index. * Function to get the key of an item at a given index.
@@ -71,6 +84,18 @@ export interface VirtualizerOptions {
useWindowScroll?: boolean; 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. * 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; const { count, data } = options;
// Implicit dependency // Implicit dependency
const v = _version; const v = _version;
if (count === 0 || containerHeight === 0 || !data) return []; if (count === 0 || containerHeight === 0 || !data) {
return [];
}
const overscan = options.overscan ?? 5; const overscan = options.overscan ?? 5;
@@ -239,7 +266,9 @@ export function createVirtualizer<T>(
containerHeight = window.innerHeight; containerHeight = window.innerHeight;
const handleScroll = () => { const handleScroll = () => {
if (rafId !== null) return; if (rafId !== null) {
return;
}
rafId = requestAnimationFrame(() => { rafId = requestAnimationFrame(() => {
// Get current position of element relative to viewport // Get current position of element relative to viewport
@@ -298,7 +327,9 @@ export function createVirtualizer<T>(
}; };
const resizeObserver = new ResizeObserver(([entry]) => { const resizeObserver = new ResizeObserver(([entry]) => {
if (entry) containerHeight = entry.contentRect.height; if (entry) {
containerHeight = entry.contentRect.height;
}
}); });
node.addEventListener('scroll', handleScroll, { passive: true }); node.addEventListener('scroll', handleScroll, { passive: true });
@@ -398,7 +429,9 @@ export function createVirtualizer<T>(
* ``` * ```
*/ */
function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') { 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 itemStart = offsets[index];
const itemSize = measuredSizes[index] ?? options.estimateSize(index); const itemSize = measuredSizes[index] ?? options.estimateSize(index);
@@ -406,16 +439,24 @@ export function createVirtualizer<T>(
const { useWindowScroll } = optionsGetter(); const { useWindowScroll } = optionsGetter();
if (useWindowScroll) { if (useWindowScroll) {
if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2; if (align === 'center') {
if (align === 'end') target = itemStart - window.innerHeight + itemSize; 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 // Add container offset to target to get absolute document position
const absoluteTarget = target + elementOffsetTop; const absoluteTarget = target + elementOffsetTop;
window.scrollTo({ top: absoluteTarget, behavior: 'smooth' }); window.scrollTo({ top: absoluteTarget, behavior: 'smooth' });
} else { } else {
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2; if (align === 'center') {
if (align === 'end') target = itemStart - containerHeight + itemSize; target = itemStart - containerHeight / 2 + itemSize / 2;
}
if (align === 'end') {
target = itemStart - containerHeight + itemSize;
}
elementRef.scrollTo({ top: target, behavior: 'smooth' }); elementRef.scrollTo({ top: target, behavior: 'smooth' });
} }
@@ -444,27 +485,45 @@ export function createVirtualizer<T>(
} }
return { return {
/**
* Current vertical scroll position in pixels (reactive)
*/
get scrollOffset() { get scrollOffset() {
return scrollOffset; return scrollOffset;
}, },
/**
* Measured height of the visible container area (reactive)
*/
get containerHeight() { get containerHeight() {
return containerHeight; return containerHeight;
}, },
/** Computed array of visible items to render (reactive) */ /**
* Computed array of visible items to render (reactive)
*/
get items() { get items() {
return items; return items;
}, },
/** Total height of all items in pixels (reactive) */ /**
* Total height of all items in pixels (reactive)
*/
get totalSize() { get totalSize() {
return totalSize; return totalSize;
}, },
/** Svelte action for the scrollable container element */ /**
* Svelte action for the scrollable container element
*/
container, container,
/** Svelte action for measuring individual item elements */ /**
* Svelte action for measuring individual item elements
*/
measureElement, measureElement,
/** Programmatic scroll method to scroll to a specific item */ /**
* Programmatic scroll method to scroll to a specific item
*/
scrollToIndex, scrollToIndex,
/** Programmatic scroll method to scroll to a specific pixel offset */ /**
* Programmatic scroll method to scroll to a specific pixel offset
*/
scrollToOffset, scrollToOffset,
}; };
} }

View File

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

View File

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

View File

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

View File

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

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