Compare commits

..

104 Commits

Author SHA1 Message Date
Ilia Mashkov 84ac886c33 chore: fix TS alias resolution and SVG mocking for test setup 2026-04-22 09:45:51 +03:00
Ilia Mashkov a60dbcfa51 test: track missing component test configuration 2026-04-22 09:42:59 +03:00
Ilia Mashkov 8fc8a7ee6f test: fix component tests by adding localStorage mock and resolving store interference 2026-04-22 09:42:00 +03:00
Ilia Mashkov cbc978df6d chore(ci): add unit and component tests to lefthook and gitea workflow 2026-04-22 09:09:21 +03:00
Ilia Mashkov 6664beec25 feat(FontList): unified skeleton — rows stay skeletal until font file loaded 2026-04-22 09:02:32 +03:00
Ilia Mashkov a801903fd3 feat(FontList): use getSkeletonWidth utility for skeleton row widths 2026-04-22 09:02:32 +03:00
Ilia Mashkov ecdb1e016d feat(FontApplicator): add skeleton snippet prop to replace blur loading state 2026-04-22 09:02:32 +03:00
Ilia Mashkov 092b58e651 feat(FontVirtualList): suppress font loading during jump scroll catch-up 2026-04-22 09:02:32 +03:00
Ilia Mashkov d6914f8179 feat(FontStore): add fetchAllPagesTo for parallel batch page loading 2026-04-22 09:01:45 +03:00
Ilia Mashkov b831861662 feat(VirtualList): add onJump callback for scroll-beyond-loaded detection 2026-04-22 09:01:45 +03:00
Ilia Mashkov 67fc9dee72 fix(FontList): address the bug with selected font transition animations 2026-04-20 13:36:05 +03:00
Ilia Mashkov a73bd75947 refactor(ComparisonView): unify pretext font string generation with a utility function 2026-04-20 11:13:54 +03:00
Ilia Mashkov 836b83f75d style: apply new dprint rules to CharacterComparisonEngine 2026-04-20 11:06:54 +03:00
Ilia Mashkov 07e4a0b9d9 chore: forbid one-line and braceless cycles in dprint config 2026-04-20 11:06:45 +03:00
Ilia Mashkov 141126530d fix(ComparisonView): fix character morphing thresholds and add tracking support 2026-04-20 10:52:28 +03:00
Ilia Mashkov f9f96e2797 fix(ComparisonView): add correct line-height calculation 2026-04-20 10:51:41 +03:00
Ilia Mashkov 3e11821814 feat: add meta description 2026-04-19 19:15:46 +03:00
Ilia Mashkov ee3f773ca5 chore: replace section with main tag 2026-04-19 19:15:03 +03:00
Ilia Mashkov 2a51f031cc chore: add missing aria labels 2026-04-19 19:14:49 +03:00
Ilia Mashkov b792dde7cb fix(FontList): overwrite css rule 2026-04-19 19:14:15 +03:00
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
ilia 46d0d887b1 Merge pull request 'feature/unified-tanstack-query' (#36) from feature/unified-tanstack-query into main
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
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
ilia f072c5b270 Merge pull request 'fix/initial-fonts-loading' (#35) from fix/initial-fonts-loading into main
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
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
ilia 5b81be6614 Merge pull request 'feature/pretext' (#34) from feature/pretext into main
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
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
ilia 26737f2f11 Merge pull request 'chore/purge-unused' (#33) from chore/purge-unused into main
Workflow / build (push) Successful in 45s
Workflow / publish (push) Successful in 23s
Reviewed-on: #33
2026-04-10 14:31:27 +00:00
222 changed files with 7141 additions and 4198 deletions
+7 -1
View File
@@ -41,7 +41,13 @@ jobs:
run: yarn lint
- name: Type Check
run: yarn check:shadcn-excluded
run: yarn check
- name: Run Unit Tests
run: yarn test:unit
- name: Run Component Tests
run: yarn test:component
publish:
needs: build # Only runs if tests/lint pass
+2 -5
View File
@@ -4,12 +4,11 @@
This provides:
- ResponsiveManager context for breakpoint tracking
- TooltipProvider for shadcn Tooltip components
- TooltipProvider for tooltip components
-->
<script lang="ts">
import { createResponsiveManager } from '$shared/lib';
import type { ResponsiveManager } from '$shared/lib';
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import { setContext } from 'svelte';
interface Props {
@@ -24,6 +23,4 @@ $effect(() => responsiveManager.init());
setContext<ResponsiveManager>('responsive', responsiveManager);
</script>
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
{@render children()}
</TooltipProvider>
{@render children()}
+7 -3
View File
@@ -1,14 +1,18 @@
<script lang="ts">
interface Props {
children: import('svelte').Snippet;
width?: string; // Optional width override
/**
* Tailwind max-width class applied to the card, or 'none' to remove width constraint.
* @default 'max-w-3xl'
*/
maxWidth?: string;
}
let { children, width = 'max-w-3xl' }: Props = $props();
let { children, maxWidth = 'max-w-3xl' }: Props = $props();
</script>
<div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
<div class="w-full bg-card shadow-lg ring-1 ring-border rounded-xl p-12 {width}">
<div class="w-full bg-card shadow-lg ring-1 ring-border rounded-xl p-12 {maxWidth !== 'none' ? maxWidth : ''}">
<div class="relative flex justify-center items-center text-foreground">
{@render children()}
</div>
+2 -63
View File
@@ -5,68 +5,6 @@ import ThemeDecorator from './ThemeDecorator.svelte';
import '../src/app/styles/app.css';
const preview: Preview = {
globalTypes: {
viewport: {
description: 'Viewport size for responsive design',
defaultValue: 'widgetWide',
toolbar: {
icon: 'view',
items: [
{
value: 'reset',
icon: 'refresh',
title: 'Reset viewport',
},
{
value: 'mobile1',
icon: 'mobile',
title: 'iPhone 5/SE',
},
{
value: 'mobile2',
icon: 'mobile',
title: 'iPhone 14 Pro Max',
},
{
value: 'tablet',
icon: 'tablet',
title: 'iPad (Portrait)',
},
{
value: 'desktop',
icon: 'desktop',
title: 'Desktop (Small)',
},
{
value: 'widgetMedium',
icon: 'view',
title: 'Widget Medium',
},
{
value: 'widgetWide',
icon: 'view',
title: 'Widget Wide',
},
{
value: 'widgetExtraWide',
icon: 'view',
title: 'Widget Extra Wide',
},
{
value: 'fullWidth',
icon: 'view',
title: 'Full Width',
},
{
value: 'fullScreen',
icon: 'expand',
title: 'Full Screen',
},
],
dynamicTitle: true,
},
},
},
parameters: {
layout: 'padded',
controls: {
@@ -195,10 +133,11 @@ const preview: Preview = {
},
}),
// Wrap with StoryStage for presentation styling
story => ({
(story, context) => ({
Component: StoryStage,
props: {
children: story(),
maxWidth: context.parameters.storyStage?.maxWidth,
},
}),
],
+2 -2
View File
@@ -8,14 +8,14 @@ A modern font exploration and comparison tool for browsing fonts from Google Fon
- **Side-by-Side Comparison**: Compare up to 4 fonts simultaneously with customizable text, size, and typography settings
- **Advanced Filtering**: Filter by category, provider, character subsets, and weight
- **Virtual Scrolling**: Fast, smooth browsing of thousands of fonts
- **Responsive UI**: Beautiful interface built with shadcn components and Tailwind CSS
- **Responsive UI**: Beautiful interface built with Tailwind CSS
- **Type-Safe**: Full TypeScript coverage with strict mode enabled
## Tech Stack
- **Framework**: Svelte 5 with reactive primitives (runes)
- **Styling**: Tailwind CSS v4
- **Components**: shadcn-svelte (via bits-ui)
- **Components**: Bits UI primitives
- **State Management**: TanStack Query for async data
- **Architecture**: Feature-Sliced Design (FSD)
- **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks)
-16
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"
}
+11 -1
View File
@@ -31,7 +31,17 @@
"importDeclaration.forceMultiLine": "whenMultiple",
"importDeclaration.forceSingleLine": false,
"exportDeclaration.forceMultiLine": "whenMultiple",
"exportDeclaration.forceSingleLine": false
"exportDeclaration.forceSingleLine": false,
"ifStatement.useBraces": "always",
"ifStatement.singleBodyPosition": "nextLine",
"whileStatement.useBraces": "always",
"whileStatement.singleBodyPosition": "nextLine",
"forStatement.useBraces": "always",
"forStatement.singleBodyPosition": "nextLine",
"forInStatement.useBraces": "always",
"forInStatement.singleBodyPosition": "nextLine",
"forOfStatement.useBraces": "always",
"forOfStatement.singleBodyPosition": "nextLine"
},
"json": {
"indentWidth": 2,
+5 -1
View File
@@ -13,11 +13,15 @@ pre-commit:
pre-push:
parallel: true
commands:
test-unit:
run: yarn test:unit
test-component:
run: yarn test:component
type-check:
run: yarn tsc --noEmit
svelte-check:
run: yarn check:shadcn-excluded --threshold warning
run: yarn check --threshold warning
format-check:
glob: "*.{ts,js,svelte,json,md}"
+1 -1
View File
@@ -11,7 +11,6 @@
"prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''",
"check": "svelte-check",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"check:shadcn-excluded": "svelte-check --no-tsconfig --ignore \"src/shared/shadcn\"",
"lint": "oxlint",
"format": "dprint fmt",
"format:check": "dprint check",
@@ -66,6 +65,7 @@
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@chenglou/pretext": "^0.0.5",
"@tanstack/svelte-query": "^6.0.14"
}
}
+24 -2
View File
@@ -7,7 +7,7 @@
/* Base font size */
--font-size: 16px;
/* GLYPHDIFF Swiss Design System */
/* GLYPHDIFF Design System */
/* Primary Colors */
--swiss-beige: #f3f0e9;
--swiss-red: #ff3b30;
@@ -91,7 +91,6 @@
--space-4xl: 4rem;
/* Typography Scale */
--text-2xs: 0.625rem;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
@@ -205,6 +204,14 @@
--font-mono: 'Space Mono', monospace;
--font-primary: 'Space Grotesk', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
/* Micro typography scale — extends Tailwind's text-xs (0.75rem) downward */
--text-5xs: 0.4375rem;
--text-4xs: 0.5rem;
--text-3xs: 0.5625rem;
--text-2xs: 0.625rem;
/* Monospace label tracking — used in Loader and Footnote */
--tracking-wider-mono: 0.2em;
}
@layer base {
@@ -265,6 +272,21 @@
}
}
@layer utilities {
/* 21× border-black/5 dark:border-white/10 → single token */
.border-subtle {
@apply border-black/5 dark:border-white/10;
}
/* Secondary text pair */
.text-secondary {
@apply text-neutral-500 dark:text-neutral-400;
}
/* Standard focus ring */
.focus-ring {
@apply focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2;
}
}
/* Global utility - useful across your app */
@media (prefers-reduced-motion: reduce) {
* {
+9 -17
View File
@@ -14,12 +14,10 @@
*
* - Footer area (currently empty, reserved for future use)
*/
import { BreadcrumbHeader } from '$entities/Breadcrumb';
import { themeManager } from '$features/ChangeAppTheme';
import GD from '$shared/assets/GD.svg';
import { ResponsiveProvider } from '$shared/lib';
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import clsx from 'clsx';
import {
type Snippet,
onDestroy,
@@ -75,29 +73,23 @@ onDestroy(() => themeManager.destroy());
/>
</noscript>
<title>GlyphDiff | Typography & Typefaces</title>
<meta
name="description"
content="Compare typefaces side by side. Adjust size, weight, leading, and tracking to find the perfect typographic pairing."
/>
</svelte:head>
<ResponsiveProvider>
<div
id="app-root"
class={cn(
class={clsx(
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
theme === 'dark' ? 'dark' : '',
)}
>
<header>
<BreadcrumbHeader />
</header>
<!-- <ScrollArea class="h-screen w-screen"> -->
<!-- <main class="flex-1 w-full mx-auto relative"> -->
<TooltipProvider>
{#if fontsReady}
{@render children?.()}
{/if}
</TooltipProvider>
<!-- </main> -->
<!-- </ScrollArea> -->
{#if fontsReady}
{@render children?.()}
{/if}
<footer></footer>
</div>
</ResponsiveProvider>
@@ -34,11 +34,17 @@
* A breadcrumb item representing a tracked section
*/
export interface BreadcrumbItem {
/** Unique index for ordering */
/**
* Unique index for ordering
*/
index: number;
/** Display title for the breadcrumb */
/**
* Display title for the breadcrumb
*/
title: string;
/** DOM element to track */
/**
* DOM element to track
*/
element: HTMLElement;
}
@@ -50,21 +56,37 @@ export interface BreadcrumbItem {
* past while moving down the page.
*/
class ScrollBreadcrumbsStore {
/** All tracked breadcrumb items */
/**
* All tracked breadcrumb items
*/
#items = $state<BreadcrumbItem[]>([]);
/** Set of indices that have scrolled past (exited viewport while scrolling down) */
/**
* Set of indices that have scrolled past (exited viewport while scrolling down)
*/
#scrolledPast = $state<Set<number>>(new Set());
/** Intersection Observer instance */
/**
* Intersection Observer instance
*/
#observer: IntersectionObserver | null = null;
/** Offset for smooth scrolling (sticky header height) */
/**
* Offset for smooth scrolling (sticky header height)
*/
#scrollOffset = 0;
/** Current scroll direction */
/**
* Current scroll direction
*/
#isScrollingDown = $state(false);
/** Previous scroll Y position to determine direction */
/**
* Previous scroll Y position to determine direction
*/
#prevScrollY = 0;
/** Throttled scroll handler */
/**
* Throttled scroll handler
*/
#handleScroll: (() => void) | null = null;
/** Listener count for cleanup */
/**
* Listener count for cleanup
*/
#listenerCount = 0;
/**
@@ -83,13 +105,17 @@ class ScrollBreadcrumbsStore {
* (fires as soon as any part of element crosses viewport edge).
*/
#initObserver(): void {
if (this.#observer) return;
if (this.#observer) {
return;
}
this.#observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
const item = this.#items.find(i => i.element === entry.target);
if (!item) continue;
if (!item) {
continue;
}
if (!entry.isIntersecting && this.#isScrollingDown) {
// Element exited viewport while scrolling DOWN - add to breadcrumbs
@@ -141,17 +167,23 @@ class ScrollBreadcrumbsStore {
this.#detachScrollListener();
}
/** All tracked items sorted by index */
/**
* All tracked items sorted by index
*/
get items(): BreadcrumbItem[] {
return this.#items.slice().sort((a, b) => a.index - b.index);
}
/** Items that have scrolled past viewport top (visible in breadcrumbs) */
/**
* Items that have scrolled past viewport top (visible in breadcrumbs)
*/
get scrolledPastItems(): BreadcrumbItem[] {
return this.items.filter(item => this.#scrolledPast.has(item.index));
}
/** Index of the most recently scrolled item (active section) */
/**
* Index of the most recently scrolled item (active section)
*/
get activeIndex(): number | null {
const past = this.scrolledPastItems;
return past.length > 0 ? past[past.length - 1].index : null;
@@ -171,7 +203,9 @@ class ScrollBreadcrumbsStore {
* @param offset - Scroll offset in pixels (for sticky headers)
*/
add(item: BreadcrumbItem, offset = 0): void {
if (this.#items.find(i => i.index === item.index)) return;
if (this.#items.find(i => i.index === item.index)) {
return;
}
this.#scrollOffset = offset;
this.#items.push(item);
@@ -188,7 +222,9 @@ class ScrollBreadcrumbsStore {
*/
remove(index: number): void {
const item = this.#items.find(i => i.index === index);
if (!item) return;
if (!item) {
return;
}
this.#observer?.unobserve(item.element);
this.#items = this.#items.filter(i => i.index !== index);
@@ -209,7 +245,9 @@ class ScrollBreadcrumbsStore {
*/
scrollTo(index: number, container: HTMLElement | Window = window): void {
const item = this.#items.find(i => i.index === index);
if (!item) return;
if (!item) {
return;
}
const rect = item.element.getBoundingClientRect();
const scrollTop = container === window ? window.scrollY : (container as HTMLElement).scrollTop;
@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import {
afterEach,
beforeEach,
@@ -24,7 +26,9 @@ class MockIntersectionObserver implements IntersectionObserver {
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.callbacks.push(callback);
if (options?.rootMargin) this.rootMargin = options.rootMargin;
if (options?.rootMargin) {
this.rootMargin = options.rootMargin;
}
if (options?.threshold) {
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold];
}
@@ -118,7 +122,9 @@ describe('ScrollBreadcrumbsStore', () => {
(event: string, listener: EventListenerOrEventListenerObject) => {
if (event === 'scroll') {
const index = scrollListeners.indexOf(listener as () => void);
if (index > -1) scrollListeners.splice(index, 1);
if (index > -1) {
scrollListeners.splice(index, 1);
}
}
return undefined;
},
@@ -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>
@@ -44,7 +44,7 @@ function createButtonText(item: BreadcrumbItem) {
flex items-center justify-between
z-40
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
border-b border-black/5 dark:border-white/10
border-b border-subtle
"
>
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
@@ -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();
});
});
@@ -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 />
@@ -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>
@@ -19,10 +19,13 @@ vi.mock('$shared/api/api', () => ({
}));
import { api } from '$shared/api/api';
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
seedFontCache,
} from './proxyFonts';
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
@@ -46,6 +49,7 @@ function mockApiGet<T>(data: T) {
describe('proxyFonts', () => {
beforeEach(() => {
vi.mocked(api.get).mockReset();
queryClient.clear();
});
describe('fetchProxyFonts', () => {
@@ -168,4 +172,33 @@ describe('proxyFonts', () => {
expect(result).toEqual([]);
});
});
describe('seedFontCache', () => {
test('should populate cache with multiple fonts', () => {
const fonts = [
createMockFont({ id: '1', name: 'A' }),
createMockFont({ id: '2', name: 'B' }),
];
seedFontCache(fonts);
expect(queryClient.getQueryData(fontKeys.detail('1'))).toEqual(fonts[0]);
expect(queryClient.getQueryData(fontKeys.detail('2'))).toEqual(fonts[1]);
});
test('should update existing cached fonts with new data', () => {
const id = 'update-me';
queryClient.setQueryData(fontKeys.detail(id), createMockFont({ id, name: 'Old' }));
const updated = createMockFont({ id, name: 'New' });
seedFontCache([updated]);
expect(queryClient.getQueryData(fontKeys.detail(id))).toEqual(updated);
});
test('should handle empty input arrays gracefully', () => {
const spy = vi.spyOn(queryClient, 'setQueryData');
seedFontCache([]);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
});
});
+29 -9
View File
@@ -11,13 +11,23 @@
*/
import { api } from '$shared/api/api';
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type { UnifiedFont } from '../../model/types';
import type {
FontCategory,
FontSubset,
} from '../../model/types';
/**
* Normalizes cache by seeding individual font entries from collection responses.
* This ensures that a font fetched in a list or batch is available via its detail key.
*
* @param fonts - Array of fonts to cache
*/
export function seedFontCache(fonts: UnifiedFont[]): void {
fonts.forEach(font => {
queryClient.setQueryData(fontKeys.detail(font.id), font);
});
}
/**
* Proxy API base URL
@@ -87,16 +97,24 @@ export interface ProxyFontsParams extends QueryParams {
* Includes pagination metadata alongside font data
*/
export interface ProxyFontsResponse {
/** Array of unified font objects */
/**
* List of font objects returned by the proxy
*/
fonts: UnifiedFont[];
/** Total number of fonts matching the query */
/**
* Total number of matching fonts (ignoring limit/offset)
*/
total: number;
/** Limit used for this request */
/**
* Page size used for the request
*/
limit: number;
/** Offset used for this request */
/**
* Start index for the result set
*/
offset: number;
}
@@ -179,7 +197,9 @@ export async function fetchProxyFontById(
* @returns Promise resolving to an array of fonts
*/
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
if (ids.length === 0) return [];
if (ids.length === 0) {
return [];
}
const queryString = ids.join(',');
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
+1
View File
@@ -1,3 +1,4 @@
export * from './api';
export * from './lib';
export * from './model';
export * from './ui';
@@ -3,7 +3,9 @@ import type {
UnifiedFont,
} from '../../model';
/** Valid font weight values (100-900 in increments of 100) */
/**
* Valid font weight values (100-900 in increments of 100)
*/
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
/**
+3
View File
@@ -48,3 +48,6 @@ export {
FontNetworkError,
FontResponseError,
} from './errors/errors';
export { createFontRowSizeResolver } from './sizeResolver/createFontRowSizeResolver';
export type { FontRowSizeResolverOptions } from './sizeResolver/createFontRowSizeResolver';
+30 -52
View File
@@ -1,31 +1,3 @@
/**
* Mock font filter data
*
* Factory functions and preset mock data for font-related filters.
* Used in Storybook stories for font filtering components.
*
* ## Usage
*
* ```ts
* import {
* createMockFilter,
* MOCK_FILTERS,
* } from '$entities/Font/lib/mocks';
*
* // Create a custom filter
* const customFilter = createMockFilter({
* properties: [
* { id: 'option1', name: 'Option 1', value: 'option1' },
* { id: 'option2', name: 'Option 2', value: 'option2', selected: true },
* ],
* });
*
* // Use preset filters
* const categoriesFilter = MOCK_FILTERS.categories;
* const subsetsFilter = MOCK_FILTERS.subsets;
* ```
*/
import type {
FontCategory,
FontProvider,
@@ -34,13 +6,13 @@ import type {
import type { Property } from '$shared/lib';
import { createFilter } from '$shared/lib';
// TYPE DEFINITIONS
/**
* Options for creating a mock filter
*/
export interface MockFilterOptions {
/** Filter properties */
/**
* Initial set of properties for the mock filter
*/
properties: Property<string>[];
}
@@ -48,16 +20,20 @@ export interface MockFilterOptions {
* Preset mock filters for font filtering
*/
export interface MockFilters {
/** Provider filter (Google, Fontshare) */
/**
* Provider filter (Google, Fontshare)
*/
providers: ReturnType<typeof createFilter<'google' | 'fontshare'>>;
/** Category filter (sans-serif, serif, display, etc.) */
/**
* Category filter (sans-serif, serif, display, etc.)
*/
categories: ReturnType<typeof createFilter<FontCategory>>;
/** Subset filter (latin, latin-ext, cyrillic, etc.) */
/**
* Subset filter (latin, latin-ext, cyrillic, etc.)
*/
subsets: ReturnType<typeof createFilter<FontSubset>>;
}
// FONT CATEGORIES
/**
* Unified categories (combines both providers)
*/
@@ -71,8 +47,6 @@ export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
{ id: 'script', name: 'Script', value: 'script' },
];
// FONT SUBSETS
/**
* Common font subsets
*/
@@ -85,8 +59,6 @@ export const FONT_SUBSETS: Property<FontSubset>[] = [
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
];
// FONT PROVIDERS
/**
* Font providers
*/
@@ -95,8 +67,6 @@ export const FONT_PROVIDERS: Property<FontProvider>[] = [
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
];
// FILTER FACTORIES
/**
* Create a mock filter from properties
*/
@@ -139,8 +109,6 @@ export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
return createFilter<FontProvider>({ properties });
}
// PRESET FILTERS
/**
* Preset mock filters - use these directly in stories
*/
@@ -216,8 +184,6 @@ export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
}),
};
// GENERIC FILTER MOCKS
/**
* Create a mock filter with generic string properties
* Useful for testing generic filter components
@@ -239,7 +205,9 @@ export function createGenericFilter(
* Preset generic filters for testing
*/
export const GENERIC_FILTERS = {
/** Small filter with 3 items */
/**
* Small filter with 3 items
*/
small: createFilter({
properties: [
{ id: 'option-1', name: 'Option 1', value: 'option-1' },
@@ -247,7 +215,9 @@ export const GENERIC_FILTERS = {
{ id: 'option-3', name: 'Option 3', value: 'option-3' },
],
}),
/** Medium filter with 6 items */
/**
* Medium filter with 6 items
*/
medium: createFilter({
properties: [
{ id: 'alpha', name: 'Alpha', value: 'alpha' },
@@ -258,7 +228,9 @@ export const GENERIC_FILTERS = {
{ id: 'zeta', name: 'Zeta', value: 'zeta' },
],
}),
/** Large filter with 12 items */
/**
* Large filter with 12 items
*/
large: createFilter({
properties: [
{ id: 'jan', name: 'January', value: 'jan' },
@@ -275,7 +247,9 @@ export const GENERIC_FILTERS = {
{ id: 'dec', name: 'December', value: 'dec' },
],
}),
/** Filter with some pre-selected items */
/**
* Filter with some pre-selected items
*/
partial: createFilter({
properties: [
{ id: 'red', name: 'Red', value: 'red', selected: true },
@@ -284,7 +258,9 @@ export const GENERIC_FILTERS = {
{ id: 'yellow', name: 'Yellow', value: 'yellow', selected: false },
],
}),
/** Filter with all items selected */
/**
* Filter with all items selected
*/
allSelected: createFilter({
properties: [
{ id: 'cat', name: 'Cat', value: 'cat', selected: true },
@@ -292,7 +268,9 @@ export const GENERIC_FILTERS = {
{ id: 'bird', name: 'Bird', value: 'bird', selected: true },
],
}),
/** Empty filter (no items) */
/**
* Empty filter (no items)
*/
empty: createFilter({
properties: [],
}),
+27 -9
View File
@@ -51,23 +51,41 @@ import type {
* Options for creating a mock UnifiedFont
*/
export interface MockUnifiedFontOptions {
/** Unique identifier (default: derived from name) */
/**
* Unique identifier (default: derived from name)
*/
id?: string;
/** Font display name (default: 'Mock Font') */
/**
* Font display name (default: 'Mock Font')
*/
name?: string;
/** Font provider (default: 'google') */
/**
* Font provider (default: 'google')
*/
provider?: FontProvider;
/** Font category (default: 'sans-serif') */
/**
* Font category (default: 'sans-serif')
*/
category?: FontCategory;
/** Font subsets (default: ['latin']) */
/**
* Font subsets (default: ['latin'])
*/
subsets?: FontSubset[];
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
/**
* Font variants (default: ['regular', '700', 'italic', '700italic'])
*/
variants?: FontVariant[];
/** Style URLs (if not provided, mock URLs are generated) */
/**
* Style URLs (if not provided, mock URLs are generated)
*/
styles?: FontStyleUrls;
/** Metadata overrides */
/**
* Metadata overrides
*/
metadata?: Partial<FontMetadata>;
/** Features overrides */
/**
* Features overrides
*/
features?: Partial<FontFeatures>;
}
+317 -41
View File
@@ -1,8 +1,4 @@
/**
* ============================================================================
* MOCK FONT STORE HELPERS
* ============================================================================
*
* Factory functions and preset mock data for TanStack Query stores and state management.
* Used in Storybook stories for components that use reactive stores.
*
@@ -35,27 +31,73 @@ import {
generateMockFonts,
} from './fonts.mock';
// TANSTACK QUERY MOCK TYPES
/**
* Mock TanStack Query state
*/
export interface MockQueryState<TData = unknown, TError = Error> {
/**
* Primary query status (pending, success, error)
*/
status: QueryStatus;
/**
* Payload data (present on success)
*/
data?: TData;
/**
* Caught error object (present on error)
*/
error?: TError;
/**
* True if initial load is in progress
*/
isLoading?: boolean;
/**
* True if background fetch is in progress
*/
isFetching?: boolean;
/**
* True if query resolved successfully
*/
isSuccess?: boolean;
/**
* True if query failed
*/
isError?: boolean;
/**
* True if query is waiting to be executed
*/
isPending?: boolean;
/**
* Timestamp of last successful data retrieval
*/
dataUpdatedAt?: number;
/**
* Timestamp of last recorded error
*/
errorUpdatedAt?: number;
/**
* Total number of consecutive failures
*/
failureCount?: number;
/**
* Detailed reason for the last failure
*/
failureReason?: TError;
/**
* Number of times an error has been caught
*/
errorUpdateCount?: number;
/**
* True if currently refetching in background
*/
isRefetching?: boolean;
/**
* True if refetch attempt failed
*/
isRefetchError?: boolean;
/**
* True if query is paused (e.g. offline)
*/
isPaused?: boolean;
}
@@ -63,26 +105,72 @@ export interface MockQueryState<TData = unknown, TError = Error> {
* Mock TanStack Query observer result
*/
export interface MockQueryObserverResult<TData = unknown, TError = Error> {
/**
* Current observer status
*/
status?: QueryStatus;
/**
* Cached or active data payload
*/
data?: TData;
/**
* Caught error from the observer
*/
error?: TError;
/**
* Loading flag for the observer
*/
isLoading?: boolean;
/**
* Fetching flag for the observer
*/
isFetching?: boolean;
/**
* Success flag for the observer
*/
isSuccess?: boolean;
/**
* Error flag for the observer
*/
isError?: boolean;
/**
* Pending flag for the observer
*/
isPending?: boolean;
/**
* Last update time for data
*/
dataUpdatedAt?: number;
/**
* Last update time for error
*/
errorUpdatedAt?: number;
/**
* Consecutive failure count
*/
failureCount?: number;
/**
* Failure reason object
*/
failureReason?: TError;
/**
* Error count for the observer
*/
errorUpdateCount?: number;
/**
* Refetching flag
*/
isRefetching?: boolean;
/**
* Refetch error flag
*/
isRefetchError?: boolean;
/**
* Paused flag
*/
isPaused?: boolean;
}
// TANSTACK QUERY MOCK FACTORIES
/**
* Create a mock query state for TanStack Query
*/
@@ -138,33 +226,53 @@ export function createSuccessState<TData>(data: TData): MockQueryObserverResult<
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
}
// FONT STORE MOCKS
/**
* Mock UnifiedFontStore state
*/
export interface MockFontStoreState {
/** All cached fonts */
/**
* Map of mock fonts indexed by ID
*/
fonts: Record<string, UnifiedFont>;
/** Current page */
/**
* Currently active page number
*/
page: number;
/** Total pages available */
/**
* Total number of pages calculated from limit
*/
totalPages: number;
/** Items per page */
/**
* Number of items per page
*/
limit: number;
/** Total font count */
/**
* Total number of available fonts
*/
total: number;
/** Loading state */
/**
* Store-level loading status
*/
isLoading: boolean;
/** Error state */
/**
* Caught error object
*/
error: Error | null;
/** Search query */
/**
* Mock search filter string
*/
searchQuery: string;
/** Selected provider */
/**
* Mock provider filter selection
*/
provider: 'google' | 'fontshare' | 'all';
/** Selected category */
/**
* Mock category filter selection
*/
category: string | null;
/** Selected subset */
/**
* Mock subset filter selection
*/
subset: string | null;
}
@@ -210,10 +318,12 @@ export function createMockFontStoreState(
}
/**
* Preset font store states
* Preset font store states for UI testing
*/
export const MOCK_FONT_STORE_STATES = {
/** Initial loading state */
/**
* Initial loading state with no data
*/
loading: createMockFontStoreState({
isLoading: true,
fonts: {},
@@ -221,7 +331,9 @@ export const MOCK_FONT_STORE_STATES = {
page: 1,
}),
/** Empty state (no fonts found) */
/**
* State with no fonts matching filters
*/
empty: createMockFontStoreState({
fonts: {},
total: 0,
@@ -229,7 +341,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** First page with fonts */
/**
* First page of results (10 items)
*/
firstPage: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]),
@@ -241,7 +355,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** Second page with fonts */
/**
* Second page of results (10 items)
*/
secondPage: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]),
@@ -253,7 +369,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** Last page with fonts */
/**
* Final page of results (5 items)
*/
lastPage: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]),
@@ -265,7 +383,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** Error state */
/**
* Terminal failure state
*/
error: createMockFontStoreState({
fonts: {},
error: new Error('Failed to load fonts'),
@@ -274,7 +394,9 @@ export const MOCK_FONT_STORE_STATES = {
isLoading: false,
}),
/** With search query */
/**
* State with active search query
*/
withSearch: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]),
@@ -285,7 +407,9 @@ export const MOCK_FONT_STORE_STATES = {
searchQuery: 'Roboto',
}),
/** Filtered by category */
/**
* State with active category filter
*/
filteredByCategory: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS)
@@ -299,7 +423,9 @@ export const MOCK_FONT_STORE_STATES = {
category: 'serif',
}),
/** Filtered by provider */
/**
* State with active provider filter
*/
filteredByProvider: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS)
@@ -313,7 +439,9 @@ export const MOCK_FONT_STORE_STATES = {
provider: 'google',
}),
/** Large dataset */
/**
* Large collection for performance testing (50 items)
*/
largeDataset: createMockFontStoreState({
fonts: Object.fromEntries(
generateMockFonts(50).map(font => [font.id, font]),
@@ -326,17 +454,30 @@ export const MOCK_FONT_STORE_STATES = {
}),
};
// MOCK STORE OBJECT
/**
* Create a mock store object that mimics TanStack Query behavior
* Useful for components that subscribe to store properties
*/
export function createMockStore<T>(config: {
/**
* Reactive data payload
*/
data?: T;
/**
* Loading status flag
*/
isLoading?: boolean;
/**
* Error status flag
*/
isError?: boolean;
/**
* Catch-all error object
*/
error?: Error;
/**
* Background fetching flag
*/
isFetching?: boolean;
}) {
const {
@@ -348,50 +489,81 @@ export function createMockStore<T>(config: {
} = config;
return {
/**
* Returns the active data payload
*/
get data() {
return data;
},
/**
* True if initially loading
*/
get isLoading() {
return isLoading;
},
/**
* True if last request failed
*/
get isError() {
return isError;
},
/**
* Returns the caught error object
*/
get error() {
return error;
},
/**
* True if fetching in background
*/
get isFetching() {
return isFetching;
},
/**
* True if query is stable and has data
*/
get isSuccess() {
return !isLoading && !isError && data !== undefined;
},
/**
* Returns semantic status string
*/
get status() {
if (isLoading) return 'pending';
if (isError) return 'error';
if (isLoading) {
return 'pending';
}
if (isError) {
return 'error';
}
return 'success';
},
};
}
/**
* Preset mock stores
* Preset mock stores for common UI states
*/
export const MOCK_STORES = {
/** Font store in loading state */
/**
* Initial loading state
*/
loadingFontStore: createMockStore<UnifiedFont[]>({
isLoading: true,
data: undefined,
}),
/** Font store with fonts loaded */
/**
* Successful data load state
*/
successFontStore: createMockStore<UnifiedFont[]>({
data: Object.values(UNIFIED_FONTS),
isLoading: false,
isError: false,
}),
/** Font store with error */
/**
* API error state
*/
errorFontStore: createMockStore<UnifiedFont[]>({
data: undefined,
isLoading: false,
@@ -399,7 +571,9 @@ export const MOCK_STORES = {
error: new Error('Failed to load fonts'),
}),
/** Font store with empty results */
/**
* Empty result set state
*/
emptyFontStore: createMockStore<UnifiedFont[]>({
data: [],
isLoading: false,
@@ -414,36 +588,69 @@ export const MOCK_STORES = {
const mockState = createMockFontStoreState(state);
return {
// State properties
/**
* Collection of mock fonts
*/
get fonts() {
return mockState.fonts;
},
/**
* Current mock page
*/
get page() {
return mockState.page;
},
/**
* Total mock pages
*/
get totalPages() {
return mockState.totalPages;
},
/**
* Mock items per page
*/
get limit() {
return mockState.limit;
},
/**
* Total mock items
*/
get total() {
return mockState.total;
},
/**
* Mock loading status
*/
get isLoading() {
return mockState.isLoading;
},
/**
* Mock error status
*/
get error() {
return mockState.error;
},
/**
* Mock search string
*/
get searchQuery() {
return mockState.searchQuery;
},
/**
* Mock provider filter
*/
get provider() {
return mockState.provider;
},
/**
* Mock category filter
*/
get category() {
return mockState.category;
},
/**
* Mock subset filter
*/
get subset() {
return mockState.subset;
},
@@ -464,15 +671,45 @@ export const MOCK_STORES = {
* Matches FontStore's public API for Storybook use
*/
fontStore: (config: {
/**
* Preset font list
*/
fonts?: UnifiedFont[];
/**
* Total item count
*/
total?: number;
/**
* Items per page
*/
limit?: number;
/**
* Pagination offset
*/
offset?: number;
/**
* Loading flag
*/
isLoading?: boolean;
/**
* Fetching flag
*/
isFetching?: boolean;
/**
* Error flag
*/
isError?: boolean;
/**
* Catch-all error object
*/
error?: Error | null;
/**
* Has more pages flag
*/
hasMore?: boolean;
/**
* Current page number
*/
page?: number;
} = {}) => {
const {
@@ -495,27 +732,51 @@ export const MOCK_STORES = {
return {
// State getters
/**
* Current mock parameters
*/
get params() {
return state.params;
},
/**
* Mock font list
*/
get fonts() {
return mockFonts;
},
/**
* Mock loading state
*/
get isLoading() {
return isLoading;
},
/**
* Mock fetching state
*/
get isFetching() {
return isFetching;
},
/**
* Mock error state
*/
get isError() {
return isError;
},
/**
* Mock error object
*/
get error() {
return error;
},
/**
* Mock empty state check
*/
get isEmpty() {
return !isLoading && !isFetching && mockFonts.length === 0;
},
/**
* Mock pagination metadata
*/
get pagination() {
return {
total: mockTotal,
@@ -527,18 +788,33 @@ export const MOCK_STORES = {
};
},
// Category getters
/**
* Derived sans-serif filter
*/
get sansSerifFonts() {
return mockFonts.filter(f => f.category === 'sans-serif');
},
/**
* Derived serif filter
*/
get serifFonts() {
return mockFonts.filter(f => f.category === 'serif');
},
/**
* Derived display filter
*/
get displayFonts() {
return mockFonts.filter(f => f.category === 'display');
},
/**
* Derived handwriting filter
*/
get handwritingFonts() {
return mockFonts.filter(f => f.category === 'handwriting');
},
/**
* Derived monospace filter
*/
get monospaceFonts() {
return mockFonts.filter(f => f.category === 'monospace');
},
@@ -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);
});
});
@@ -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;
};
}
@@ -1,5 +1,5 @@
import type { ControlModel } from '$shared/lib';
import type { ControlId } from '..';
import type { ControlId } from '../types/typography';
/**
* Font size constants
@@ -86,3 +86,9 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
export const MULTIPLIER_S = 0.5;
export const MULTIPLIER_M = 0.75;
export const MULTIPLIER_L = 1;
/**
* Index value for items not yet loaded in a virtualized list.
* Treated as being at the very bottom of the infinite scroll.
*/
export const VIRTUAL_INDEX_NOT_LOADED = Infinity;
+2 -6
View File
@@ -1,7 +1,3 @@
export {
appliedFontsManager,
createFontStore,
FontStore,
fontStore,
} from './store';
export * from './const/const';
export * from './store';
export * from './types';
@@ -1,10 +1,10 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import { AppliedFontsManager } from './appliedFontsStore.svelte';
import { FontFetchError } from './errors';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
// ── Fake collaborators ────────────────────────────────────────────────────────
class FakeBufferCache {
async get(_url: string): Promise<ArrayBuffer> {
return new ArrayBuffer(8);
@@ -13,7 +13,9 @@ class FakeBufferCache {
clear(): void {}
}
/** Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure. */
/**
* Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure.
*/
class FailingBufferCache {
async get(url: string): Promise<never> {
throw new FontFetchError(url, new Error('network error'), 500);
@@ -22,8 +24,6 @@ class FailingBufferCache {
clear(): void {}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
id,
name: id,
@@ -32,8 +32,6 @@ const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable:
...overrides,
});
// ── Suite ─────────────────────────────────────────────────────────────────────
describe('AppliedFontsManager', () => {
let manager: AppliedFontsManager;
let eviction: FontEvictionPolicy;
@@ -66,8 +64,6 @@ describe('AppliedFontsManager', () => {
vi.unstubAllGlobals();
});
// ── touch() ───────────────────────────────────────────────────────────────
describe('touch()', () => {
it('queues and loads a new font', async () => {
manager.touch([makeConfig('roboto')]);
@@ -131,8 +127,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── queue processing ──────────────────────────────────────────────────────
describe('queue processing', () => {
it('filters non-critical weights in data-saver mode', async () => {
(navigator as any).connection = { saveData: true };
@@ -163,8 +157,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── Phase 1: fetch ────────────────────────────────────────────────────────
describe('Phase 1 — fetch', () => {
it('sets status to error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -209,8 +201,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── Phase 2: parse ────────────────────────────────────────────────────────
describe('Phase 2 — parse', () => {
it('sets status to error on parse failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -241,8 +231,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── #purgeUnused ──────────────────────────────────────────────────────────
describe('#purgeUnused', () => {
it('evicts fonts after TTL expires', async () => {
manager.touch([makeConfig('ephemeral')]);
@@ -300,8 +288,6 @@ describe('AppliedFontsManager', () => {
});
});
// ── destroy() ─────────────────────────────────────────────────────────────
describe('destroy()', () => {
it('clears all statuses', async () => {
manager.touch([makeConfig('roboto')]);
@@ -156,7 +156,9 @@ export class AppliedFontsManager {
}
}
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
/**
* Returns true if data-saver mode is enabled (defers non-critical weights).
*/
#shouldDeferNonCritical(): boolean {
return (navigator as any).connection?.saveData === true;
}
@@ -188,13 +190,11 @@ export class AppliedFontsManager {
const concurrency = getEffectiveConcurrency();
const buffers = new Map<string, ArrayBuffer>();
// ==================== PHASE 1: Concurrent Fetching ====================
// Fetch multiple font files in parallel since network I/O is non-blocking
for (let i = 0; i < entries.length; i += concurrency) {
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers);
}
// ==================== PHASE 2: Sequential Parsing ====================
// Parse buffers one at a time with periodic yields to avoid blocking UI
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
let lastYield = performance.now();
@@ -246,12 +246,16 @@ export class AppliedFontsManager {
);
for (const result of results) {
if (result.ok) continue;
if (result.ok) {
continue;
}
const { key, config, reason } = result;
const isAbort = reason instanceof FontFetchError
&& reason.cause instanceof Error
&& reason.cause.name === 'AbortError';
if (isAbort) continue;
if (isAbort) {
continue;
}
if (reason instanceof FontFetchError) {
console.error(`Font fetch failed: ${config.name}`, reason);
}
@@ -279,7 +283,9 @@ export class AppliedFontsManager {
}
}
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */
/**
* Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted.
*/
#purgeUnused() {
const now = Date.now();
// Iterate through all tracked font keys
@@ -291,7 +297,9 @@ export class AppliedFontsManager {
// Remove FontFace from document to free memory
const font = this.#loadedFonts.get(key);
if (font) document.fonts.delete(font);
if (font) {
document.fonts.delete(font);
}
// Evict from cache and cleanup URL mapping
const url = this.#urlByKey.get(key);
@@ -307,7 +315,9 @@ export class AppliedFontsManager {
}
}
/** Returns current loading status for a font, or undefined if never requested. */
/**
* Returns current loading status for a font, or undefined if never requested.
*/
getFontStatus(id: string, weight: number, isVariable = false) {
try {
return this.statuses.get(generateFontKey({ id, weight, isVariable }));
@@ -316,17 +326,23 @@ export class AppliedFontsManager {
}
}
/** Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. */
/**
* Pins a font so it is never evicted by #purgeUnused(), regardless of TTL.
*/
pin(id: string, weight: number, isVariable = false): void {
this.#eviction.pin(generateFontKey({ id, weight, isVariable }));
}
/** Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires. */
/**
* Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires.
*/
unpin(id: string, weight: number, isVariable = false): void {
this.#eviction.unpin(generateFontKey({ id, weight, isVariable }));
}
/** Waits for all fonts to finish loading using document.fonts.ready. */
/**
* Waits for all fonts to finish loading using document.fonts.ready.
*/
async ready(): Promise<void> {
if (typeof document === 'undefined') {
return;
@@ -336,7 +352,9 @@ export class AppliedFontsManager {
} catch { /* document unloaded */ }
}
/** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */
/**
* Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after.
*/
destroy() {
// Abort all in-flight network requests
this.#abortController.abort();
@@ -375,5 +393,7 @@ export class AppliedFontsManager {
}
}
/** Singleton instance — use throughout the application for unified font loading state. */
/**
* Singleton instance — use throughout the application for unified font loading state.
*/
export const appliedFontsManager = new AppliedFontsManager();
@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import { FontFetchError } from '../../errors';
import { FontBufferCache } from './FontBufferCache';
@@ -3,9 +3,13 @@ import { FontFetchError } from '../../errors';
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
interface FontBufferCacheOptions {
/** Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation. */
/**
* Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation.
*/
fetcher?: Fetcher;
/** Cache API cache name. Defaults to `'font-cache-v1'`. */
/**
* Cache API cache name. Defaults to `'font-cache-v1'`.
*/
cacheName?: string;
}
@@ -85,12 +89,16 @@ export class FontBufferCache {
return buffer;
}
/** Removes a URL from the in-memory cache. Next call to `get()` will re-fetch. */
/**
* Removes a URL from the in-memory cache. Next call to `get()` will re-fetch.
*/
evict(url: string): void {
this.#buffersByUrl.delete(url);
}
/** Clears all in-memory cached buffers. */
/**
* Clears all in-memory cached buffers.
*/
clear(): void {
this.#buffersByUrl.clear();
}
@@ -1,5 +1,7 @@
interface FontEvictionPolicyOptions {
/** TTL in milliseconds. Defaults to 5 minutes. */
/**
* TTL in milliseconds. Defaults to 5 minutes.
*/
ttl?: number;
}
@@ -28,12 +30,16 @@ export class FontEvictionPolicy {
this.#usageTracker.set(key, now);
}
/** Pins a font key so it is never evicted regardless of TTL. */
/**
* Pins a font key so it is never evicted regardless of TTL.
*/
pin(key: string): void {
this.#pinnedFonts.add(key);
}
/** Unpins a font key, allowing it to be evicted once its TTL expires. */
/**
* Unpins a font key, allowing it to be evicted once its TTL expires.
*/
unpin(key: string): void {
this.#pinnedFonts.delete(key);
}
@@ -57,18 +63,24 @@ export class FontEvictionPolicy {
return now - lastUsed >= this.#TTL;
}
/** Returns an iterator over all tracked font keys. */
/**
* Returns an iterator over all tracked font keys.
*/
keys(): IterableIterator<string> {
return this.#usageTracker.keys();
}
/** Removes a font key from tracking. Called by the orchestrator after eviction. */
/**
* Removes a font key from tracking. Called by the orchestrator after eviction.
*/
remove(key: string): void {
this.#usageTracker.delete(key);
this.#pinnedFonts.delete(key);
}
/** Clears all usage timestamps and pinned keys. */
/**
* Clears all usage timestamps and pinned keys.
*/
clear(): void {
this.#usageTracker.clear();
this.#pinnedFonts.clear();
@@ -34,22 +34,30 @@ export class FontLoadQueue {
return entries;
}
/** Returns `true` if the key is currently in the queue. */
/**
* Returns `true` if the key is currently in the queue.
*/
has(key: string): boolean {
return this.#queue.has(key);
}
/** Increments the retry count for a font key. */
/**
* Increments the retry count for a font key.
*/
incrementRetry(key: string): void {
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
}
/** Returns `true` if the font has reached or exceeded the maximum retry limit. */
/**
* Returns `true` if the font has reached or exceeded the maximum retry limit.
*/
isMaxRetriesReached(key: string): boolean {
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES;
}
/** Clears all queued fonts and resets all retry counts. */
/**
* Clears all queued fonts and resets all retry counts.
*/
clear(): void {
this.#queue.clear();
this.#retryCounts.clear();
@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import { FontParseError } from '../../errors';
import { loadFont } from './loadFont';
@@ -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;
}
}
@@ -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 });
});
});
});
@@ -61,7 +61,6 @@ describe('FontStore', () => {
vi.resetAllMocks();
});
// -----------------------------------------------------------------------
describe('construction', () => {
it('stores initial params', () => {
const store = makeStore({ limit: 20 });
@@ -90,7 +89,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('state after fetch', () => {
it('exposes loaded fonts', async () => {
const store = await fetchedStore({}, generateMockFonts(7));
@@ -129,7 +127,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('error states', () => {
it('isError is false before any fetch', () => {
const store = makeStore();
@@ -178,7 +175,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('font accumulation', () => {
it('replaces fonts when refetching the first page', async () => {
const store = makeStore();
@@ -212,7 +208,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('pagination state', () => {
it('returns zero-value defaults before any fetch', () => {
const store = makeStore();
@@ -248,7 +243,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('setParams', () => {
it('merges updates into existing params', () => {
const store = makeStore({ limit: 10 });
@@ -266,7 +260,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('filter change resets', () => {
it('clears accumulated fonts when a filter changes', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
@@ -302,7 +295,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('staleTime in buildOptions', () => {
it('is 5 minutes with no active filters', () => {
const store = makeStore();
@@ -331,7 +323,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('buildQueryKey', () => {
it('omits empty-string params', () => {
const store = makeStore();
@@ -366,7 +357,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('destroy', () => {
it('does not throw', () => {
const store = makeStore();
@@ -380,7 +370,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('refetch', () => {
it('triggers a fetch', async () => {
const store = makeStore();
@@ -400,7 +389,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('nextPage', () => {
let store: FontStore;
@@ -437,7 +425,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('prevPage and goToPage', () => {
it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
@@ -454,7 +441,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('prefetch', () => {
it('triggers a fetch for the provided params', async () => {
const store = makeStore();
@@ -465,7 +451,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('getCachedData / setQueryData', () => {
it('getCachedData returns undefined before any fetch', () => {
queryClient.clear();
@@ -497,7 +482,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('invalidate', () => {
it('calls invalidateQueries', async () => {
const store = await fetchedStore();
@@ -508,7 +492,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('setLimit', () => {
it('updates the limit param', () => {
const store = makeStore({ limit: 10 });
@@ -518,7 +501,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('filter shortcut methods', () => {
let store: FontStore;
@@ -561,7 +543,6 @@ describe('FontStore', () => {
});
});
// -----------------------------------------------------------------------
describe('category getters', () => {
it('each getter returns only fonts of that category', async () => {
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total
@@ -580,4 +561,67 @@ describe('FontStore', () => {
store.destroy();
});
});
describe('fetchAllPagesTo', () => {
beforeEach(() => {
fetch.mockReset();
queryClient.clear();
});
it('fetches all missing pages in parallel up to targetIndex', async () => {
// First page already loaded (offset 0, limit 10, total 50)
const firstFonts = generateMockFonts(10);
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 }));
const store = makeStore();
await store.refetch();
flushSync();
expect(store.fonts).toHaveLength(10);
// Mock remaining pages
for (let offset = 10; offset < 50; offset += 10) {
fetch.mockResolvedValueOnce(
makeResponse(generateMockFonts(10), { total: 50, limit: 10, offset }),
);
}
await store.fetchAllPagesTo(40);
flushSync();
expect(store.fonts).toHaveLength(50);
});
it('skips pages that fail and still merges successful ones', async () => {
const firstFonts = generateMockFonts(10);
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 30, limit: 10, offset: 0 }));
const store = makeStore();
await store.refetch();
flushSync();
// offset=10 fails, offset=20 succeeds
fetch.mockRejectedValueOnce(new Error('network error'));
fetch.mockResolvedValueOnce(
makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 20 }),
);
await store.fetchAllPagesTo(25);
flushSync();
// Page at offset=20 merged, page at offset=10 missing — 20 total
expect(store.fonts).toHaveLength(20);
});
it('is a no-op when target is within already-loaded data', async () => {
const firstFonts = generateMockFonts(10);
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 }));
const store = makeStore();
await store.refetch();
flushSync();
const callsBefore = fetch.mock.calls.length;
await store.fetchAllPagesTo(5);
expect(fetch.mock.calls.length).toBe(callsBefore);
});
});
});
@@ -18,7 +18,9 @@ import type { UnifiedFont } from '../../types';
type PageParam = { offset: number };
/** Filter params + limit — offset is managed by TQ as a page param, not a user param. */
/**
* Filter params + limit — offset is managed by TQ as a page param, not a user param.
*/
type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
@@ -44,34 +46,53 @@ export class FontStore {
});
}
// -- Public state --
/**
* Current filter and limit configuration
*/
get params(): FontStoreParams {
return this.#params;
}
/**
* Flattened list of all fonts loaded across all pages (reactive)
*/
get fonts(): UnifiedFont[] {
return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
}
/**
* True if the first page is currently being fetched
*/
get isLoading(): boolean {
return this.#result.isLoading;
}
/**
* True if any background fetch is in progress (initial or pagination)
*/
get isFetching(): boolean {
return this.#result.isFetching;
}
/**
* True if the last fetch attempt resulted in an error
*/
get isError(): boolean {
return this.#result.isError;
}
/**
* Last caught error from the query observer
*/
get error(): Error | null {
return this.#result.error ?? null;
}
// isEmpty is false during loading/fetching so the UI never flashes "no results"
// while a fetch is in progress. The !isFetching guard is specifically for the filter-change
// transition: fonts clear synchronously → isFetching becomes true → isEmpty stays false.
/**
* True if no fonts were found for the current filter criteria
*/
get isEmpty(): boolean {
return !this.isLoading && !this.isFetching && this.fonts.length === 0;
}
/**
* Pagination metadata derived from the last loaded page
*/
get pagination() {
const pages = this.#result.data?.pages;
const last = pages?.at(-1);
@@ -95,45 +116,65 @@ export class FontStore {
};
}
// -- Lifecycle --
/**
* Cleans up subscriptions and destroys the observer
*/
destroy() {
this.#unsubscribe();
this.#observer.destroy();
}
// -- Param management --
/**
* Merge new parameters into existing state and trigger a refetch
*/
setParams(updates: Partial<FontStoreParams>) {
this.#params = { ...this.#params, ...updates };
this.#observer.setOptions(this.buildOptions());
}
/**
* Forcefully invalidate and refetch the current query from the network
*/
invalidate() {
this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) });
}
// -- Async operations --
/**
* Manually trigger a query refetch
*/
async refetch() {
await this.#observer.refetch();
}
/**
* Prime the cache with data for a specific parameter set
*/
async prefetch(params: FontStoreParams) {
await this.#qc.prefetchInfiniteQuery(this.buildOptions(params));
}
/**
* Abort any active network requests for this store
*/
cancel() {
this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) });
}
/**
* Retrieve current font list from cache without triggering a fetch
*/
getCachedData(): UnifiedFont[] | undefined {
const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
this.buildQueryKey(this.#params),
);
if (!data) return undefined;
if (!data) {
return undefined;
}
return data.pages.flatMap(p => p.fonts);
}
/**
* Manually update the cached font data (useful for optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
const key = this.buildQueryKey(this.#params);
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
@@ -164,55 +205,191 @@ export class FontStore {
);
}
// -- Filter shortcuts --
/**
* Shortcut to update provider filters
*/
setProviders(v: ProxyFontsParams['providers']) {
this.setParams({ providers: v });
}
/**
* Shortcut to update category filters
*/
setCategories(v: ProxyFontsParams['categories']) {
this.setParams({ categories: v });
}
/**
* Shortcut to update subset filters
*/
setSubsets(v: ProxyFontsParams['subsets']) {
this.setParams({ subsets: v });
}
/**
* Shortcut to update search query
*/
setSearch(v: string) {
this.setParams({ q: v || undefined });
}
/**
* Shortcut to update sort order
*/
setSort(v: ProxyFontsParams['sort']) {
this.setParams({ sort: v });
}
// -- Pagination navigation --
/**
* Fetch the next page of results if available
*/
async nextPage(): Promise<void> {
await this.#observer.fetchNextPage();
}
prevPage(): void {} // no-op: infinite scroll accumulates forward only; method kept for API compatibility
goToPage(_page: number): void {} // no-op
#isCatchingUp = false;
#inFlightOffsets = new Set<number>();
/**
* Fetch all pages between the current loaded count and targetIndex in parallel.
* Pages are merged into the cache as they arrive (sorted by offset).
* Failed pages are silently skipped — normal scroll will re-fetch them on demand.
*/
async fetchAllPagesTo(targetIndex: number): Promise<void> {
if (this.#isCatchingUp) {
return;
}
const pageSize = typeof this.#params.limit === 'number' ? this.#params.limit : 50;
const key = this.buildQueryKey(this.#params);
const existing = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key);
if (!existing) {
return;
}
const loadedOffsets = new Set(existing.pageParams.map(p => p.offset));
// Collect offsets for all missing and not-in-flight pages
const missingOffsets: number[] = [];
for (let offset = 0; offset <= targetIndex; offset += pageSize) {
if (!loadedOffsets.has(offset) && !this.#inFlightOffsets.has(offset)) {
missingOffsets.push(offset);
}
}
if (missingOffsets.length === 0) {
return;
}
this.#isCatchingUp = true;
// Sorted merge buffer — flush in offset order as pages arrive
const buffer = new Map<number, ProxyFontsResponse>();
const failed = new Set<number>();
let nextFlushOffset = (existing.pageParams.at(-1)?.offset ?? -pageSize) + pageSize;
const flush = () => {
while (buffer.has(nextFlushOffset) || failed.has(nextFlushOffset)) {
if (buffer.has(nextFlushOffset)) {
this.#appendPageToCache(buffer.get(nextFlushOffset)!);
buffer.delete(nextFlushOffset);
}
failed.delete(nextFlushOffset);
nextFlushOffset += pageSize;
}
};
try {
await Promise.allSettled(
missingOffsets.map(async offset => {
this.#inFlightOffsets.add(offset);
try {
const page = await this.fetchPage({ ...this.#params, offset });
buffer.set(offset, page);
} catch {
failed.add(offset);
} finally {
this.#inFlightOffsets.delete(offset);
}
flush();
}),
);
} finally {
this.#isCatchingUp = false;
}
}
/**
* Backward pagination (no-op: infinite scroll accumulates forward only)
*/
prevPage(): void {}
/**
* Jump to specific page (no-op for infinite scroll)
*/
goToPage(_page: number): void {}
/**
* Update the number of items fetched per page
*/
setLimit(limit: number) {
this.setParams({ limit });
}
// -- Category views --
/**
* Derived list of sans-serif fonts in the current set
*/
get sansSerifFonts() {
return this.fonts.filter(f => f.category === 'sans-serif');
}
/**
* Derived list of serif fonts in the current set
*/
get serifFonts() {
return this.fonts.filter(f => f.category === 'serif');
}
/**
* Derived list of display fonts in the current set
*/
get displayFonts() {
return this.fonts.filter(f => f.category === 'display');
}
/**
* Derived list of handwriting fonts in the current set
*/
get handwritingFonts() {
return this.fonts.filter(f => f.category === 'handwriting');
}
/**
* Derived list of monospace fonts in the current set
*/
get monospaceFonts() {
return this.fonts.filter(f => f.category === 'monospace');
}
// -- Private helpers (TypeScript-private so tests can spy via `as any`) --
/**
* Merge a single page into the InfiniteQuery cache in offset order.
* Called by fetchAllPagesTo as each parallel fetch resolves.
*/
#appendPageToCache(page: ProxyFontsResponse): void {
const key = this.buildQueryKey(this.#params);
const existing = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key);
if (!existing) {
return;
}
// Guard against duplicates
const loadedOffsets = new Set(existing.pageParams.map(p => p.offset));
if (loadedOffsets.has(page.offset)) {
return;
}
const allPages = [...existing.pages, page].sort((a, b) => a.offset - b.offset);
const allParams = [...existing.pageParams, { offset: page.offset }].sort(
(a, b) => a.offset - b.offset,
);
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key, {
pages: allPages,
pageParams: allParams,
});
}
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
const filtered: Record<string, any> = {};
@@ -263,9 +440,15 @@ export class FontStore {
throw new FontNetworkError(cause);
}
if (!response) throw new FontResponseError('response', response);
if (!response.fonts) throw new FontResponseError('response.fonts', response.fonts);
if (!Array.isArray(response.fonts)) throw new FontResponseError('response.fonts', response.fonts);
if (!response) {
throw new FontResponseError('response', response);
}
if (!response.fonts) {
throw new FontResponseError('response.fonts', response.fonts);
}
if (!Array.isArray(response.fonts)) {
throw new FontResponseError('response.fonts', response.fonts);
}
return {
fonts: response.fonts,
+4 -1
View File
@@ -1,5 +1,8 @@
// Applied fonts manager
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
export * from './appliedFontsStore/appliedFontsStore.svelte';
// Batch font store
export { BatchFontStore } from './batchFontStore.svelte';
// Single FontStore
export {
+95 -34
View File
@@ -31,18 +31,28 @@ export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic'
* Combined filter state for font queries
*/
export interface FontFilters {
/** Selected font providers */
/**
* Active font providers to fetch from
*/
providers: FontProvider[];
/** Selected font categories */
/**
* Visual classifications (sans, serif, etc.)
*/
categories: FontCategory[];
/** Selected character subsets */
/**
* Character sets required for the sample text
*/
subsets: FontSubset[];
}
/** Filter group identifier */
/**
* Filter group identifier
*/
export type FilterGroup = 'providers' | 'categories' | 'subsets';
/** Filter type including search query */
/**
* Filter type including search query
*/
export type FilterType = FilterGroup | 'searchQuery';
/**
@@ -80,15 +90,25 @@ export type UnifiedFontVariant = FontVariant;
* Font style URLs
*/
export interface FontStyleUrls {
/** Regular weight URL */
/**
* URL for the regular (400) weight
*/
regular?: string;
/** Italic URL */
/**
* URL for the italic (400) style
*/
italic?: string;
/** Bold weight URL */
/**
* URL for the bold (700) weight
*/
bold?: string;
/** Bold italic URL */
/**
* URL for the bold-italic (700) style
*/
boldItalic?: string;
/** Additional variant mapping */
/**
* Mapping for all other numeric/custom variants
*/
variants?: Partial<Record<UnifiedFontVariant, string>>;
}
@@ -96,19 +116,24 @@ export interface FontStyleUrls {
* Font metadata
*/
export interface FontMetadata {
/** Timestamp when font was cached */
/**
* Epoch timestamp of last successful fetch
*/
cachedAt: number;
/** Font version from provider */
/**
* Semantic version string from upstream
*/
version?: string;
/** Last modified date from provider */
/**
* ISO date string of last remote update
*/
lastModified?: string;
/** Popularity rank (if available from provider) */
/**
* Raw ranking integer from provider
*/
popularity?: number;
/**
* Normalized popularity score (0-100)
*
* Normalized across all fonts for consistent ranking
* Higher values indicate more popular fonts
* Normalized score (0-100) used for global sorting
*/
popularityScore?: number;
}
@@ -117,17 +142,38 @@ export interface FontMetadata {
* Font features (variable fonts, axes, tags)
*/
export interface FontFeatures {
/** Whether this is a variable font */
/**
* Whether the font supports fluid weight/width axes
*/
isVariable?: boolean;
/** Variable font axes (for Fontshare) */
/**
* Definable axes for variable font interpolation
*/
axes?: Array<{
/**
* Human-readable axis name (e.g., 'Weight')
*/
name: string;
/**
* CSS property name (e.g., 'wght')
*/
property: string;
/**
* Default numeric value for the axis
*/
default: number;
/**
* Minimum inclusive bound
*/
min: number;
/**
* Maximum inclusive bound
*/
max: number;
}>;
/** Usage tags (for Fontshare) */
/**
* Descriptive keywords for search indexing
*/
tags?: string[];
}
@@ -138,29 +184,44 @@ export interface FontFeatures {
* for consistent font handling across the application.
*/
export interface UnifiedFont {
/** Unique identifier (Google: family name, Fontshare: slug) */
/**
* Unique ID (family name for Google, slug for Fontshare)
*/
id: string;
/** Font display name */
/**
* Canonical family name for CSS font-family
*/
name: string;
/** Font provider (google | fontshare) */
/**
* Upstream data source
*/
provider: FontProvider;
/**
* Provider badge display name
*
* Human-readable provider name for UI display
* e.g., "Google Fonts" or "Fontshare"
* Display label for provider badges
*/
providerBadge?: string;
/** Font category classification */
/**
* Primary typographic category
*/
category: FontCategory;
/** Supported character subsets */
/**
* All supported character sets
*/
subsets: FontSubset[];
/** Available font variants (weights, styles) */
/**
* List of available weights and styles
*/
variants: UnifiedFontVariant[];
/** URL mapping for font file downloads */
/**
* Remote assets for font loading
*/
styles: FontStyleUrls;
/** Additional metadata */
/**
* Technical metadata and rankings
*/
metadata: FontMetadata;
/** Advanced font features */
/**
* Variable font details and tags
*/
features: FontFeatures;
}
+1 -9
View File
@@ -1,12 +1,3 @@
/**
* ============================================================================
* SINGLE EXPORT POINT
* ============================================================================
*
* This is the single export point for all Font types.
* All imports should use: `import { X } from '$entities/Font/model/types'`
*/
// Font domain and model types
export type {
FilterGroup,
@@ -33,3 +24,4 @@ export type {
} from './store';
export * from './store/appliedFonts';
export * from './typography';
+30 -18
View File
@@ -1,9 +1,3 @@
/**
* ============================================================================
* STORE TYPES
* ============================================================================
*/
import type {
FontCategory,
FontProvider,
@@ -12,37 +6,55 @@ import type {
} from './font';
/**
* Font collection state
* Global state for the local font collection
*/
export interface FontCollectionState {
/** All cached fonts */
/**
* Map of cached fonts indexed by their unique family ID
*/
fonts: Record<string, UnifiedFont>;
/** Active filters */
/**
* Set of active user-defined filters
*/
filters: FontCollectionFilters;
/** Sort configuration */
/**
* Current sorting parameters for the display list
*/
sort: FontCollectionSort;
}
/**
* Font collection filters
* Filter configuration for narrow collections
*/
export interface FontCollectionFilters {
/** Search query */
/**
* Partial family name to match against
*/
searchQuery: string;
/** Filter by providers */
/**
* Data sources (Google, Fontshare) to include
*/
providers?: FontProvider[];
/** Filter by categories */
/**
* Typographic categories (Serif, Sans, etc.) to include
*/
categories?: FontCategory[];
/** Filter by subsets */
/**
* Character sets (Latin, Cyrillic, etc.) to include
*/
subsets?: FontSubset[];
}
/**
* Font collection sort configuration
* Ordering configuration for the font list
*/
export interface FontCollectionSort {
/** Sort field */
/**
* The font property to order by
*/
field: 'name' | 'popularity' | 'category';
/** Sort direction */
/**
* The sort order (Ascending or Descending)
*/
direction: 'asc' | 'desc';
}
@@ -0,0 +1 @@
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
@@ -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>
@@ -1,15 +1,13 @@
<!--
Component: FontApplicator
Loads fonts from fontshare with link tag
- Loads font only if it's not already applied
- Reacts to font load status to show/hide content
- Adds smooth transition when font appears
Applies a font to its children once the font file is loaded.
Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import clsx from 'clsx';
import type { Snippet } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion';
import {
DEFAULT_FONT_WEIGHT,
type UnifiedFont,
appliedFontsManager,
} from '../../model';
@@ -32,13 +30,19 @@ interface Props {
* Content snippet
*/
children?: Snippet;
/**
* Shown while the font file is loading.
* When omitted, children render in system font until ready.
*/
skeleton?: Snippet;
}
let {
font,
weight = 400,
weight = DEFAULT_FONT_WEIGHT,
className,
children,
skeleton,
}: Props = $props();
const status = $derived(
@@ -49,30 +53,16 @@ const status = $derived(
),
);
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
const shouldReveal = $derived(status === 'loaded' || status === 'error');
const transitionClasses = $derived(
prefersReducedMotion.current
? 'transition-none' // Disable CSS transitions if motion is reduced
: 'transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
);
</script>
<div
style:font-family={shouldReveal
? `'${font.name}'`
: 'system-ui, -apple-system, sans-serif'}
class={cn(
transitionClasses,
// If reduced motion is on, we skip the transform/blur entirely
!shouldReveal
&& !prefersReducedMotion.current
&& 'opacity-50 scale-[0.95] blur-sm',
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
shouldReveal && 'opacity-100 scale-100 blur-0',
className,
)}
>
{@render children?.()}
</div>
{#if !shouldReveal && skeleton}
{@render skeleton()}
{:else}
<div
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
class={clsx(className)}
>
{@render children?.()}
</div>
{/if}
@@ -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>
@@ -4,6 +4,7 @@
- Handles font registration with the manager
-->
<script lang="ts">
import { debounce } from '$shared/lib/utils';
import {
Skeleton,
VirtualList,
@@ -18,8 +19,8 @@ import {
type FontLoadRequestConfig,
type UnifiedFont,
appliedFontsManager,
fontStore,
} from '../../model';
import { fontStore } from '../../model/store';
interface Props extends
Omit<
@@ -53,30 +54,72 @@ const isLoading = $derived(
fontStore.isFetching || fontStore.isLoading,
);
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
const configs: FontLoadRequestConfig[] = [];
let visibleFonts = $state<UnifiedFont[]>([]);
let isCatchingUp = $state(false);
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);
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontStore.fonts.length === 0);
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
function handleInternalVisibleChange(items: UnifiedFont[]) {
visibleFonts = items;
// Forward the call to any external listener
// onVisibleItemsChange?.(visibleItems);
onVisibleItemsChange?.(items);
}
/**
* Handle jump scroll — batch-load all missing pages then re-enable font loading.
* Suppresses appliedFontsManager.touch() during catch-up to avoid loading
* font files for thousands of intermediate fonts.
*/
async function handleJump(targetIndex: number) {
if (isCatchingUp || !fontStore.pagination.hasMore) {
return;
}
isCatchingUp = true;
try {
await fontStore.fetchAllPagesTo(targetIndex);
} finally {
isCatchingUp = false;
}
}
const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
appliedFontsManager.touch(configs);
}, 150);
// Re-touch whenever visible set or weight changes — fixes weight-change gap
$effect(() => {
if (isCatchingUp) {
return;
}
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) {
debouncedTouch(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
*/
@@ -99,17 +142,19 @@ function loadMore() {
function handleNearBottom(_lastVisibleIndex: number) {
const { hasMore } = fontStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items
if (hasMore && !fontStore.isFetching) {
// VirtualList already checks if we're near the bottom of loaded items.
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
// during batch catch-up, which would otherwise let nextPage() race with it.
if (hasMore && !fontStore.isFetching && !isCatchingUp) {
loadMore();
}
}
</script>
<div class="relative w-full h-full">
{#if skeleton && isLoading && fontStore.fonts.length === 0}
{#if showInitialSkeleton && skeleton}
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
<div transition:fade={{ duration: 300 }}>
<div class="overflow-hidden h-full" transition:fade={{ duration: 300 }}>
{@render skeleton()}
</div>
{:else}
@@ -117,14 +162,20 @@ function handleNearBottom(_lastVisibleIndex: number) {
<VirtualList
items={fontStore.fonts}
total={fontStore.pagination.total}
isLoading={isLoading}
isLoading={isLoading || isCatchingUp}
onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom}
onJump={handleJump}
{...rest}
>
{#snippet children(scope)}
{@render children(scope)}
{/snippet}
</VirtualList>
{#if showCatchupSkeleton && skeleton}
<div class="absolute inset-0 overflow-hidden" transition:fade={{ duration: 150 }}>
{@render skeleton()}
</div>
{/if}
{/if}
</div>
@@ -41,15 +41,25 @@ type ThemeSource = 'system' | 'user';
*/
class ThemeManager {
// Private reactive state
/** Current theme value ('light' or 'dark') */
/**
* Current theme value ('light' or 'dark')
*/
#theme = $state<Theme>('light');
/** Whether theme is controlled by user or follows system */
/**
* Whether theme is controlled by user or follows system
*/
#source = $state<ThemeSource>('system');
/** MediaQueryList for detecting system theme changes */
/**
* MediaQueryList for detecting system theme changes
*/
#mediaQuery: MediaQueryList | null = null;
/** Persistent storage for user's theme preference */
/**
* Persistent storage for user's theme preference
*/
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
/** Bound handler for system theme change events */
/**
* Bound handler for system theme change events
*/
#systemChangeHandler = this.#onSystemChange.bind(this);
constructor() {
@@ -64,22 +74,30 @@ class ThemeManager {
}
}
/** Current theme value */
/**
* Current theme value
*/
get value(): Theme {
return this.#theme;
}
/** Source of current theme ('system' or 'user') */
/**
* Source of current theme ('system' or 'user')
*/
get source(): ThemeSource {
return this.#source;
}
/** Whether dark theme is active */
/**
* Whether dark theme is active
*/
get isDark(): boolean {
return this.#theme === 'dark';
}
/** Whether theme is controlled by user (not following system) */
/**
* Whether theme is controlled by user (not following system)
*/
get isUserControlled(): boolean {
return this.#source === 'user';
}
@@ -1,9 +1,9 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
// ============================================================
// Mock MediaQueryListEvent for system theme change simulations
// Note: Other mocks (ResizeObserver, localStorage, matchMedia) are set up in vitest.setup.unit.ts
// ============================================================
class MockMediaQueryListEvent extends Event {
matches: boolean;
@@ -16,9 +16,7 @@ class MockMediaQueryListEvent extends Event {
}
}
// ============================================================
// NOW IT'S SAFE TO IMPORT
// ============================================================
import {
afterEach,
@@ -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');
});
});
});
@@ -35,7 +35,7 @@ const { Story } = defineMeta({
<script lang="ts">
import type { UnifiedFont } from '$entities/Font';
import { controlManager } from '$features/SetupFont';
import type { ComponentProps } from 'svelte';
// Mock fonts for testing
const mockArial: UnifiedFont = {
@@ -89,7 +89,7 @@ const mockGeorgia: UnifiedFont = {
index: 0,
}}
>
{#snippet template(args)}
{#snippet template(args: ComponentProps<typeof FontSampler>)}
<Providers>
<div class="max-w-2xl mx-auto">
<FontSampler {...args} />
@@ -106,7 +106,7 @@ const mockGeorgia: UnifiedFont = {
index: 1,
}}
>
{#snippet template(args)}
{#snippet template(args: ComponentProps<typeof FontSampler>)}
<Providers>
<div class="max-w-2xl mx-auto">
<FontSampler {...args} />
@@ -8,14 +8,13 @@ import {
FontApplicator,
type UnifiedFont,
} from '$entities/Font';
import { controlManager } from '$features/SetupFont';
import { typographySettingsStore } from '$features/SetupFont/model';
import {
Badge,
ContentEditable,
Divider,
Footnote,
Stat,
StatGroup,
} from '$shared/ui';
import { fly } from 'svelte/transition';
@@ -37,11 +36,6 @@ interface Props {
let { font, text = $bindable(), index = 0 }: Props = $props();
const fontWeight = $derived(controlManager.weight);
const fontSize = $derived(controlManager.renderedSize);
const lineHeight = $derived(controlManager.height);
const letterSpacing = $derived(controlManager.spacing);
// Adjust the property name to match your UnifiedFont type
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
@@ -52,10 +46,10 @@ const providerBadge = $derived(
);
const stats = $derived([
{ label: 'SZ', value: `${fontSize}PX` },
{ label: 'WGT', value: `${fontWeight}` },
{ label: 'LH', value: lineHeight?.toFixed(2) },
{ label: 'LTR', value: `${letterSpacing}` },
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
{ label: 'WGT', value: `${typographySettingsStore.weight}` },
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
{ label: 'LTR', value: `${typographySettingsStore.spacing}` },
]);
</script>
@@ -65,7 +59,7 @@ const stats = $derived([
group relative
w-full h-full
bg-paper dark:bg-dark-card
border border-black/5 dark:border-white/10
border border-subtle
hover:border-brand dark:hover:border-brand
hover:shadow-brand/10
hover:shadow-[5px_5px_0px_0px]
@@ -75,20 +69,20 @@ const stats = $derived([
min-h-60
rounded-none
"
style:font-weight={fontWeight}
style:font-weight={typographySettingsStore.weight}
>
<!-- ── Header bar ─────────────────────────────────────────────────── -->
<div
class="
flex items-center justify-between
px-4 sm:px-5 md:px-6 py-3 sm:py-4
border-b border-black/5 dark:border-white/10
border-b border-subtle
bg-paper dark:bg-dark-card
"
>
<!-- Left: index · name · type badge · provider badge -->
<div class="flex items-center gap-2 sm:gap-4 min-w-0 shrink-0">
<span class="font-mono text-[0.625rem] tracking-widest text-neutral-400 uppercase leading-none shrink-0">
<span class="font-mono text-2xs tracking-widest text-neutral-400 uppercase leading-none shrink-0">
{String(index + 1).padStart(2, '0')}
</span>
<Divider orientation="vertical" class="h-3 shrink-0" />
@@ -100,14 +94,14 @@ const stats = $derived([
</span>
{#if fontType}
<Badge size="xs" variant="default" class="text-nowrap font-mono">
<Badge size="xs" variant="default" nowrap>
{fontType}
</Badge>
{/if}
<!-- Provider badge -->
{#if providerBadge}
<Badge size="xs" variant="default" class="text-nowrap font-mono" data-provider={font.provider}>
<Badge size="xs" variant="default" nowrap data-provider={font.provider}>
{providerBadge}
</Badge>
{/if}
@@ -140,20 +134,20 @@ const stats = $derived([
<!-- ── Main content area ──────────────────────────────────────────── -->
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
<FontApplicator {font} weight={fontWeight}>
<FontApplicator {font} weight={typographySettingsStore.weight}>
<ContentEditable
bind:text
{fontSize}
{lineHeight}
{letterSpacing}
fontSize={typographySettingsStore.renderedSize}
lineHeight={typographySettingsStore.height}
letterSpacing={typographySettingsStore.spacing}
/>
</FontApplicator>
</div>
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-black/5 dark:border-white/10 flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-subtle flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
{#each stats as stat, i}
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider {i === 0 ? 'ml-auto' : ''}">
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
{stat.label}:{stat.value}
</Footnote>
{#if i < stats.length - 1}
+30 -10
View File
@@ -15,19 +15,29 @@ const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const;
* Filter metadata type from backend
*/
export interface FilterMetadata {
/** Filter ID (e.g., "providers", "categories", "subsets") */
/**
* Filter ID (e.g., "providers", "categories", "subsets")
*/
id: string;
/** Display name (e.g., "Font Providers", "Categories", "Character Subsets") */
/**
* Display name (e.g., "Font Providers", "Categories", "Character Subsets")
*/
name: string;
/** Filter description */
/**
* Filter description
*/
description: string;
/** Filter type */
/**
* Filter type
*/
type: 'enum' | 'string' | 'array';
/** Available filter options */
/**
* Available filter options
*/
options: FilterOption[];
}
@@ -35,16 +45,24 @@ export interface FilterMetadata {
* Filter option type
*/
export interface FilterOption {
/** Option ID (e.g., "google", "serif", "latin") */
/**
* Option ID (e.g., "google", "serif", "latin")
*/
id: string;
/** Display name (e.g., "Google Fonts", "Serif", "Latin") */
/**
* Display name (e.g., "Google Fonts", "Serif", "Latin")
*/
name: string;
/** Option value (e.g., "google", "serif", "latin") */
/**
* Option value (e.g., "google", "serif", "latin")
*/
value: string;
/** Number of fonts with this value */
/**
* Number of fonts with this value
*/
count: number;
}
@@ -52,7 +70,9 @@ export interface FilterOption {
* Proxy filters API response
*/
export interface ProxyFiltersResponse {
/** Array of filter metadata */
/**
* Array of filter metadata
*/
filters: FilterMetadata[];
}
+1
View File
@@ -4,6 +4,7 @@ export {
mapManagerToParams,
} from './lib';
export { filtersStore } from './model/state/filters.svelte';
export { filterManager } from './model/state/manager.svelte';
export {
+44 -3
View File
@@ -1,15 +1,56 @@
export type {
/**
* Top-level configuration for all filters
*/
FilterConfig,
/**
* Configuration for a single grouping of filter properties
*/
FilterGroupConfig,
} from './types/filter';
export { filtersStore } from './state/filters.svelte';
export { filterManager } from './state/manager.svelte';
/**
* Global reactive filter state
*/
export {
/**
* Low-level property selection store
*/
filtersStore,
} from './state/filters.svelte';
/**
* Main filter controller
*/
export {
/**
* High-level manager for syncing search and filters
*/
filterManager,
} from './state/manager.svelte';
/**
* Sorting logic
*/
export {
/**
* Map of human-readable labels to API sort keys
*/
SORT_MAP,
/**
* List of all available sort options for the UI
*/
SORT_OPTIONS,
/**
* Valid sort key values
*/
type SortApiValue,
/**
* UI model for a single sort option
*/
type SortOption,
/**
* Reactive store for the current sort selection
*/
sortStore,
} from './store/sortStore.svelte';
@@ -32,13 +32,19 @@ import {
* Provides reactive access to filter data
*/
class FiltersStore {
/** TanStack Query result state */
/**
* TanStack Query result state
*/
protected result = $state<QueryObserverResult<FilterMetadata[], Error>>({} as any);
/** TanStack Query observer instance */
/**
* TanStack Query observer instance
*/
protected observer: QueryObserver<FilterMetadata[], Error>;
/** Shared query client */
/**
* Shared query client
*/
protected qc = queryClient;
/**
@@ -21,17 +21,23 @@ function createSortStore(initial: SortOption = 'Popularity') {
let current = $state<SortOption>(initial);
return {
/** Current display label (e.g. 'Popularity') */
/**
* Current display label (e.g. 'Popularity')
*/
get value() {
return current;
},
/** Mapped API value (e.g. 'popularity') */
/**
* Mapped API value (e.g. 'popularity')
*/
get apiValue(): SortApiValue {
return SORT_MAP[current];
},
/** Set the active sort option by its display label */
/**
* Set the active sort option by its display label
*/
set(option: SortOption) {
current = option;
},
@@ -1,12 +1,27 @@
import type { Property } from '$shared/lib';
export interface FilterGroupConfig<TValue extends string> {
/**
* Unique identifier for the filter group (e.g. 'categories')
*/
id: string;
/**
* Human-readable label displayed in the UI header
*/
label: string;
/**
* List of toggleable properties within this group
*/
properties: Property<TValue>[];
}
export interface FilterConfig<TValue extends string> {
/**
* Optional string to filter results by name
*/
queryValue?: string;
/**
* Collection of filter groups to display
*/
groups: FilterGroupConfig<TValue>[];
}
@@ -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>
@@ -0,0 +1,74 @@
import {
filterManager,
filtersStore,
} from '$features/GetFonts';
import {
render,
screen,
} from '@testing-library/svelte';
import { vi } from 'vitest';
import Filters from './Filters.svelte';
describe('Filters', () => {
beforeEach(() => {
// Clear groups and mock filtersStore to be empty so the auto-sync effect doesn't overwrite us
filterManager.setGroups([]);
vi.spyOn(filtersStore, 'filters', 'get').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Rendering', () => {
it('renders nothing when filter groups are empty', () => {
const { container } = render(Filters);
// It might render an empty container if the component has one, but we expect no children
expect(container.firstChild?.childNodes.length ?? 0).toBe(0);
});
it('renders a label for each filter group', () => {
filterManager.setGroups([
{ id: 'cat', label: 'Categories', properties: [] },
{ id: 'prov', label: 'Font Providers', properties: [] },
]);
render(Filters);
expect(screen.getByText('Categories')).toBeInTheDocument();
expect(screen.getByText('Font Providers')).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();
});
});
});
@@ -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>
@@ -6,10 +6,10 @@
<script lang="ts">
import { fontStore } from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Button } from '$shared/ui';
import { Label } from '$shared/ui';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import clsx from 'clsx';
import {
getContext,
untrack,
@@ -45,7 +45,7 @@ function handleReset() {
</script>
<div
class={cn(
class={clsx(
'flex flex-col md:flex-row justify-between items-start md:items-center',
'gap-1 md:gap-6',
'pt-6 mt-6 md:pt-8 md:mt-8',
@@ -61,13 +61,10 @@ function handleReset() {
{#each SORT_OPTIONS as option}
<Button
variant="ghost"
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
active={sortStore.value === option}
onclick={() => sortStore.set(option)}
class={cn(
'font-bold uppercase tracking-wide font-primary, px-0',
isMobileOrTabletPortrait ? 'text-[0.5625rem]' : 'text-[0.625rem]',
)}
class="tracking-wide px-0"
>
{option}
</Button>
@@ -78,12 +75,9 @@ function handleReset() {
<!-- Reset_Filters -->
<Button
variant="ghost"
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
onclick={handleReset}
class={cn(
'group text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest text-neutral-400',
isMobileOrTabletPortrait && 'px-0',
)}
class={clsx('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
iconPosition="left"
>
{#snippet icon()}
+4 -26
View File
@@ -1,28 +1,6 @@
export { TypographyMenu } from './ui';
export {
type ControlId,
controlManager,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from './model';
export {
createTypographyControlManager,
type TypographyControlManager,
createTypographySettingsManager,
type TypographySettingsManager,
} from './lib';
export { typographySettingsStore } from './model';
export { TypographyMenu } from './ui';
+3 -3
View File
@@ -1,4 +1,4 @@
export {
createTypographyControlManager,
type TypographyControlManager,
} from './controlManager/controlManager.svelte';
createTypographySettingsManager,
type TypographySettingsManager,
} from './settingsManager/settingsManager.svelte';
@@ -10,6 +10,13 @@
* when displaying/editing, but the base size is what's stored.
*/
import {
type ControlId,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
} from '$entities/Font';
import {
type ControlDataModel,
type ControlModel,
@@ -19,20 +26,16 @@ import {
createTypographyControl,
} from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity';
import {
type ControlId,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
} from '../../model';
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
/**
* A control with its instance
* A control with its associated instance
*/
export interface Control extends ControlOnlyFields<ControlId> {
/**
* The reactive typography control instance
*/
instance: TypographyControl;
}
@@ -40,9 +43,21 @@ export interface Control extends ControlOnlyFields<ControlId> {
* Storage schema for typography settings
*/
export interface TypographySettings {
/**
* Base font size (User preference, unscaled)
*/
fontSize: number;
/**
* Numeric font weight (100-900)
*/
fontWeight: number;
/**
* Line height multiplier (e.g. 1.5)
*/
lineHeight: number;
/**
* Letter spacing in em/px
*/
letterSpacing: number;
}
@@ -52,14 +67,22 @@ export interface TypographySettings {
* Manages multiple typography controls with persistent storage and
* responsive scaling support for font size.
*/
export class TypographyControlManager {
/** Map of controls keyed by ID */
export class TypographySettingsManager {
/**
* Internal map of reactive controls keyed by their identifier
*/
#controls = new SvelteMap<string, Control>();
/** Responsive multiplier for font size display */
/**
* Global multiplier for responsive font size scaling
*/
#multiplier = $state(1);
/** Persistent storage for settings */
/**
* LocalStorage-backed storage for persistence
*/
#storage: PersistentStore<TypographySettings>;
/** Base font size (user preference, unscaled) */
/**
* The underlying font size before responsive scaling is applied
*/
#baseSize = $state(DEFAULT_FONT_SIZE);
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
@@ -105,7 +128,9 @@ export class TypographyControlManager {
// This handles the "Multiplier" logic specifically for the Font Size Control
$effect(() => {
const ctrl = this.#controls.get('font_size')?.instance;
if (!ctrl) return;
if (!ctrl) {
return;
}
// If the user moves the slider/clicks buttons in the UI:
// We update the baseSize (User Intent)
@@ -124,26 +149,35 @@ export class TypographyControlManager {
* Gets initial value for a control from storage or defaults
*/
#getInitialValue(id: string, saved: TypographySettings): number {
if (id === 'font_size') return saved.fontSize * this.#multiplier;
if (id === 'font_weight') return saved.fontWeight;
if (id === 'line_height') return saved.lineHeight;
if (id === 'letter_spacing') return saved.letterSpacing;
if (id === 'font_size') {
return saved.fontSize * this.#multiplier;
}
if (id === 'font_weight') {
return saved.fontWeight;
}
if (id === 'line_height') {
return saved.lineHeight;
}
if (id === 'letter_spacing') {
return saved.letterSpacing;
}
return 0;
}
/** Current multiplier for responsive scaling */
/**
* Active scaling factor for the rendered font size
*/
get multiplier() {
return this.#multiplier;
}
/**
* Set the multiplier and update font size display
*
* When multiplier changes, the font size control's display value
* is updated to reflect the new scale while preserving base size.
* Updates the multiplier and recalculates dependent control values
*/
set multiplier(value: number) {
if (this.#multiplier === value) return;
if (this.#multiplier === value) {
return;
}
this.#multiplier = value;
// When multiplier changes, we must update the Font Size Control's display value
@@ -154,14 +188,15 @@ export class TypographyControlManager {
}
/**
* The scaled size for CSS usage
* Returns baseSize * multiplier for actual rendering
* The actual pixel value for CSS font-size (baseSize * multiplier)
*/
get renderedSize() {
return this.#baseSize * this.#multiplier;
}
/** The base size (User Preference) */
/**
* The raw font size preference before scaling
*/
get baseSize() {
return this.#baseSize;
}
@@ -169,49 +204,69 @@ export class TypographyControlManager {
set baseSize(val: number) {
this.#baseSize = val;
const ctrl = this.#controls.get('font_size')?.instance;
if (ctrl) ctrl.value = val * this.#multiplier;
if (ctrl) {
ctrl.value = val * this.#multiplier;
}
}
/**
* Getters for controls
* List of all managed typography controls
*/
get controls() {
return Array.from(this.#controls.values());
}
/**
* Reactive instance for weight manipulation
*/
get weightControl() {
return this.#controls.get('font_weight')?.instance;
}
/**
* Reactive instance for size manipulation
*/
get sizeControl() {
return this.#controls.get('font_size')?.instance;
}
/**
* Reactive instance for line-height manipulation
*/
get heightControl() {
return this.#controls.get('line_height')?.instance;
}
/**
* Reactive instance for letter-spacing manipulation
*/
get spacingControl() {
return this.#controls.get('letter_spacing')?.instance;
}
/**
* Getters for values (besides font-size)
* Current numeric font weight (reactive)
*/
get weight() {
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
}
/**
* Current numeric line height (reactive)
*/
get height() {
return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
}
/**
* Current numeric letter spacing (reactive)
*/
get spacing() {
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
}
/**
* Reset all controls to default values
* Reset all controls to project-defined defaults
*/
reset() {
this.#storage.clear();
@@ -227,9 +282,15 @@ export class TypographyControlManager {
// Map storage key to control id
const key = c.id.replace('_', '') as keyof TypographySettings;
// Simplified for brevity, you'd map these properly:
if (c.id === 'font_weight') c.instance.value = defaults.fontWeight;
if (c.id === 'line_height') c.instance.value = defaults.lineHeight;
if (c.id === 'letter_spacing') c.instance.value = defaults.letterSpacing;
if (c.id === 'font_weight') {
c.instance.value = defaults.fontWeight;
}
if (c.id === 'line_height') {
c.instance.value = defaults.lineHeight;
}
if (c.id === 'letter_spacing') {
c.instance.value = defaults.letterSpacing;
}
}
});
}
@@ -242,7 +303,7 @@ export class TypographyControlManager {
* @param storageId - Persistent storage identifier
* @returns Typography control manager instance
*/
export function createTypographyControlManager(
export function createTypographySettingsManager(
configs: ControlModel<ControlId>[],
storageId: string = 'glyphdiff:typography',
) {
@@ -252,5 +313,5 @@ export function createTypographyControlManager(
lineHeight: DEFAULT_LINE_HEIGHT,
letterSpacing: DEFAULT_LETTER_SPACING,
});
return new TypographyControlManager(configs, storage);
return new TypographySettingsManager(configs, storage);
}
@@ -1,6 +1,14 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} from '$entities/Font';
import {
afterEach,
beforeEach,
describe,
expect,
@@ -8,21 +16,14 @@ import {
vi,
} from 'vitest';
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} from '../../model';
import {
TypographyControlManager,
type TypographySettings,
} from './controlManager.svelte';
TypographySettingsManager,
} from './settingsManager.svelte';
/**
* Test Strategy for TypographyControlManager
* Test Strategy for TypographySettingsManager
*
* This test suite validates the TypographyControlManager state management logic.
* This test suite validates the TypographySettingsManager state management logic.
* These are unit tests for the manager logic, separate from component rendering.
*
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
@@ -45,7 +46,7 @@ async function flushEffects() {
await Promise.resolve();
}
describe('TypographyControlManager - Unit Tests', () => {
describe('TypographySettingsManager - Unit Tests', () => {
let mockStorage: TypographySettings;
let mockPersistentStore: {
value: TypographySettings;
@@ -85,7 +86,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Initialization', () => {
it('creates manager with default values from storage', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -105,7 +106,7 @@ describe('TypographyControlManager - Unit Tests', () => {
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -117,7 +118,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -126,7 +127,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('returns all controls via controls getter', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -142,7 +143,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('returns individual controls via specific getters', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -160,7 +161,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('control instances have expected interface', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -179,7 +180,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Multiplier System', () => {
it('has default multiplier of 1', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -188,7 +189,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('updates multiplier when set', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -201,7 +202,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('does not update multiplier if set to same value', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -217,7 +218,7 @@ describe('TypographyControlManager - Unit Tests', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -241,7 +242,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('updates font size control display value when multiplier increases', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -262,7 +263,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Base Size Setter', () => {
it('updates baseSize when set directly', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -273,7 +274,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('updates size control value when baseSize is set', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -284,7 +285,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('applies multiplier to size control when baseSize is set', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -298,7 +299,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Rendered Size Calculation', () => {
it('calculates renderedSize as baseSize * multiplier', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -307,7 +308,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('updates renderedSize when multiplier changes', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -320,7 +321,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('updates renderedSize when baseSize changes', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -340,7 +341,7 @@ describe('TypographyControlManager - Unit Tests', () => {
// proxy effect behavior should be tested in E2E tests.
it('does NOT immediately update baseSize from control change (effect is async)', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -355,7 +356,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('updates baseSize via direct setter (synchronous)', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -380,7 +381,7 @@ describe('TypographyControlManager - Unit Tests', () => {
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -393,7 +394,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('syncs to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -409,7 +410,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('syncs control changes to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -422,7 +423,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('syncs height control changes to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -434,7 +435,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('syncs spacing control changes to storage after effect flush (async)', async () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -448,7 +449,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Control Value Getters', () => {
it('returns current weight value', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -460,7 +461,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('returns current height value', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -472,7 +473,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('returns current spacing value', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -485,7 +486,7 @@ describe('TypographyControlManager - Unit Tests', () => {
it('returns default value when control is not found', () => {
// Create a manager with empty configs (no controls)
const manager = new TypographyControlManager([], mockPersistentStore);
const manager = new TypographySettingsManager([], mockPersistentStore);
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
@@ -503,7 +504,7 @@ describe('TypographyControlManager - Unit Tests', () => {
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -536,7 +537,7 @@ describe('TypographyControlManager - Unit Tests', () => {
clear: clearSpy,
};
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -547,7 +548,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('respects multiplier when resetting font size control', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -565,7 +566,7 @@ describe('TypographyControlManager - Unit Tests', () => {
describe('Complex Scenarios', () => {
it('handles changing multiplier then modifying baseSize', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -586,7 +587,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('maintains correct renderedSize throughout changes', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -608,7 +609,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('handles multiple control changes in sequence', async () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -633,7 +634,7 @@ describe('TypographyControlManager - Unit Tests', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -645,7 +646,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('handles very small multiplier', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -658,7 +659,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('handles large base size with multiplier', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -671,7 +672,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('handles floating point precision in multiplier', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -690,7 +691,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('handles control methods (increase/decrease)', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
@@ -704,7 +705,7 @@ describe('TypographyControlManager - Unit Tests', () => {
});
it('handles control boundary conditions', () => {
const manager = new TypographyControlManager(
const manager = new TypographySettingsManager(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
+1 -24
View File
@@ -1,24 +1 @@
export {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from './const/const';
export {
type ControlId,
controlManager,
} from './state/manager.svelte';
export { typographySettingsStore } from './state/typographySettingsStore';
@@ -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);
@@ -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',
);
@@ -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>
@@ -1,13 +1,16 @@
<!--
Component: TypographyMenu
Floating controls bar for typography settings.
Warm surface, sharp corners, Settings icon header, dividers between units.
Mobile: popover with slider controls anchored to settings button.
Desktop: inline bar with combo controls.
-->
<script lang="ts">
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
Button,
ComboControl,
@@ -17,15 +20,11 @@ import {
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import XIcon from '@lucide/svelte/icons/x';
import { Popover } from 'bits-ui';
import clsx from 'clsx';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
controlManager,
} from '../../model';
import { typographySettingsStore } from '../../model';
interface Props {
/**
@@ -37,67 +36,62 @@ interface Props {
* @default false
*/
hidden?: boolean;
/**
* Bindable popover open state
* @default false
*/
open?: boolean;
}
const { class: className, hidden = false }: Props = $props();
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
let isOpen = $state(false);
/**
* Sets the common font size multiplier based on the current responsive state.
*/
$effect(() => {
if (!responsive) return;
if (!responsive) {
return;
}
switch (true) {
case responsive.isMobile:
controlManager.multiplier = MULTIPLIER_S;
typographySettingsStore.multiplier = MULTIPLIER_S;
break;
case responsive.isTablet:
controlManager.multiplier = MULTIPLIER_M;
typographySettingsStore.multiplier = MULTIPLIER_M;
break;
case responsive.isDesktop:
controlManager.multiplier = MULTIPLIER_L;
typographySettingsStore.multiplier = MULTIPLIER_L;
break;
default:
controlManager.multiplier = MULTIPLIER_L;
typographySettingsStore.multiplier = MULTIPLIER_L;
}
});
</script>
{#if !hidden}
{#if responsive.isMobile}
<Popover.Root bind:open={isOpen}>
{#if responsive.isMobileOrTablet}
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<button
{...props}
class={cn(
'inline-flex items-center justify-center',
'size-8 p-0',
'border border-transparent rounded-none',
'transition-colors duration-150',
'hover:bg-white/50 dark:hover:bg-white/5',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
isOpen && 'bg-paper dark:bg-dark-card border-black/5 dark:border-white/10 shadow-sm',
className,
)}
>
<Settings2Icon class="size-4" />
</button>
<Button class={className} variant="primary" {...props}>
{#snippet icon()}
<Settings2Icon class="size-4" />
{/snippet}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="top"
align="start"
align="end"
sideOffset={8}
class={cn(
class={clsx(
'z-50 w-72',
'bg-surface dark:bg-dark-card',
'border border-black/5 dark:border-white/10',
'border border-subtle',
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
'rounded-none p-4',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
@@ -110,11 +104,11 @@ $effect(() => {
escapeKeydownBehavior="close"
>
<!-- Header -->
<div class="flex items-center justify-between mb-3 pb-3 border-b border-black/5 dark:border-white/10">
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
<div class="flex items-center gap-1.5">
<Settings2Icon size={12} class="text-swiss-red" />
<span
class="text-[0.5625rem] font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
class="text-3xs font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
>
CONTROLS
</span>
@@ -133,7 +127,7 @@ $effect(() => {
</div>
<!-- Controls -->
{#each controlManager.controls as control (control.id)}
{#each typographySettingsStore.controls as control (control.id)}
<ControlGroup label={control.controlLabel ?? ''}>
<Slider
bind:value={control.instance.value}
@@ -148,33 +142,33 @@ $effect(() => {
</Popover.Root>
{:else}
<div
class={cn('w-full md:w-auto', className)}
class={clsx('w-full md:w-auto', className)}
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
>
<div
class={cn(
class={clsx(
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
'border border-black/5 dark:border-white/10',
'border border-subtle',
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
)}
>
<!-- Header: icon + label -->
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-black/5 dark:border-white/10 mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-subtle mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
<Settings2Icon
size={14}
class="text-swiss-red"
/>
<span
class="text-[0.5625rem] md:text-[0.625rem] font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
class="text-3xs md:text-2xs font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
>
GLOBAL_CONTROLS
</span>
</div>
<!-- Controls with dividers between each -->
{#each controlManager.controls as control, i (control.id)}
{#each typographySettingsStore.controls as control, i (control.id)}
{#if i > 0}
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
{/if}
+6
View File
@@ -1,3 +1,9 @@
/**
* Application entry point
*
* Mounts the main App component to the DOM and initializes
* global styles.
*/
import App from '$app/App.svelte';
import { mount } from 'svelte';
import '$app/styles/app.css';
+1 -8
View File
@@ -3,10 +3,7 @@
Description: The main page component of the application.
-->
<script lang="ts">
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
import { ComparisonView } from '$widgets/ComparisonView';
import { FontSearchSection } from '$widgets/FontSearch';
import { SampleListSection } from '$widgets/SampleList';
import { cubicIn } from 'svelte/easing';
import { fade } from 'svelte/transition';
</script>
@@ -15,11 +12,7 @@ import { fade } from 'svelte/transition';
class="h-full flex flex-col gap-3 sm:gap-4"
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
>
<section class="w-auto">
<main class="w-auto">
<ComparisonView />
</section>
<main class="w-full pt-0 pb-10 sm:px-6 sm:pt-16 sm:pb-12 md:px-8 md:pt-32 md:pb-16 lg:px-10 lg:pt-48 lg:pb-20 xl:px-16">
<FontSearchSection />
<SampleListSection index={1} />
</main>
</div>
+6 -2
View File
@@ -41,10 +41,14 @@ export class ApiError extends Error {
* @param response - Original fetch Response object
*/
constructor(
/** HTTP status code */
/**
* HTTP status code
*/
public status: number,
message: string,
/** Original Response object for inspection */
/**
* Original Response object for inspection
*/
public response?: Response,
) {
super(message);
+15 -5
View File
@@ -15,15 +15,25 @@ import { QueryClient } from '@tanstack/query-core';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
/** Data remains fresh for 5 minutes after fetch */
/**
* Data remains fresh for 5 minutes after fetch
*/
staleTime: 5 * 60 * 1000,
/** Unused cache entries are removed after 10 minutes */
/**
* Unused cache entries are removed after 10 minutes
*/
gcTime: 10 * 60 * 1000,
/** Don't refetch when window regains focus */
/**
* Don't refetch when window regains focus
*/
refetchOnWindowFocus: false,
/** Refetch on mount if data is stale */
/**
* Refetch on mount if data is stale
*/
refetchOnMount: true,
/** Retry failed requests up to 3 times */
/**
* Retry failed requests up to 3 times
*/
retry: 3,
/**
* Exponential backoff for retries
+73
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());
});
});
});
+37
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;
@@ -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();
}
}
@@ -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();
});
});
});
@@ -0,0 +1,345 @@
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 = '';
#lastSpacing = 0;
#lastSize = 0;
// Cached layout results
#lastWidth = -1;
#lastLineHeight = -1;
#lastResult = $state<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).
* @param spacing Letter spacing in em (from typography settings).
* @param size Current font size in pixels (used to convert spacing em to px).
* @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,
spacing: number = 0,
size: number = 16,
): ComparisonResult {
if (!text) {
return { lines: [], totalHeight: 0 };
}
const spacingPx = spacing * size;
const isFontChange = text !== this.#lastText
|| fontA !== this.#lastFontA
|| fontB !== this.#lastFontB
|| spacing !== this.#lastSpacing
|| size !== this.#lastSize;
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, spacingPx);
this.#lastText = text;
this.#lastFontA = fontA;
this.#lastFontB = fontB;
this.#lastSpacing = spacing;
this.#lastSize = size;
}
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;
}
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];
let wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
let wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
// Apply letter spacing (tracking) to the width of each character
wA += spacingPx;
wB += spacingPx;
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 states for an entire line in a single sequential pass.
*
* Walks characters left-to-right, accumulating the running x position using
* each character's actual rendered width: `widthB` for already-morphed characters
* (isPast=true) and `widthA` for upcoming ones. This ensures thresholds stay
* aligned with the visual DOM layout even when the two fonts have different widths.
*
* @param line A single laid-out line from the last layout result.
* @param sliderPos Current slider position as a percentage (0100) of `containerWidth`.
* @param containerWidth Total container width in pixels.
* @returns Per-character `proximity` and `isPast` in the same order as `line.chars`.
*/
getLineCharStates(
line: ComparisonLine,
sliderPos: number,
containerWidth: number,
): Array<{ proximity: number; isPast: boolean }> {
if (!line) {
return [];
}
const chars = line.chars;
const n = chars.length;
const sliderX = (sliderPos / 100) * containerWidth;
const range = 5;
// Prefix sums of widthA (left chars will be past → use widthA).
// Suffix sums of widthB (right chars will not be past → use widthB).
// This lets us compute, for each char i, what the total line width and
// char center would be at the exact moment the slider crosses that char:
// left side (0..i-1) already past → font A widths
// right side (i+1..n-1) not yet past → font B widths
const prefA = new Float64Array(n + 1);
const sufB = new Float64Array(n + 1);
for (let i = 0; i < n; i++) {
prefA[i + 1] = prefA[i] + chars[i].widthA;
}
for (let i = n - 1; i >= 0; i--) {
sufB[i] = sufB[i + 1] + chars[i].widthB;
}
// Per-char threshold: slider x at which this char should toggle isPast.
const thresholds = new Float64Array(n);
for (let i = 0; i < n; i++) {
const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1];
const xOffset = (containerWidth - totalWidth) / 2;
thresholds[i] = xOffset + prefA[i] + chars[i].widthA / 2;
}
// Determine isPast for each char at the current slider position.
const isPastArr = new Uint8Array(n);
for (let i = 0; i < n; i++) {
isPastArr[i] = sliderX > thresholds[i] ? 1 : 0;
}
// Compute visual positions based on actual rendered widths (font A if past, B if not).
const totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0);
const xOffset = (containerWidth - totalRendered) / 2;
let currentX = xOffset;
return chars.map((char, i) => {
const isPast = isPastArr[i] === 1;
const charWidth = isPast ? char.widthA : char.widthB;
const visualCenter = currentX + charWidth / 2;
const charGlobalPercent = (visualCenter / containerWidth) * 100;
const distance = Math.abs(sliderPos - charGlobalPercent);
const proximity = Math.max(0, 1 - distance / range);
currentX += charWidth;
return { proximity, isPast };
});
}
/**
* Internal helper to merge two prepared texts into a "worst-case" unified version
*/
#createUnifiedPrepared(
a: PreparedTextWithSegments,
b: PreparedTextWithSegments,
spacingPx: number = 0,
): 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]) + spacingPx);
unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) =>
Math.max(w, intB.lineEndFitAdvances[i]) + spacingPx
);
unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) =>
Math.max(w, intB.lineEndPaintAdvances[i]) + spacingPx
);
unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => {
const advB = intB.breakableFitAdvances[i];
if (!advA && !advB) {
return null;
}
if (!advA) {
return advB.map((w: number) => w + spacingPx);
}
if (!advB) {
return advA.map((w: number) => w + spacingPx);
}
return advA.map((w: number, j: number) => Math.max(w, advB[j]) + spacingPx);
});
return unified;
}
}
@@ -0,0 +1,160 @@
// @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('getLineCharStates returns proximity 1 when slider is exactly over char center', () => {
const containerWidth = 500;
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
// Single char: no neighbors → totalWidth = widthA, threshold = containerWidth/2.
// When isPast=false, visual center = (containerWidth - widthB)/2 + widthB/2 = containerWidth/2.
// So proximity=1 at exactly 50%.
const charPercent = 50;
const states = engine.getLineCharStates(result.lines[0], charPercent, containerWidth);
expect(states[0]?.proximity).toBe(1);
expect(states[0]?.isPast).toBe(false);
});
it('getLineCharStates returns proximity 0 when slider is far from char', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const states = engine.getLineCharStates(result.lines[0], 0, 500);
expect(states[0]?.proximity).toBe(0);
});
it('getLineCharStates isPast is true when slider has passed char center', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const states = engine.getLineCharStates(result.lines[0], 100, 500);
expect(states[0]?.isPast).toBe(true);
});
it('getLineCharStates returns empty array for out-of-range lineIndex', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
// Passing an undefined object because the index doesn't exist.
const states = engine.getLineCharStates(result.lines[99], 50, 500);
expect(states).toEqual([]);
});
it('getLineCharStates returns empty array before layout() has been called', () => {
// Passing an undefined object because layout() hasn't been called.
const states = engine.getLineCharStates(undefined as any, 50, 500);
expect(states).toEqual([]);
});
it('getLineCharStates returns safe defaults for all chars', () => {
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const states = engine.getLineCharStates(result.lines[0], 50, 500);
expect(states.length).toBeGreaterThan(0);
for (const s of states) {
expect(s.proximity).toBeGreaterThanOrEqual(0);
expect(s.proximity).toBeLessThanOrEqual(1);
expect(typeof s.isPast).toBe('boolean');
}
});
});
@@ -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,
};
}
}
@@ -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);
});
});
@@ -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),
});
}
@@ -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>;
@@ -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);
});
});
});
@@ -32,7 +32,9 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
}, wait);
return {
/** Current value with immediate updates (for UI binding) */
/**
* Current value with immediate updates (for UI binding)
*/
get immediate() {
return immediate;
},
@@ -41,7 +43,9 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
// Manually trigger the debounce on write
updateDebounced(value);
},
/** Current value with debounced updates (for logic/operations) */
/**
* Current value with debounced updates (for logic/operations)
*/
get debounced() {
return debounced;
},
@@ -28,7 +28,9 @@ import { SvelteMap } from 'svelte/reactivity';
* Base entity interface requiring an ID field
*/
export interface Entity {
/** Unique identifier for the entity */
/**
* Unique identifier for the entity
*/
id: string;
}
@@ -39,7 +41,9 @@ export interface Entity {
* triggers updates when entities are added, removed, or modified.
*/
export class EntityStore<T extends Entity> {
/** Reactive map of entities keyed by ID */
/**
* Reactive map of entities keyed by ID
*/
#entities = new SvelteMap<string, T>();
/**
@@ -29,13 +29,21 @@
* @template TValue - The type of the property value (typically string)
*/
export interface Property<TValue extends string> {
/** Unique identifier for the property */
/**
* Unique string identifier for the filterable property
*/
id: string;
/** Human-readable display name */
/**
* Human-readable label for UI display
*/
name: string;
/** Underlying value for filtering logic */
/**
* Underlying machine-readable value used for filtering logic
*/
value: TValue;
/** Whether the property is currently selected */
/**
* Current selection status (reactive)
*/
selected?: boolean;
}
@@ -45,7 +53,9 @@ export interface Property<TValue extends string> {
* @template TValue - The type of property values
*/
export interface FilterModel<TValue extends string> {
/** Array of filterable properties */
/**
* Collection of properties that can be toggled in this filter
*/
properties: Property<TValue>[];
}
@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import {
afterEach,
beforeEach,
@@ -32,19 +32,33 @@ import { Spring } from 'svelte/motion';
* Configuration options for perspective effects
*/
export interface PerspectiveConfig {
/** Z-axis translation per level in pixels */
/**
* Z-axis translation per level in pixels
*/
depthStep?: number;
/** Scale reduction per level (0-1) */
/**
* Scale reduction per level (0-1)
*/
scaleStep?: number;
/** Blur amount per level in pixels */
/**
* Blur amount per level in pixels
*/
blurStep?: number;
/** Opacity reduction per level (0-1) */
/**
* Opacity reduction per level (0-1)
*/
opacityStep?: number;
/** Parallax movement intensity per level */
/**
* Parallax movement intensity per level
*/
parallaxIntensity?: number;
/** Horizontal offset - positive for right, negative for left */
/**
* Horizontal offset - positive for right, negative for left
*/
horizontalOffset?: number;
/** Layout mode: 'center' for centered, 'split' for side-by-side */
/**
* Layout mode: 'center' for centered, 'split' for side-by-side
*/
layoutMode?: 'center' | 'split';
}
@@ -39,15 +39,25 @@
* Customize to match your design system's breakpoints.
*/
export interface Breakpoints {
/** Mobile devices - default 640px */
/**
* Mobile devices - default 640px
*/
mobile: number;
/** Tablet portrait - default 768px */
/**
* Tablet portrait - default 768px
*/
tabletPortrait: number;
/** Tablet landscape - default 1024px */
/**
* Tablet landscape - default 1024px
*/
tablet: number;
/** Desktop - default 1280px */
/**
* Desktop - default 1280px
*/
desktop: number;
/** Large desktop - default 1536px */
/**
* Large desktop - default 1536px
*/
desktopLarge: number;
}
@@ -140,7 +150,9 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
* @returns Cleanup function to remove listeners
*/
function init() {
if (typeof window === 'undefined') return;
if (typeof window === 'undefined') {
return;
}
const handleResize = () => {
width = window.innerWidth;
@@ -206,66 +218,108 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
);
return {
/** Viewport width in pixels */
/**
* Current viewport width in pixels (reactive)
*/
get width() {
return width;
},
/** Viewport height in pixels */
/**
* Current viewport height in pixels (reactive)
*/
get height() {
return height;
},
// Standard breakpoints
/**
* True if viewport width is below the mobile threshold
*/
get isMobile() {
return isMobile;
},
/**
* True if viewport width is between mobile and tablet portrait thresholds
*/
get isTabletPortrait() {
return isTabletPortrait;
},
/**
* True if viewport width is between tablet portrait and desktop thresholds
*/
get isTablet() {
return isTablet;
},
/**
* True if viewport width is between desktop and large desktop thresholds
*/
get isDesktop() {
return isDesktop;
},
/**
* True if viewport width is at or above the large desktop threshold
*/
get isDesktopLarge() {
return isDesktopLarge;
},
// Convenience groupings
/**
* True if viewport width is below the desktop threshold
*/
get isMobileOrTablet() {
return isMobileOrTablet;
},
/**
* True if viewport width is at or above the tablet portrait threshold
*/
get isTabletOrDesktop() {
return isTabletOrDesktop;
},
// Orientation
/**
* Current screen orientation (portrait | landscape)
*/
get orientation() {
return orientation;
},
/**
* True if screen height is greater than width
*/
get isPortrait() {
return isPortrait;
},
/**
* True if screen width is greater than height
*/
get isLandscape() {
return isLandscape;
},
// Device capabilities
/**
* True if the device supports touch interaction
*/
get isTouchDevice() {
return isTouchDevice;
},
// Current breakpoint
/**
* Name of the currently active breakpoint (reactive)
*/
get currentBreakpoint() {
return currentBreakpoint;
},
// Methods
/**
* Initialization function to start event listeners
*/
init,
/**
* Helper to check for custom width ranges
*/
matches,
// Breakpoint values (for custom logic)
/**
* Underlying breakpoint pixel values
*/
breakpoints,
};
}
@@ -34,13 +34,21 @@ import {
* Defines the bounds and stepping behavior for a control
*/
export interface ControlDataModel {
/** Current numeric value */
/**
* Initial or current numeric value
*/
value: number;
/** Minimum allowed value (inclusive) */
/**
* Lower inclusive bound
*/
min: number;
/** Maximum allowed value (inclusive) */
/**
* Upper inclusive bound
*/
max: number;
/** Step size for increment/decrement operations */
/**
* Precision for increment/decrement operations
*/
step: number;
}
@@ -50,13 +58,21 @@ export interface ControlDataModel {
* @template T - Type for the control identifier
*/
export interface ControlModel<T extends string = string> extends ControlDataModel {
/** Unique identifier for the control */
/**
* Unique string identifier for the control
*/
id: T;
/** ARIA label for the increase button */
/**
* Label used by screen readers for the increase button
*/
increaseLabel?: string;
/** ARIA label for the decrease button */
/**
* Label used by screen readers for the decrease button
*/
decreaseLabel?: string;
/** ARIA label for the control area */
/**
* Overall label describing the control's purpose
*/
controlLabel?: string;
}
@@ -109,8 +125,7 @@ export function createTypographyControl<T extends ControlDataModel>(
return {
/**
* Current control value (getter/setter)
* Setting automatically clamps to bounds and rounds to step precision
* Clamped and rounded control value (reactive)
*/
get value() {
return value;
@@ -122,27 +137,37 @@ export function createTypographyControl<T extends ControlDataModel>(
}
},
/** Maximum allowed value */
/**
* Upper limit for the control value
*/
get max() {
return max;
},
/** Minimum allowed value */
/**
* Lower limit for the control value
*/
get min() {
return min;
},
/** Step increment size */
/**
* Configured step increment
*/
get step() {
return step;
},
/** Whether the value is at or exceeds the maximum */
/**
* True if current value is equal to or greater than max
*/
get isAtMax() {
return isAtMax;
},
/** Whether the value is at or below the minimum */
/**
* True if current value is equal to or less than min
*/
get isAtMin() {
return isAtMin;
},
@@ -45,14 +45,27 @@ export interface VirtualItem {
* Options are reactive - pass them through a function getter to enable updates.
*/
export interface VirtualizerOptions {
/** Total number of items in the data array */
/**
* Total number of items in the underlying data array
*/
count: number;
/**
* Function to estimate the size of an item at a given index.
* Used for initial layout before actual measurements are available.
*
* Called inside a `$derived.by` block. Any `$state` or `$derived` value
* read within this function is automatically tracked as a dependency
* when those values change, `offsets` and `totalSize` recompute instantly.
*
* For font preview rows, pass a closure that reads
* `appliedFontsManager.statuses` so the virtualizer recalculates heights
* as fonts finish loading, eliminating the DOM-measurement snap on load.
*/
estimateSize: (index: number) => number;
/** Number of extra items to render outside viewport for smoother scrolling (default: 5) */
/**
* Number of extra items to render outside viewport for smoother scrolling
* @default 5
*/
overscan?: number;
/**
* Function to get the key of an item at a given index.
@@ -71,6 +84,18 @@ export interface VirtualizerOptions {
useWindowScroll?: boolean;
}
/**
* A height resolver for a single virtual-list row.
*
* When this function reads reactive state (e.g. `SvelteMap.get()`), calling
* it inside a `$derived.by` block automatically subscribes to that state.
* Return `fallbackHeight` whenever the true height is not yet known.
*
* @param rowIndex Zero-based row index within the data array.
* @returns Row height in pixels, excluding the list gap.
*/
export type ItemSizeResolver = (rowIndex: number) => number;
/**
* Creates a reactive virtualizer for efficiently rendering large lists by only rendering visible items.
*
@@ -150,7 +175,9 @@ export function createVirtualizer<T>(
const { count, data } = options;
// Implicit dependency
const v = _version;
if (count === 0 || containerHeight === 0 || !data) return [];
if (count === 0 || containerHeight === 0 || !data) {
return [];
}
const overscan = options.overscan ?? 5;
@@ -239,7 +266,9 @@ export function createVirtualizer<T>(
containerHeight = window.innerHeight;
const handleScroll = () => {
if (rafId !== null) return;
if (rafId !== null) {
return;
}
rafId = requestAnimationFrame(() => {
// Get current position of element relative to viewport
@@ -298,7 +327,9 @@ export function createVirtualizer<T>(
};
const resizeObserver = new ResizeObserver(([entry]) => {
if (entry) containerHeight = entry.contentRect.height;
if (entry) {
containerHeight = entry.contentRect.height;
}
});
node.addEventListener('scroll', handleScroll, { passive: true });
@@ -398,7 +429,9 @@ export function createVirtualizer<T>(
* ```
*/
function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') {
if (!elementRef || index < 0 || index >= options.count) return;
if (!elementRef || index < 0 || index >= options.count) {
return;
}
const itemStart = offsets[index];
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
@@ -406,16 +439,24 @@ export function createVirtualizer<T>(
const { useWindowScroll } = optionsGetter();
if (useWindowScroll) {
if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2;
if (align === 'end') target = itemStart - window.innerHeight + itemSize;
if (align === 'center') {
target = itemStart - window.innerHeight / 2 + itemSize / 2;
}
if (align === 'end') {
target = itemStart - window.innerHeight + itemSize;
}
// Add container offset to target to get absolute document position
const absoluteTarget = target + elementOffsetTop;
window.scrollTo({ top: absoluteTarget, behavior: 'smooth' });
} else {
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
if (align === 'end') target = itemStart - containerHeight + itemSize;
if (align === 'center') {
target = itemStart - containerHeight / 2 + itemSize / 2;
}
if (align === 'end') {
target = itemStart - containerHeight + itemSize;
}
elementRef.scrollTo({ top: target, behavior: 'smooth' });
}
@@ -444,27 +485,45 @@ export function createVirtualizer<T>(
}
return {
/**
* Current vertical scroll position in pixels (reactive)
*/
get scrollOffset() {
return scrollOffset;
},
/**
* Measured height of the visible container area (reactive)
*/
get containerHeight() {
return containerHeight;
},
/** Computed array of visible items to render (reactive) */
/**
* Computed array of visible items to render (reactive)
*/
get items() {
return items;
},
/** Total height of all items in pixels (reactive) */
/**
* Total height of all items in pixels (reactive)
*/
get totalSize() {
return totalSize;
},
/** Svelte action for the scrollable container element */
/**
* Svelte action for the scrollable container element
*/
container,
/** Svelte action for measuring individual item elements */
/**
* Svelte action for measuring individual item elements
*/
measureElement,
/** Programmatic scroll method to scroll to a specific item */
/**
* Programmatic scroll method to scroll to a specific item
*/
scrollToIndex,
/** Programmatic scroll method to scroll to a specific pixel offset */
/**
* Programmatic scroll method to scroll to a specific pixel offset
*/
scrollToOffset,
};
}
@@ -1,4 +1,6 @@
/** @vitest-environment jsdom */
/**
* @vitest-environment jsdom
*/
import {
afterEach,
describe,
+131 -6
View File
@@ -22,53 +22,178 @@
* ```
*/
/**
* Filter management
*/
export {
/**
* Reactive filter factory
*/
createFilter,
/**
* Filter instance type
*/
type Filter,
/**
* Initial state model
*/
type FilterModel,
/**
* Filterable property definition
*/
type Property,
} from './createFilter/createFilter.svelte';
/**
* Bounded numeric controls
*/
export {
/**
* Base numeric configuration
*/
type ControlDataModel,
/**
* Extended model with labels
*/
type ControlModel,
/**
* Reactive control factory
*/
createTypographyControl,
/**
* Control instance type
*/
type TypographyControl,
} from './createTypographyControl/createTypographyControl.svelte';
/**
* List virtualization
*/
export {
/**
* Reactive virtualizer factory
*/
createVirtualizer,
/**
* Rendered item layout data
*/
type VirtualItem,
/**
* Virtualizer instance type
*/
type Virtualizer,
/**
* Configuration options
*/
type VirtualizerOptions,
} from './createVirtualizer/createVirtualizer.svelte';
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';
/**
* UI State
*/
export {
/**
* Immediate/debounced state factory
*/
createDebouncedState,
} from './createDebouncedState/createDebouncedState.svelte';
/**
* Entity collections
*/
export {
/**
* Reactive entity store factory
*/
createEntityStore,
/**
* Base entity requirement
*/
type Entity,
/**
* Entity store instance type
*/
type EntityStore,
} from './createEntityStore/createEntityStore.svelte';
/**
* Comparison logic
*/
export {
type CharacterComparison,
createCharacterComparison,
type LineData,
} from './createCharacterComparison/createCharacterComparison.svelte';
/**
* Character-by-character comparison utility
*/
CharacterComparisonEngine,
/**
* Single line of comparison results
*/
type ComparisonLine,
/**
* Full comparison output
*/
type ComparisonResult,
} from './CharacterComparisonEngine/CharacterComparisonEngine.svelte';
/**
* Text layout
*/
export {
/**
* Single line layout information
*/
type LayoutLine as TextLayoutLine,
/**
* Full multi-line layout information
*/
type LayoutResult as TextLayoutResult,
/**
* High-level text measurement engine
*/
TextLayoutEngine,
} from './TextLayoutEngine/TextLayoutEngine.svelte';
/**
* Persistence
*/
export {
/**
* LocalStorage-backed reactive store factory
*/
createPersistentStore,
/**
* Persistent store instance type
*/
type PersistentStore,
} from './createPersistentStore/createPersistentStore.svelte';
/**
* Responsive design
*/
export {
/**
* Breakpoint tracking factory
*/
createResponsiveManager,
/**
* Responsive manager instance type
*/
type ResponsiveManager,
/**
* Singleton manager for global usage
*/
responsiveManager,
} from './createResponsiveManager/createResponsiveManager.svelte';
/**
* 3D Perspectives
*/
export {
/**
* Motion-aware perspective factory
*/
createPerspectiveManager,
/**
* Perspective manager instance type
*/
type PerspectiveManager,
} from './createPerspectiveManager/createPerspectiveManager.svelte';
+6 -3
View File
@@ -5,10 +5,11 @@
*/
export {
type CharacterComparison,
CharacterComparisonEngine,
type ComparisonLine,
type ComparisonResult,
type ControlDataModel,
type ControlModel,
createCharacterComparison,
createDebouncedState,
createEntityStore,
createFilter,
@@ -21,12 +22,14 @@ export {
type EntityStore,
type Filter,
type FilterModel,
type LineData,
type PersistentStore,
type PerspectiveManager,
type Property,
type ResponsiveManager,
responsiveManager,
TextLayoutEngine,
type TextLayoutLine,
type TextLayoutResult,
type TypographyControl,
type VirtualItem,
type Virtualizer,

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