Compare commits

...

136 Commits

Author SHA1 Message Date
Ilia Mashkov
66dcffa448 chore(storybook): replace viewport with defaultViewport 2026-04-18 11:04:10 +03:00
Ilia Mashkov
cca00fccaa chore(storybook): remove mobile stories and initialWidth prop from stories. The mobile view available throught viewport selector in the header 2026-04-18 11:03:43 +03:00
Ilia Mashkov
af05443763 chore(storybook): purge unused Providers props 2026-04-18 11:02:34 +03:00
Ilia Mashkov
99d92d487f feat(storybook): replace width with maxWidth for StoryStage 2026-04-18 11:01:36 +03:00
Ilia Mashkov
4a907619cc chore(storybook): purge custom viewports from storybook preview 2026-04-18 11:00:32 +03:00
Ilia Mashkov
6c69d7a5b3 test(ComparisonView): cover parts of the widget with tests 2026-04-18 01:19:01 +03:00
Ilia Mashkov
993812de0a test(GetFonts): add tests for Filters component behavior 2026-04-18 01:18:02 +03:00
Ilia Mashkov
67c16530af test(ChangeAppTheme): cover theme switcher component with tests 2026-04-18 01:17:25 +03:00
Ilia Mashkov
fbbb439023 test(Breadcrumb): add test for BreadcrumbHeader component 2026-04-18 01:16:45 +03:00
Ilia Mashkov
c2046770ef test(SampleList): add test coverage for LayoutSwitch component 2026-04-18 01:16:09 +03:00
Ilia Mashkov
adfba38063 test: exclude lucide from dependency optimization 2026-04-18 01:15:25 +03:00
Ilia Mashkov
dfb304d436 test: remove legacy tests and add new ones 2026-04-17 22:16:44 +03:00
Ilia Mashkov
f55043a1e7 test(Badge): cover Baddge with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
409dd1b229 test(Divider): cover Divider with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
9fbce095b2 test(Footnote): cover Footnote with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
171627e0ea test(Input): cover Input with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
d07fb1a3af test(Label): cover Label with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
6f84644ecb test(Loader): cover Loader with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
5ab5cda611 test(SearchBar): cover SearchBar with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
7975d9aeee test(Skeleton): cover Skeleton with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov
2ba5fc0e3e test(Slider): cover Slider with tests 2026-04-17 20:24:09 +03:00
Ilia Mashkov
1947d7731e test(Stat): cover Stat with tests 2026-04-17 20:09:59 +03:00
Ilia Mashkov
38bfc4ba4b test(TechTech): cover TextTech with tests 2026-04-17 20:09:41 +03:00
Ilia Mashkov
6cf3047b74 test(Button): cover Button with tests 2026-04-17 19:20:13 +03:00
Ilia Mashkov
81363156d7 feat: set up vitest browser config for svelte components tests 2026-04-17 18:52:37 +03:00
Ilia Mashkov
bb65f1c8d6 feat: add missing storybook files and type template arguments properly 2026-04-17 18:01:24 +03:00
Ilia Mashkov
5eb9584797 feat(TypographyMenu): add bindable "open" prop to close popover from outside 2026-04-17 16:30:41 +03:00
Ilia Mashkov
bb5c3667b4 feat(SliderArea): utilize responsive breakpoints for TypographyMenu positioning 2026-04-17 14:39:25 +03:00
Ilia Mashkov
3711616a91 feat(TypograpyMenu): change custom button for existed Button component 2026-04-17 14:31:57 +03:00
Ilia Mashkov
6905c54040 chore: edit comments 2026-04-17 14:30:30 +03:00
Ilia Mashkov
1e8e22e2eb fix: edit tailwind variable name 2026-04-17 13:56:43 +03:00
Ilia Mashkov
8a93c7b545 chore: purge shadcn from codebase. Replace with bits-ui components and other tools 2026-04-17 13:37:44 +03:00
Ilia Mashkov
0004b81e40 chore(ComboControl): replace shadcn tooltip with the one from bits-ui 2026-04-17 13:20:47 +03:00
Ilia Mashkov
fb1d2765d0 chore: purge TooltipProvider 2026-04-17 13:20:01 +03:00
Ilia Mashkov
12e8bc0a89 chore: enforce brackets for if clause and for/while loops 2026-04-17 13:05:36 +03:00
Ilia Mashkov
cfaff46d59 chore: follow the general comments style 2026-04-17 12:14:55 +03:00
Ilia Mashkov
0ebf75b24e refactor: replace arbitrary text sizes in FontSampler, TypographyMenu; fix font token in SectionTitle 2026-04-17 09:42:24 +03:00
Ilia Mashkov
7b46e06f8b refactor: replace arbitrary text sizes in ComboControl, ControlGroup, Input, Slider, SectionHeader 2026-04-17 09:41:55 +03:00
Ilia Mashkov
0737db69a9 refactor: replace px text sizes in Button, Loader, Footnote with named tokens 2026-04-17 09:41:14 +03:00
Ilia Mashkov
64b4a65e7b refactor: replace arbitrary sizes in labelSizeConfig with named tokens 2026-04-17 09:40:53 +03:00
Ilia Mashkov
7f0d2b54e0 feat: add micro type scale and tracking-wider-mono tokens to @theme 2026-04-17 09:40:42 +03:00
Ilia Mashkov
5b1a1d0b0a fix: use Button's size prop instead of direct font-size class 2026-04-17 08:56:46 +03:00
Ilia Mashkov
0562b94b03 feat(Label): add font prop to purge custom classes 2026-04-17 08:55:38 +03:00
Ilia Mashkov
ef08512986 feat(Badge): add nowrap prop to purge custom classes 2026-04-17 08:54:29 +03:00
Ilia Mashkov
816d4b89ce refactor: tailwind tier 1 — border-subtle/text-secondary/focus-ring utilities + Input config extraction 2026-04-16 16:32:41 +03:00
Ilia Mashkov
aa1379c15b chore: remove unused code 2026-04-16 15:59:58 +03:00
Ilia Mashkov
33e589f041 feat: remove widgets from page 2026-04-16 15:58:33 +03:00
Ilia Mashkov
b12dc6257d feat(ComparisonView): add wrapper for search bar 2026-04-16 15:58:10 +03:00
Ilia Mashkov
35e0f06a77 feat(ComparisonView): add color transition for each character 2026-04-16 15:55:57 +03:00
Ilia Mashkov
dde187e0b2 chore: move ControlId type to the entities/Font layer 2026-04-16 11:19:17 +03:00
Ilia Mashkov
5a7c61ade7 feat(FontVirtualList): re-touch on weight change and pin visible fonts 2026-04-16 11:05:09 +03:00
Ilia Mashkov
d2bce85f9c feat(ComparisonStore): pin fontA/fontB to prevent eviction while on-screen 2026-04-16 10:55:41 +03:00
Ilia Mashkov
e509463911 chore: remove unused 2026-04-16 09:07:46 +03:00
Ilia Mashkov
db08f523f6 chore: move typography constants to the entity/Font layer 2026-04-16 09:05:34 +03:00
Ilia Mashkov
c5fa159c14 fix(FontList): remove weight prop, use default weight for FontList 2026-04-16 08:51:18 +03:00
Ilia Mashkov
8645c7dcc8 feat: use typographySettingsStore everywhere for the typography settings 2026-04-16 08:44:49 +03:00
Ilia Mashkov
fbeb84270b feat(Layout): remove breadcrumbs 2026-04-16 08:40:16 +03:00
Ilia Mashkov
c1ac9b5bc4 chore(SetupFont): rename controlManager to typographySettingsStore for better semantic 2026-04-16 08:22:08 +03:00
46d0d887b1 Merge pull request 'feature/unified-tanstack-query' (#36) from feature/unified-tanstack-query into main
All checks were successful
Workflow / build (push) Successful in 45s
Workflow / publish (push) Successful in 47s
Reviewed-on: #36
2026-04-16 04:53:28 +00:00
Ilia Mashkov
0a489a8adc fix(BaseQueryStore): use QueryObserverOptions instead of QueryOptions
All checks were successful
Workflow / build (pull_request) Successful in 58s
Workflow / publish (pull_request) Has been skipped
QueryOptions has queryKey as optional; QueryObserverOptions requires it,
matching what QueryObserver.constructor and setOptions actually expect.
2026-04-15 22:37:30 +03:00
Ilia Mashkov
cd349aec92 fix: imports 2026-04-15 22:32:45 +03:00
Ilia Mashkov
adaa6d7648 feat: refactor ComparisonStore to use BatchFontStore
Replace hand-rolled async fetching (fetchFontsByIds + isRestoring flag)
with BatchFontStore backed by TanStack Query. Three reactive effects
handle batch sync, CSS font loading, and default-font fallback.
isLoading now derives from batchStore.isLoading + fontsReady.
2026-04-15 22:25:34 +03:00
Ilia Mashkov
10f4781a67 test: enrich coverage for queryKeys, BaseQueryStore, and BatchFontStore
- queryKeys: add mutation-safety test for batch(), key hierarchy tests
  (list/batch/detail keys rooted in their parent base keys), and
  unique-key test for different detail IDs
- BaseQueryStore: add initial-state test (data undefined, isError false
  before any fetch resolves)
- BatchFontStore: add FontResponseError type assertion on malformed
  response, null error assertion on success, and setIds([]) disables
  query and returns empty fonts without triggering a fetch
2026-04-15 15:59:01 +03:00
Ilia Mashkov
f4a568832a feat: implement reactive BatchFontStore 2026-04-15 12:29:16 +03:00
Ilia Mashkov
4e9670118a feat: add seedFontCache utility 2026-04-15 12:21:04 +03:00
Ilia Mashkov
8e88d1b7cf feat: add BaseQueryStore for reactive query observers 2026-04-15 12:19:25 +03:00
Ilia Mashkov
1cbc262af7 feat: add stable query key factory 2026-04-15 12:06:32 +03:00
f072c5b270 Merge pull request 'fix/initial-fonts-loading' (#35) from fix/initial-fonts-loading into main
All checks were successful
Workflow / build (push) Successful in 46s
Workflow / publish (push) Successful in 45s
Reviewed-on: #35
2026-04-15 08:37:40 +00:00
Ilia Mashkov
bfa99cde20 fix(comparisonStore): add missing batch request and effect for initial font loading
All checks were successful
Workflow / build (pull_request) Successful in 3m8s
Workflow / publish (pull_request) Has been skipped
2026-04-15 11:35:37 +03:00
Ilia Mashkov
75b62265be fix: add missing export 2026-04-15 09:13:22 +03:00
5b81be6614 Merge pull request 'feature/pretext' (#34) from feature/pretext into main
Some checks failed
Workflow / build (push) Failing after 36s
Workflow / publish (push) Has been skipped
Reviewed-on: #34
2026-04-14 07:12:41 +00:00
Ilia Mashkov
a74abbb0b3 feat: wire createFontRowSizeResolver into SampleList for pretext-backed row heights
Some checks failed
Workflow / build (pull_request) Failing after 49s
Workflow / publish (pull_request) Has been skipped
2026-04-13 13:23:03 +03:00
Ilia Mashkov
20accb9c93 feat: implement createFontRowSizeResolver with canvas-measured heights and reactive status check 2026-04-13 08:54:19 +03:00
Ilia Mashkov
46b9db1db3 feat: export ItemSizeResolver type and document reactive estimateSize contract 2026-04-12 19:43:44 +03:00
Ilia Mashkov
4b017a83bb fix: add missing JSDoc, return types, and as-any comments to layout engines 2026-04-12 09:51:36 +03:00
Ilia Mashkov
49822f8af7 feat: install pretext library 2026-04-12 09:08:01 +03:00
Ilia Mashkov
338ca9b4fd feat: export TextLayoutEngine and CharacterComparisonEngine from shared helpers index
Remove deleted createCharacterComparison exports and benchmark.
2026-04-11 16:44:49 +03:00
Ilia Mashkov
99f662e2d5 fix: iterate pre-computed chars array in Line.svelte to fix unicode grapheme splitting bug 2026-04-11 16:26:41 +03:00
Ilia Mashkov
5977e0a0dc fix: correct advances null-check in CharacterComparisonEngine and remove unused TextLayoutEngine dep 2026-04-11 16:14:28 +03:00
Ilia Mashkov
2b0d8470e5 test: fix CharacterComparisonEngine tests — correct env directive, canvas mock, and full spec coverage 2026-04-11 16:14:24 +03:00
Ilia Mashkov
351ee9fd52 docs: add inline documentation to TextLayoutEngine 2026-04-11 16:10:01 +03:00
Ilia Mashkov
a526a51af8 test: fix TextLayoutEngine tests — correct jsdom directive placement and canvas mock setup
fix: correct grapheme-width fallback in TextLayoutEngine for null breakableFitAdvances
2026-04-11 15:48:52 +03:00
Ilia Mashkov
fcde78abad test: add canvas mock helper for pretext-based engine tests 2026-04-11 15:48:47 +03:00
26737f2f11 Merge pull request 'chore/purge-unused' (#33) from chore/purge-unused into main
All checks were successful
Workflow / build (push) Successful in 45s
Workflow / publish (push) Successful in 23s
Reviewed-on: #33
2026-04-10 14:31:27 +00:00
Ilia Mashkov
d9fa2bc501 refactor: consolidate font domain and model types into font.ts
All checks were successful
Workflow / build (pull_request) Successful in 59s
Workflow / publish (pull_request) Has been skipped
2026-04-10 17:29:15 +03:00
Ilia Mashkov
5f38996665 chore: purge legacy font provider types and normalization logic 2026-04-10 16:05:57 +03:00
d70fc9f918 Merge pull request 'feat/font-store-merge' (#32) from feat/font-store-merge into main
All checks were successful
Workflow / build (push) Successful in 43s
Workflow / publish (push) Successful in 21s
Reviewed-on: #32
2026-04-10 05:13:39 +00:00
Ilia Mashkov
14dbd374ec refactor: replace unifiedFontStore with fontStore in comparisonStore tests
All checks were successful
Workflow / build (pull_request) Successful in 1m1s
Workflow / publish (pull_request) Has been skipped
2026-04-10 08:06:51 +03:00
Ilia Mashkov
dc6e15492a test: mock fontStore and update FontStore type signatures 2026-04-09 19:40:31 +03:00
Ilia Mashkov
45eac0c396 refactor: delete BaseFontStore and UnifiedFontStore — FontStore is the single implementation 2026-04-08 10:07:36 +03:00
Ilia Mashkov
ed7d31bf5c refactor: migrate all callers from unifiedFontStore to fontStore 2026-04-08 10:00:30 +03:00
Ilia Mashkov
468d2e7f8c feat(FontStore): export through entity barrel files 2026-04-08 09:55:40 +03:00
Ilia Mashkov
2a761b9d47 feat(FontStore): implement lifecycle, param management, async methods, shortcuts, pagination, category getters, singleton — all tests green 2026-04-08 09:54:27 +03:00
Ilia Mashkov
a9e4633b64 feat(FontStore): implement fetchPage with error wrapping 2026-04-08 09:50:16 +03:00
Ilia Mashkov
778988977f feat(FontStore): implement state getters, pagination, buildQueryKey, buildOptions 2026-04-08 09:47:25 +03:00
Ilia Mashkov
9a9ff95bf3 test(FontStore): write full TDD spec and empty shell (InfiniteQueryObserver) 2026-04-08 09:43:29 +03:00
Ilia Mashkov
7517678e87 chore: add .worktrees to .gitignore for isolated development 2026-04-08 09:37:47 +03:00
4281d94d66 Merge pull request 'refactor/code-splitting' (#31) from refactor/code-splitting into main
All checks were successful
Workflow / build (push) Successful in 44s
Workflow / publish (push) Successful in 42s
Reviewed-on: #31
2026-04-08 06:34:19 +00:00
Ilia Mashkov
752e38adf9 test: full test coverage of baseFontStore and unifiedFontStore
All checks were successful
Workflow / build (pull_request) Successful in 55s
Workflow / publish (pull_request) Has been skipped
2026-04-08 09:33:04 +03:00
Ilia Mashkov
9c538069e4 test(UnifiedFontStore): add isEmpty and destroy tests 2026-04-06 12:26:08 +03:00
Ilia Mashkov
71fed58af9 test(UnifiedFontStore): add category getter tests 2026-04-06 12:24:23 +03:00
Ilia Mashkov
fee3355a65 test(UnifiedFontStore): add filter change reset tests 2026-04-06 12:19:49 +03:00
Ilia Mashkov
2ff7f1a13d test(UnifiedFontStore): add filter setter tests 2026-04-06 11:35:56 +03:00
Ilia Mashkov
6bf1b1ea87 test(UnifiedFontStore): add pagination navigation tests 2026-04-06 11:34:53 +03:00
Ilia Mashkov
3ef012eb43 test(UnifiedFontStore): add pagination state tests 2026-04-06 11:34:03 +03:00
Ilia Mashkov
5df60b236c test(UnifiedFontStore): cover fetchFn typed error paths and error getter 2026-04-05 15:20:15 +03:00
Ilia Mashkov
df3c694909 feat(UnifiedFontStore): throw FontNetworkError and FontResponseError in fetchFn 2026-04-05 14:07:26 +03:00
Ilia Mashkov
a1a1fcf39d feat(BaseFontStore): expose error getter 2026-04-05 11:03:00 +03:00
Ilia Mashkov
b40e651be4 refactor(Font/model): move baseFontStore and unifiedFontStore to subdirectories, rename errors/index to errors/errors 2026-04-05 11:02:42 +03:00
Ilia Mashkov
9427f4e50f feat(Font): re-export FontNetworkError and FontResponseError from entity barrel 2026-04-05 09:33:58 +03:00
Ilia Mashkov
ed9791c176 feat(Font/lib): add FontNetworkError and FontResponseError 2026-04-05 09:04:47 +03:00
Ilia Mashkov
c6dabafd93 chore(appliedFontsStore): move FontBufferCache, FontEvicionPolicy and FontLoadQueue to appliedFontsStore/utils 2026-04-05 08:25:05 +03:00
Ilia Mashkov
e88cca9289 test(FontBufferCache): change mock fetcher type 2026-04-04 16:43:54 +03:00
Ilia Mashkov
d4cf6764b4 test(appliedFontsStore): rewrite tests with describe grouping and full coverage 2026-04-04 10:38:20 +03:00
Ilia Mashkov
5a065ae5a1 refactor: extract #fetchChunk, replace Promise.allSettled with self-describing results 2026-04-04 09:58:41 +03:00
Ilia Mashkov
20110168f2 refactor: extract #processFont and #scheduleProcessing from touch and #processQueue 2026-04-04 09:52:45 +03:00
Ilia Mashkov
f88729cc77 fix: guard AbortError from retry counting; eviction policy removes stale keys 2026-04-04 09:40:21 +03:00
Ilia Mashkov
d21de1bf78 chore(appliedFontsStore): use created collaborators classes 2026-04-03 16:09:10 +03:00
Ilia Mashkov
bc4ab58644 fix(buildQueryString): change the way the searchParams built 2026-04-03 16:08:15 +03:00
Ilia Mashkov
37e0c29788 refactor: loadFont throws FontParseError instead of re-throwing raw error 2026-04-03 15:42:08 +03:00
Ilia Mashkov
46ce0f7aab feat: extract FontBufferCache with injectable fetcher 2026-04-03 15:24:14 +03:00
Ilia Mashkov
128f341399 feat: extract FontEvictionPolicy with TTL and pin/unpin 2026-04-03 15:06:01 +03:00
Ilia Mashkov
64b97794a6 feat: extract FontLoadQueue with retry tracking 2026-04-03 15:01:36 +03:00
Ilia Mashkov
d6eb02bb28 feat: add FontFetchError and FontParseError typed errors 2026-04-03 14:44:06 +03:00
Ilia Mashkov
a711e4e12a chore(appliedFontsStore): move generateFontKey into separate function and cover it with tests 2026-04-03 12:50:50 +03:00
Ilia Mashkov
05e4c082ed feat(appliedFontsStore): move font loading logic into loadFont function and cover it with tests 2026-04-03 12:29:48 +03:00
Ilia Mashkov
b602b5022b chore(appliedFontsStore): move the FontLoadRequestConfig type and other types from appliedFontsStore into types directory 2026-04-03 12:25:38 +03:00
Ilia Mashkov
5249d88df7 feat(appliedFontsStore): create separate yieldToMainThread function with proper tests 2026-04-03 11:05:29 +03:00
Ilia Mashkov
e553cf1f10 feat(appliedFontsStore): create separate getEffectiveConcurrency function with proper tests 2026-04-03 11:03:48 +03:00
Ilia Mashkov
0fdded79d7 test: change globals to true to use vitest tools without importing them 2026-04-03 11:02:17 +03:00
8dbfde882f Merge pull request 'fix(appliedFontsStore): solve ttl based fonts purge by adding cache for on-screen fonts' (#30) from fix/ttl-based-purge into main
All checks were successful
Workflow / build (push) Successful in 44s
Workflow / publish (push) Successful in 43s
Reviewed-on: #30
2026-04-03 06:38:00 +00:00
Ilia Mashkov
a6c8b50cea fix(appliedFontsStore): solve ttl based fonts purge by adding cache for on-screen fonts
All checks were successful
Workflow / build (pull_request) Successful in 3m45s
Workflow / publish (pull_request) Has been skipped
2026-04-03 09:35:16 +03:00
Ilia Mashkov
11c4750d0e chore: update gitignore
All checks were successful
Workflow / build (push) Successful in 51s
Workflow / publish (push) Successful in 26s
2026-03-04 16:53:53 +03:00
03917cf947 Merge pull request 'chore: change hex colors to tailwind bariables' (#29) from chore/color-variables into main
Some checks failed
Workflow / publish (push) Has been cancelled
Workflow / build (push) Has been cancelled
Reviewed-on: #29
2026-03-04 13:52:53 +00:00
Ilia Mashkov
9b90080c57 chore: change hex colors to tailwind bariables
All checks were successful
Workflow / build (pull_request) Successful in 3m29s
Workflow / publish (pull_request) Has been skipped
2026-03-04 16:51:49 +03:00
9c6ff3859a Merge pull request 'feature/project-redesign' (#28) from feature/project-redesign into main
All checks were successful
Workflow / build (push) Successful in 51s
Workflow / publish (push) Successful in 49s
Reviewed-on: #28
2026-03-02 19:46:38 +00:00
238 changed files with 8840 additions and 7066 deletions

View File

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

4
.gitignore vendored
View File

@@ -10,6 +10,9 @@ node_modules
/build
/dist
# Git worktrees (isolated development branches)
.worktrees
# OS
.DS_Store
Thumbs.db
@@ -43,3 +46,4 @@ storybook-static
# Tests
coverage/
.aider*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>glyphdiff</title>
<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
</head>
<body>
<div id="app"></div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,110 +1,4 @@
// Proxy API (primary)
export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
} from './api/proxy/proxyFonts';
export type {
ProxyFontsParams,
ProxyFontsResponse,
} from './api/proxy/proxyFonts';
export {
normalizeFontshareFont,
normalizeFontshareFonts,
} from './lib/normalize/normalize';
export type {
// Domain types
FontCategory,
FontCollectionFilters,
FontCollectionSort,
// Store types
FontCollectionState,
FontFeatures,
FontFiles,
FontItem,
FontMetadata,
FontProvider,
// Fontshare API types
FontshareApiModel,
FontshareAxis,
FontshareDesigner,
FontshareFeature,
FontshareFont,
FontshareLink,
FontsharePublisher,
FontshareStyle,
FontshareStyleProperties,
FontshareTag,
FontshareWeight,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
// Normalization types
UnifiedFont,
UnifiedFontVariant,
} from './model';
export {
appliedFontsManager,
createUnifiedFontStore,
unifiedFontStore,
} from './model';
// Mock data helpers for Storybook and testing
export {
createCategoriesFilter,
createErrorState,
createGenericFilter,
createLoadingState,
createMockComparisonStore,
// Filter mocks
createMockFilter,
createMockFontApiResponse,
createMockFontStoreState,
// Store mocks
createMockQueryState,
createMockReactiveState,
createMockStore,
createProvidersFilter,
createSubsetsFilter,
createSuccessState,
FONTHARE_FONTS,
generateMixedCategoryFonts,
generateMockFonts,
generatePaginatedFonts,
generateSequentialFilter,
GENERIC_FILTERS,
getAllMockFonts,
getFontsByCategory,
getFontsByProvider,
GOOGLE_FONTS,
MOCK_FILTERS,
MOCK_FILTERS_ALL_SELECTED,
MOCK_FILTERS_EMPTY,
MOCK_FILTERS_SELECTED,
MOCK_FONT_STORE_STATES,
MOCK_STORES,
type MockFilterOptions,
type MockFilters,
mockFontshareFont,
type MockFontshareFontOptions,
type MockFontStoreState,
// Font mocks
mockGoogleFont,
// Types
type MockGoogleFontOptions,
type MockQueryObserverResult,
type MockQueryState,
mockUnifiedFont,
type MockUnifiedFontOptions,
UNIFIED_FONTS,
} from './lib/mocks';
// UI elements
export {
FontApplicator,
FontVirtualList,
} from './ui';
export * from './api';
export * from './lib';
export * from './model';
export * from './ui';

View File

@@ -0,0 +1,51 @@
import {
FontNetworkError,
FontResponseError,
} from './errors';
describe('FontNetworkError', () => {
it('has correct name', () => {
const err = new FontNetworkError();
expect(err.name).toBe('FontNetworkError');
});
it('is instance of Error', () => {
expect(new FontNetworkError()).toBeInstanceOf(Error);
});
it('stores cause', () => {
const cause = new Error('network down');
const err = new FontNetworkError(cause);
expect(err.cause).toBe(cause);
});
it('has default message', () => {
expect(new FontNetworkError().message).toBe('Failed to fetch fonts from proxy API');
});
});
describe('FontResponseError', () => {
it('has correct name', () => {
const err = new FontResponseError('response', undefined);
expect(err.name).toBe('FontResponseError');
});
it('is instance of Error', () => {
expect(new FontResponseError('response.fonts', null)).toBeInstanceOf(Error);
});
it('stores field', () => {
const err = new FontResponseError('response.fonts', 42);
expect(err.field).toBe('response.fonts');
});
it('stores received value', () => {
const err = new FontResponseError('response.fonts', 42);
expect(err.received).toBe(42);
});
it('message includes field name', () => {
const err = new FontResponseError('response.fonts', null);
expect(err.message).toContain('response.fonts');
});
});

View File

@@ -0,0 +1,28 @@
/**
* Thrown when the network request to the proxy API fails.
* Wraps the underlying fetch error (timeout, DNS failure, connection refused, etc.).
*/
export class FontNetworkError extends Error {
readonly name = 'FontNetworkError';
constructor(public readonly cause?: unknown) {
super('Failed to fetch fonts from proxy API');
}
}
/**
* Thrown when the proxy API returns a response with an unexpected shape.
*
* @property field - The name of the field that failed validation (e.g. `'response'`, `'response.fonts'`).
* @property received - The actual value received at that field, for debugging.
*/
export class FontResponseError extends Error {
readonly name = 'FontResponseError';
constructor(
public readonly field: string,
public readonly received: unknown,
) {
super(`Invalid proxy API response: ${field}`);
}
}

View File

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

View File

@@ -1,10 +1,3 @@
export {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './normalize/normalize';
export { getFontUrl } from './getFontUrl/getFontUrl';
// Mock data helpers for Storybook and testing
@@ -25,7 +18,6 @@ export {
createProvidersFilter,
createSubsetsFilter,
createSuccessState,
FONTHARE_FONTS,
generateMixedCategoryFonts,
generateMockFonts,
generatePaginatedFonts,
@@ -34,7 +26,6 @@ export {
getAllMockFonts,
getFontsByCategory,
getFontsByProvider,
GOOGLE_FONTS,
MOCK_FILTERS,
MOCK_FILTERS_ALL_SELECTED,
MOCK_FILTERS_EMPTY,
@@ -43,16 +34,20 @@ export {
MOCK_STORES,
type MockFilterOptions,
type MockFilters,
mockFontshareFont,
type MockFontshareFontOptions,
type MockFontStoreState,
// Font mocks
mockGoogleFont,
// Types
type MockGoogleFontOptions,
type MockQueryObserverResult,
type MockQueryState,
mockUnifiedFont,
type MockUnifiedFontOptions,
UNIFIED_FONTS,
} from './mocks';
export {
FontNetworkError,
FontResponseError,
} from './errors/errors';
export { createFontRowSizeResolver } from './sizeResolver/createFontRowSizeResolver';
export type { FontRowSizeResolverOptions } from './sizeResolver/createFontRowSizeResolver';

View File

@@ -1,31 +1,3 @@
/**
* Mock font filter data
*
* Factory functions and preset mock data for font-related filters.
* Used in Storybook stories for font filtering components.
*
* ## Usage
*
* ```ts
* import {
* createMockFilter,
* MOCK_FILTERS,
* } from '$entities/Font/lib/mocks';
*
* // Create a custom filter
* const customFilter = createMockFilter({
* properties: [
* { id: 'option1', name: 'Option 1', value: 'option1' },
* { id: 'option2', name: 'Option 2', value: 'option2', selected: true },
* ],
* });
*
* // Use preset filters
* const categoriesFilter = MOCK_FILTERS.categories;
* const subsetsFilter = MOCK_FILTERS.subsets;
* ```
*/
import type {
FontCategory,
FontProvider,
@@ -34,13 +6,13 @@ import type {
import type { Property } from '$shared/lib';
import { createFilter } from '$shared/lib';
// TYPE DEFINITIONS
/**
* Options for creating a mock filter
*/
export interface MockFilterOptions {
/** Filter properties */
/**
* Initial set of properties for the mock filter
*/
properties: Property<string>[];
}
@@ -48,39 +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
/**
* Google Fonts categories
*/
export const GOOGLE_CATEGORIES: Property<'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'>[] = [
{ id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
{ id: 'serif', name: 'Serif', value: 'serif' },
{ id: 'display', name: 'Display', value: 'display' },
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
];
/**
* Fontshare categories (mapped to common naming)
*/
export const FONTHARE_CATEGORIES: Property<'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script'>[] = [
{ id: 'sans', name: 'Sans', value: 'sans' },
{ id: 'serif', name: 'Serif', value: 'serif' },
{ id: 'slab', name: 'Slab', value: 'slab' },
{ id: 'display', name: 'Display', value: 'display' },
{ id: 'handwritten', name: 'Handwritten', value: 'handwritten' },
{ id: 'script', name: 'Script', value: 'script' },
];
/**
* Unified categories (combines both providers)
*/
@@ -90,10 +43,10 @@ export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
{ id: 'display', name: 'Display', value: 'display' },
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
{ id: 'slab', name: 'Slab', value: 'slab' },
{ id: 'script', name: 'Script', value: 'script' },
];
// FONT SUBSETS
/**
* Common font subsets
*/
@@ -106,8 +59,6 @@ export const FONT_SUBSETS: Property<FontSubset>[] = [
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
];
// FONT PROVIDERS
/**
* Font providers
*/
@@ -116,8 +67,6 @@ export const FONT_PROVIDERS: Property<FontProvider>[] = [
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
];
// FILTER FACTORIES
/**
* Create a mock filter from properties
*/
@@ -160,8 +109,6 @@ export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
return createFilter<FontProvider>({ properties });
}
// PRESET FILTERS
/**
* Preset mock filters - use these directly in stories
*/
@@ -237,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
@@ -260,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' },
@@ -268,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' },
@@ -279,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' },
@@ -296,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 },
@@ -305,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 },
@@ -313,7 +268,9 @@ export const GENERIC_FILTERS = {
{ id: 'bird', name: 'Bird', value: 'bird', selected: true },
],
}),
/** Empty filter (no items) */
/**
* Empty filter (no items)
*/
empty: createFilter({
properties: [],
}),

View File

@@ -38,11 +38,6 @@ import type {
FontSubset,
FontVariant,
} from '$entities/Font/model/types';
import type {
FontItem,
FontshareFont,
GoogleFontItem,
} from '$entities/Font/model/types';
import type {
FontFeatures,
FontMetadata,
@@ -50,374 +45,47 @@ import type {
UnifiedFont,
} from '$entities/Font/model/types';
// GOOGLE FONTS MOCKS
/**
* Options for creating a mock Google Font
*/
export interface MockGoogleFontOptions {
/** Font family name (default: 'Mock Font') */
family?: string;
/** Font category (default: 'sans-serif') */
category?: 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
variants?: FontVariant[];
/** Font subsets (default: ['latin']) */
subsets?: string[];
/** Font version (default: 'v30') */
version?: string;
/** Last modified date (default: current ISO date) */
lastModified?: string;
/** Custom file URLs (if not provided, mock URLs are generated) */
files?: Partial<Record<FontVariant, string>>;
/** Popularity rank (1 = most popular) */
popularity?: number;
}
/**
* Default mock Google Font
*/
export function mockGoogleFont(options: MockGoogleFontOptions = {}): GoogleFontItem {
const {
family = 'Mock Font',
category = 'sans-serif',
variants = ['regular', '700', 'italic', '700italic'],
subsets = ['latin'],
version = 'v30',
lastModified = new Date().toISOString().split('T')[0],
files,
popularity = 1,
} = options;
const baseUrl = `https://fonts.gstatic.com/s/${family.toLowerCase().replace(/\s+/g, '')}/${version}`;
return {
family,
category,
variants: variants as FontVariant[],
subsets,
version,
lastModified,
files: files ?? {
regular: `${baseUrl}/KFOmCnqEu92Fr1Me4W.woff2`,
'700': `${baseUrl}/KFOlCnqEu92Fr1MmWUlfBBc9.woff2`,
italic: `${baseUrl}/KFOkCnqEu92Fr1Mu51xIIzI.woff2`,
'700italic': `${baseUrl}/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2`,
},
menu: `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}`,
};
}
/**
* Preset Google Font mocks
*/
export const GOOGLE_FONTS: Record<string, GoogleFontItem> = {
roboto: mockGoogleFont({
family: 'Roboto',
category: 'sans-serif',
variants: ['100', '300', '400', '500', '700', '900', 'italic', '700italic'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
popularity: 1,
}),
openSans: mockGoogleFont({
family: 'Open Sans',
category: 'sans-serif',
variants: ['300', '400', '500', '600', '700', '800', 'italic', '700italic'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
popularity: 2,
}),
lato: mockGoogleFont({
family: 'Lato',
category: 'sans-serif',
variants: ['100', '300', '400', '700', '900', 'italic', '700italic'],
subsets: ['latin', 'latin-ext'],
popularity: 3,
}),
playfairDisplay: mockGoogleFont({
family: 'Playfair Display',
category: 'serif',
variants: ['400', '500', '600', '700', '800', '900', 'italic', '700italic'],
subsets: ['latin', 'latin-ext', 'cyrillic'],
popularity: 10,
}),
montserrat: mockGoogleFont({
family: 'Montserrat',
category: 'sans-serif',
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic', '700italic'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
popularity: 4,
}),
sourceSansPro: mockGoogleFont({
family: 'Source Sans Pro',
category: 'sans-serif',
variants: ['200', '300', '400', '600', '700', '900', 'italic', '700italic'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
popularity: 5,
}),
merriweather: mockGoogleFont({
family: 'Merriweather',
category: 'serif',
variants: ['300', '400', '700', '900', 'italic', '700italic'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
popularity: 15,
}),
robotoSlab: mockGoogleFont({
family: 'Roboto Slab',
category: 'serif',
variants: ['100', '300', '400', '500', '700', '900'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
popularity: 8,
}),
oswald: mockGoogleFont({
family: 'Oswald',
category: 'sans-serif',
variants: ['200', '300', '400', '500', '600', '700'],
subsets: ['latin', 'latin-ext', 'vietnamese'],
popularity: 6,
}),
raleway: mockGoogleFont({
family: 'Raleway',
category: 'sans-serif',
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
popularity: 7,
}),
};
// FONTHARE MOCKS
/**
* Options for creating a mock Fontshare font
*/
export interface MockFontshareFontOptions {
/** Font name (default: 'Mock Font') */
name?: string;
/** URL-friendly slug (default: derived from name) */
slug?: string;
/** Font category (default: 'sans') */
category?: 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script' | 'mono';
/** Script (default: 'latin') */
script?: string;
/** Whether this is a variable font (default: false) */
isVariable?: boolean;
/** Font version (default: '1.0') */
version?: string;
/** Popularity/views count (default: 1000) */
views?: number;
/** Usage tags */
tags?: string[];
/** Font weights available */
weights?: number[];
/** Publisher name */
publisher?: string;
/** Designer name */
designer?: string;
}
/**
* Create a mock Fontshare style
*/
function mockFontshareStyle(
weight: number,
isItalic: boolean,
isVariable: boolean,
slug: string,
): FontshareFont['styles'][number] {
const weightLabel = weight === 400 ? 'Regular' : weight === 700 ? 'Bold' : weight.toString();
const suffix = isItalic ? 'italic' : '';
const variablePrefix = isVariable ? 'variable-' : '';
return {
id: `style-${weight}${isItalic ? '-italic' : ''}`,
default: weight === 400 && !isItalic,
file: `//cdn.fontshare.com/wf/${slug}-${variablePrefix}${weight}${suffix}.woff2`,
is_italic: isItalic,
is_variable: isVariable,
properties: {},
weight: {
label: isVariable ? 'Variable' + (isItalic ? ' Italic' : '') : weightLabel,
name: isVariable ? 'Variable' + (isItalic ? 'Italic' : '') : weightLabel,
native_name: null,
number: isVariable ? 0 : weight,
weight: isVariable ? 0 : weight,
},
};
}
/**
* Default mock Fontshare font
*/
export function mockFontshareFont(options: MockFontshareFontOptions = {}): FontshareFont {
const {
name = 'Mock Font',
slug = name.toLowerCase().replace(/\s+/g, '-'),
category = 'sans',
script = 'latin',
isVariable = false,
version = '1.0',
views = 1000,
tags = [],
weights = [400, 700],
publisher = 'Mock Foundry',
designer = 'Mock Designer',
} = options;
// Generate styles based on weights and variable setting
const styles: FontshareFont['styles'] = isVariable
? [
mockFontshareStyle(0, false, true, slug),
mockFontshareStyle(0, true, true, slug),
]
: weights.flatMap(weight => [
mockFontshareStyle(weight, false, false, slug),
mockFontshareStyle(weight, true, false, slug),
]);
return {
id: `mock-${slug}`,
name,
native_name: null,
slug,
category,
script,
publisher: {
bio: `Mock publisher bio for ${publisher}`,
email: null,
id: `pub-${slug}`,
links: [],
name: publisher,
},
designers: [
{
bio: `Mock designer bio for ${designer}`,
links: [],
name: designer,
},
],
related_families: null,
display_publisher_as_designer: false,
trials_enabled: true,
show_latin_metrics: false,
license_type: 'ofl',
languages: 'English, Spanish, French, German',
inserted_at: '2021-03-12T20:49:05Z',
story: `<p>A mock font story for ${name}.</p>`,
version,
views,
views_recent: Math.floor(views * 0.1),
is_hot: views > 5000,
is_new: views < 500,
is_shortlisted: null,
is_top: views > 10000,
axes: isVariable
? [
{
name: 'Weight',
property: 'wght',
range_default: 400,
range_left: 300,
range_right: 700,
},
]
: [],
font_tags: tags.map(name => ({ name })),
features: [],
styles,
};
}
/**
* Preset Fontshare font mocks
*/
export const FONTHARE_FONTS: Record<string, FontshareFont> = {
satoshi: mockFontshareFont({
name: 'Satoshi',
slug: 'satoshi',
category: 'sans',
isVariable: true,
views: 15000,
tags: ['Branding', 'Logos', 'Editorial'],
publisher: 'Indian Type Foundry',
designer: 'Denis Shelabovets',
}),
generalSans: mockFontshareFont({
name: 'General Sans',
slug: 'general-sans',
category: 'sans',
isVariable: true,
views: 12000,
tags: ['UI', 'Branding', 'Display'],
publisher: 'Indestructible Type',
designer: 'Eugene Tantsur',
}),
clashDisplay: mockFontshareFont({
name: 'Clash Display',
slug: 'clash-display',
category: 'display',
isVariable: false,
views: 8000,
tags: ['Headlines', 'Posters', 'Branding'],
weights: [400, 500, 600, 700],
publisher: 'Letterogika',
designer: 'Matěj Trnka',
}),
fonta: mockFontshareFont({
name: 'Fonta',
slug: 'fonta',
category: 'serif',
isVariable: false,
views: 5000,
tags: ['Editorial', 'Books', 'Magazines'],
weights: [300, 400, 500, 600, 700],
publisher: 'Fonta',
designer: 'Alexei Vanyashin',
}),
aileron: mockFontshareFont({
name: 'Aileron',
slug: 'aileron',
category: 'sans',
isVariable: false,
views: 3000,
tags: ['Display', 'Headlines'],
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
publisher: 'Sorkin Type',
designer: 'Sorkin Type',
}),
beVietnamPro: mockFontshareFont({
name: 'Be Vietnam Pro',
slug: 'be-vietnam-pro',
category: 'sans',
isVariable: true,
views: 20000,
tags: ['UI', 'App', 'Web'],
publisher: 'ildefox',
designer: 'Manh Nguyen',
}),
};
// UNIFIED FONT MOCKS
/**
* Options for creating a mock UnifiedFont
*/
export interface MockUnifiedFontOptions {
/** Unique identifier (default: derived from name) */
/**
* Unique identifier (default: derived from name)
*/
id?: string;
/** Font display name (default: 'Mock Font') */
/**
* Font display name (default: 'Mock Font')
*/
name?: string;
/** Font provider (default: 'google') */
/**
* Font provider (default: 'google')
*/
provider?: FontProvider;
/** Font category (default: 'sans-serif') */
/**
* Font category (default: 'sans-serif')
*/
category?: FontCategory;
/** Font subsets (default: ['latin']) */
/**
* Font subsets (default: ['latin'])
*/
subsets?: FontSubset[];
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
/**
* Font variants (default: ['regular', '700', 'italic', '700italic'])
*/
variants?: FontVariant[];
/** Style URLs (if not provided, mock URLs are generated) */
/**
* Style URLs (if not provided, mock URLs are generated)
*/
styles?: FontStyleUrls;
/** Metadata overrides */
/**
* Metadata overrides
*/
metadata?: Partial<FontMetadata>;
/** Features overrides */
/**
* Features overrides
*/
features?: Partial<FontFeatures>;
}

View File

@@ -26,17 +26,11 @@
// Font mocks
export {
FONTHARE_FONTS,
generateMixedCategoryFonts,
generateMockFonts,
getAllMockFonts,
getFontsByCategory,
getFontsByProvider,
GOOGLE_FONTS,
mockFontshareFont,
type MockFontshareFontOptions,
mockGoogleFont,
type MockGoogleFontOptions,
mockUnifiedFont,
type MockUnifiedFontOptions,
UNIFIED_FONTS,
@@ -51,10 +45,8 @@ export {
createSubsetsFilter,
FONT_PROVIDERS,
FONT_SUBSETS,
FONTHARE_CATEGORIES,
generateSequentialFilter,
GENERIC_FILTERS,
GOOGLE_CATEGORIES,
MOCK_FILTERS,
MOCK_FILTERS_ALL_SELECTED,
MOCK_FILTERS_EMPTY,

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.
*
@@ -20,7 +16,7 @@
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
*
* // Use preset stores
* const mockFontStore = MOCK_STORES.unifiedFontStore();
* const mockFontStore = createMockFontStore();
* ```
*/
@@ -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;
},
@@ -459,6 +666,186 @@ export const MOCK_STORES = {
resetFilters: () => {},
};
},
/**
* Create a mock FontStore object
* 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 {
fonts: mockFonts = Object.values(UNIFIED_FONTS).slice(0, 5),
total: mockTotal = mockFonts.length,
limit = 50,
offset = 0,
isLoading = false,
isFetching = false,
isError = false,
error = null,
hasMore = false,
page = 1,
} = config;
const totalPages = Math.ceil(mockTotal / limit);
const state = {
params: { limit },
};
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,
limit,
offset,
hasMore,
page,
totalPages,
};
},
// 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');
},
// Lifecycle
destroy() {},
// Param management
setParams(_updates: Record<string, unknown>) {},
invalidate() {},
// Async operations (no-op for Storybook)
refetch() {},
prefetch() {},
cancel() {},
getCachedData() {
return mockFonts.length > 0 ? mockFonts : undefined;
},
setQueryData() {},
// Filter shortcuts
setProviders() {},
setCategories() {},
setSubsets() {},
setSearch() {},
setSort() {},
// Pagination navigation
nextPage() {},
prevPage() {},
goToPage() {},
setLimit(_limit: number) {
state.params.limit = _limit;
},
};
},
};
// REACTIVE STATE MOCKS

View File

@@ -1,582 +0,0 @@
import {
describe,
expect,
it,
} from 'vitest';
import type {
FontItem,
FontshareFont,
GoogleFontItem,
} from '../../model/types';
import {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './normalize';
describe('Font Normalization', () => {
describe('normalizeGoogleFont', () => {
const mockGoogleFont: GoogleFontItem = {
family: 'Roboto',
category: 'sans-serif',
variants: ['regular', '700', 'italic', '700italic'],
subsets: ['latin', 'latin-ext'],
files: {
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
'700italic': 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
},
version: 'v30',
lastModified: '2022-01-01',
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
};
it('normalizes Google Font to unified model', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.id).toBe('Roboto');
expect(result.name).toBe('Roboto');
expect(result.provider).toBe('google');
expect(result.category).toBe('sans-serif');
});
it('maps font variants correctly', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.variants).toEqual(['regular', '700', 'italic', '700italic']);
});
it('maps subsets correctly', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.subsets).toContain('latin');
expect(result.subsets).toContain('latin-ext');
expect(result.subsets).toHaveLength(2);
});
it('maps style URLs correctly', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.styles.regular).toBeDefined();
expect(result.styles.bold).toBeDefined();
expect(result.styles.italic).toBeDefined();
expect(result.styles.boldItalic).toBeDefined();
});
it('includes metadata', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.metadata.cachedAt).toBeDefined();
expect(result.metadata.version).toBe('v30');
expect(result.metadata.lastModified).toBe('2022-01-01');
});
it('marks Google Fonts as non-variable', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.features.isVariable).toBe(false);
expect(result.features.tags).toEqual([]);
});
it('handles sans-serif category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'sans-serif' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('sans-serif');
});
it('handles serif category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'serif' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('serif');
});
it('handles display category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'display' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('display');
});
it('handles handwriting category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'handwriting' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('handwriting');
});
it('handles cursive category (maps to handwriting)', () => {
const font: FontItem = { ...mockGoogleFont, category: 'cursive' as any };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('handwriting');
});
it('handles monospace category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'monospace' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('monospace');
});
it('filters invalid subsets', () => {
const font = {
...mockGoogleFont,
subsets: ['latin', 'latin-ext', 'invalid-subset'],
};
const result = normalizeGoogleFont(font);
expect(result.subsets).not.toContain('invalid-subset');
expect(result.subsets).toHaveLength(2);
});
it('maps variant weights correctly', () => {
const font: GoogleFontItem = {
...mockGoogleFont,
variants: ['regular', '100', '400', '700', '900'] as any,
};
const result = normalizeGoogleFont(font);
expect(result.variants).toContain('regular');
expect(result.variants).toContain('100');
expect(result.variants).toContain('400');
expect(result.variants).toContain('700');
expect(result.variants).toContain('900');
});
});
describe('normalizeFontshareFont', () => {
const mockFontshareFont: FontshareFont = {
id: '20e9fcdc-1e41-4559-a43d-1ede0adc8896',
name: 'Satoshi',
native_name: null,
slug: 'satoshi',
category: 'Sans',
script: 'latin',
publisher: {
bio: 'Indian Type Foundry',
email: null,
id: 'test-id',
links: [],
name: 'Indian Type Foundry',
},
designers: [
{
bio: 'Designer bio',
links: [],
name: 'Designer Name',
},
],
related_families: null,
display_publisher_as_designer: false,
trials_enabled: true,
show_latin_metrics: false,
license_type: 'itf_ffl',
languages: 'Afar, Afrikaans',
inserted_at: '2021-03-12T20:49:05Z',
story: '<p>Font story</p>',
version: '1.0',
views: 10000,
views_recent: 500,
is_hot: true,
is_new: false,
is_shortlisted: false,
is_top: true,
axes: [],
font_tags: [
{ name: 'Branding' },
{ name: 'Logos' },
],
features: [
{
name: 'Alternate t',
on_by_default: false,
tag: 'ss01',
},
],
styles: [
{
id: 'style-id-1',
default: true,
file: '//cdn.fontshare.com/wf/satoshi.woff2',
is_italic: false,
is_variable: false,
properties: {},
weight: {
label: 'Regular',
name: 'Regular',
native_name: null,
number: 400,
weight: 400,
},
},
{
id: 'style-id-2',
default: false,
file: '//cdn.fontshare.com/wf/satoshi-bold.woff2',
is_italic: false,
is_variable: false,
properties: {},
weight: {
label: 'Bold',
name: 'Bold',
native_name: null,
number: 700,
weight: 700,
},
},
{
id: 'style-id-3',
default: false,
file: '//cdn.fontshare.com/wf/satoshi-italic.woff2',
is_italic: true,
is_variable: false,
properties: {},
weight: {
label: 'Regular',
name: 'Regular',
native_name: null,
number: 400,
weight: 400,
},
},
{
id: 'style-id-4',
default: false,
file: '//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
is_italic: true,
is_variable: false,
properties: {},
weight: {
label: 'Bold',
name: 'Bold',
native_name: null,
number: 700,
weight: 700,
},
},
],
};
it('normalizes Fontshare font to unified model', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.id).toBe('satoshi');
expect(result.name).toBe('Satoshi');
expect(result.provider).toBe('fontshare');
expect(result.category).toBe('sans-serif');
});
it('uses slug as unique identifier', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.id).toBe('satoshi');
});
it('extracts variant names from styles', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.variants).toContain('Regular');
expect(result.variants).toContain('Bold');
expect(result.variants).toContain('Regularitalic');
expect(result.variants).toContain('Bolditalic');
});
it('maps Fontshare Sans to sans-serif category', () => {
const font = { ...mockFontshareFont, category: 'Sans' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('sans-serif');
});
it('maps Fontshare Serif to serif category', () => {
const font = { ...mockFontshareFont, category: 'Serif' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('serif');
});
it('maps Fontshare Display to display category', () => {
const font = { ...mockFontshareFont, category: 'Display' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('display');
});
it('maps Fontshare Script to handwriting category', () => {
const font = { ...mockFontshareFont, category: 'Script' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('handwriting');
});
it('maps Fontshare Mono to monospace category', () => {
const font = { ...mockFontshareFont, category: 'Mono' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('monospace');
});
it('maps style URLs correctly', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.styles.regular).toBe('//cdn.fontshare.com/wf/satoshi.woff2');
expect(result.styles.bold).toBe('//cdn.fontshare.com/wf/satoshi-bold.woff2');
expect(result.styles.italic).toBe('//cdn.fontshare.com/wf/satoshi-italic.woff2');
expect(result.styles.boldItalic).toBe(
'//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
);
});
it('handles variable fonts', () => {
const variableFont: FontshareFont = {
...mockFontshareFont,
axes: [
{
name: 'wght',
property: 'wght',
range_default: 400,
range_left: 300,
range_right: 900,
},
],
styles: [
{
id: 'var-style',
default: true,
file: '//cdn.fontshare.com/wf/satoshi-variable.woff2',
is_italic: false,
is_variable: true,
properties: {},
weight: {
label: 'Variable',
name: 'Variable',
native_name: null,
number: 0,
weight: 0,
},
},
],
};
const result = normalizeFontshareFont(variableFont);
expect(result.features.isVariable).toBe(true);
expect(result.features.axes).toHaveLength(1);
expect(result.features.axes?.[0].name).toBe('wght');
});
it('extracts font tags', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.features.tags).toContain('Branding');
expect(result.features.tags).toContain('Logos');
expect(result.features.tags).toHaveLength(2);
});
it('includes popularity from views', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.metadata.popularity).toBe(10000);
});
it('includes metadata', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.metadata.cachedAt).toBeDefined();
expect(result.metadata.version).toBe('1.0');
expect(result.metadata.lastModified).toBe('2021-03-12T20:49:05Z');
});
it('handles missing subsets gracefully', () => {
const font = {
...mockFontshareFont,
script: 'invalid-script',
};
const result = normalizeFontshareFont(font);
expect(result.subsets).toEqual([]);
});
it('handles empty tags', () => {
const font = {
...mockFontshareFont,
font_tags: [],
};
const result = normalizeFontshareFont(font);
expect(result.features.tags).toBeUndefined();
});
it('handles empty axes', () => {
const font = {
...mockFontshareFont,
axes: [],
};
const result = normalizeFontshareFont(font);
expect(result.features.isVariable).toBe(false);
expect(result.features.axes).toBeUndefined();
});
});
describe('normalizeGoogleFonts', () => {
it('normalizes array of Google Fonts', () => {
const fonts: GoogleFontItem[] = [
{
family: 'Roboto',
category: 'sans-serif',
variants: ['regular'],
subsets: ['latin'],
files: { regular: 'url' },
version: 'v1',
lastModified: '2022-01-01',
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
},
{
family: 'Open Sans',
category: 'sans-serif',
variants: ['regular'],
subsets: ['latin'],
files: { regular: 'url' },
version: 'v1',
lastModified: '2022-01-01',
menu: 'https://fonts.googleapis.com/css2?family=Open+Sans',
},
];
const result = normalizeGoogleFonts(fonts);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Roboto');
expect(result[1].name).toBe('Open Sans');
});
it('returns empty array for empty input', () => {
const result = normalizeGoogleFonts([]);
expect(result).toEqual([]);
});
});
describe('normalizeFontshareFonts', () => {
it('normalizes array of Fontshare fonts', () => {
const fonts: FontshareFont[] = [
{
...mockMinimalFontshareFont('font1', 'Font 1'),
},
{
...mockMinimalFontshareFont('font2', 'Font 2'),
},
];
const result = normalizeFontshareFonts(fonts);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Font 1');
expect(result[1].name).toBe('Font 2');
});
it('returns empty array for empty input', () => {
const result = normalizeFontshareFonts([]);
expect(result).toEqual([]);
});
});
describe('edge cases', () => {
it('handles Google Font with missing optional fields', () => {
const font: Partial<GoogleFontItem> = {
family: 'Test Font',
category: 'sans-serif',
variants: ['regular'],
subsets: ['latin'],
files: { regular: 'url' },
};
const result = normalizeGoogleFont(font as GoogleFontItem);
expect(result.id).toBe('Test Font');
expect(result.metadata.version).toBeUndefined();
expect(result.metadata.lastModified).toBeUndefined();
});
it('handles Fontshare font with minimal data', () => {
const result = normalizeFontshareFont(mockMinimalFontshareFont('slug', 'Name'));
expect(result.id).toBe('slug');
expect(result.name).toBe('Name');
expect(result.provider).toBe('fontshare');
});
it('handles unknown Fontshare category', () => {
const font = {
...mockMinimalFontshareFont('slug', 'Name'),
category: 'Unknown Category',
};
const result = normalizeFontshareFont(font);
expect(result.category).toBe('sans-serif'); // fallback
});
});
});
/**
* Helper function to create minimal Fontshare font mock
*/
function mockMinimalFontshareFont(slug: string, name: string): FontshareFont {
return {
id: 'test-id',
name,
native_name: null,
slug,
category: 'Sans',
script: 'latin',
publisher: {
bio: '',
email: null,
id: '',
links: [],
name: '',
},
designers: [],
related_families: null,
display_publisher_as_designer: false,
trials_enabled: false,
show_latin_metrics: false,
license_type: '',
languages: '',
inserted_at: '',
story: '',
version: '1.0',
views: 0,
views_recent: 0,
is_hot: false,
is_new: false,
is_shortlisted: null,
is_top: false,
axes: [],
font_tags: [],
features: [],
styles: [
{
id: 'style-id',
default: true,
file: '//cdn.fontshare.com/wf/test.woff2',
is_italic: false,
is_variable: false,
properties: {},
weight: {
label: 'Regular',
name: 'Regular',
native_name: null,
number: 400,
weight: 400,
},
},
],
};
}

View File

@@ -1,275 +0,0 @@
/**
* Normalize fonts from Google Fonts and Fontshare to unified model
*
* Transforms provider-specific font data into a common interface
* for consistent handling across the application.
*/
import type {
FontCategory,
FontStyleUrls,
FontSubset,
FontshareFont,
GoogleFontItem,
UnifiedFont,
UnifiedFontVariant,
} from '../../model/types';
/**
* Map Google Fonts category to unified FontCategory
*/
function mapGoogleCategory(category: string): FontCategory {
const normalized = category.toLowerCase();
if (normalized.includes('sans-serif')) {
return 'sans-serif';
}
if (normalized.includes('serif')) {
return 'serif';
}
if (normalized.includes('display')) {
return 'display';
}
if (normalized.includes('handwriting') || normalized.includes('cursive')) {
return 'handwriting';
}
if (normalized.includes('monospace')) {
return 'monospace';
}
// Default fallback
return 'sans-serif';
}
/**
* Map Fontshare category to unified FontCategory
*/
function mapFontshareCategory(category: string): FontCategory {
const normalized = category.toLowerCase();
if (normalized === 'sans' || normalized === 'sans-serif') {
return 'sans-serif';
}
if (normalized === 'serif') {
return 'serif';
}
if (normalized === 'display') {
return 'display';
}
if (normalized === 'script') {
return 'handwriting';
}
if (normalized === 'mono' || normalized === 'monospace') {
return 'monospace';
}
// Default fallback
return 'sans-serif';
}
/**
* Map Google subset to unified FontSubset
*/
function mapGoogleSubset(subset: string): FontSubset | null {
const validSubsets: FontSubset[] = [
'latin',
'latin-ext',
'cyrillic',
'greek',
'arabic',
'devanagari',
];
return validSubsets.includes(subset as FontSubset)
? (subset as FontSubset)
: null;
}
/**
* Map Fontshare script to unified FontSubset
*/
function mapFontshareScript(script: string): FontSubset | null {
const normalized = script.toLowerCase();
const mapping: Record<string, FontSubset | null> = {
latin: 'latin',
'latin-ext': 'latin-ext',
cyrillic: 'cyrillic',
greek: 'greek',
arabic: 'arabic',
devanagari: 'devanagari',
};
return mapping[normalized] ?? null;
}
/**
* Normalize Google Font to unified model
*
* @param apiFont - Font item from Google Fonts API
* @returns Unified font model
*
* @example
* ```ts
* const roboto = normalizeGoogleFont({
* family: 'Roboto',
* category: 'sans-serif',
* variants: ['regular', '700'],
* subsets: ['latin', 'latin-ext'],
* files: { regular: '...', '700': '...' }
* });
*
* console.log(roboto.id); // 'Roboto'
* console.log(roboto.provider); // 'google'
* ```
*/
export function normalizeGoogleFont(apiFont: GoogleFontItem): UnifiedFont {
const category = mapGoogleCategory(apiFont.category);
const subsets = apiFont.subsets
.map(mapGoogleSubset)
.filter((subset): subset is FontSubset => subset !== null);
// Map variant files to style URLs
const styles: FontStyleUrls = {};
for (const [variant, url] of Object.entries(apiFont.files)) {
const urlString = url as string; // Type assertion for Record<string, string>
if (variant === 'regular' || variant === '400') {
styles.regular = urlString;
} else if (variant === 'italic' || variant === '400italic') {
styles.italic = urlString;
} else if (variant === 'bold' || variant === '700') {
styles.bold = urlString;
} else if (variant === 'bolditalic' || variant === '700italic') {
styles.boldItalic = urlString;
}
}
return {
id: apiFont.family,
name: apiFont.family,
provider: 'google',
category,
subsets,
variants: apiFont.variants,
styles,
metadata: {
cachedAt: Date.now(),
version: apiFont.version,
lastModified: apiFont.lastModified,
},
features: {
isVariable: false, // Google Fonts doesn't expose variable font info
tags: [],
},
};
}
/**
* Normalize Fontshare font to unified model
*
* @param apiFont - Font item from Fontshare API
* @returns Unified font model
*
* @example
* ```ts
* const satoshi = normalizeFontshareFont({
* id: 'uuid',
* name: 'Satoshi',
* slug: 'satoshi',
* category: 'Sans',
* script: 'latin',
* styles: [ ... ]
* });
*
* console.log(satoshi.id); // 'satoshi'
* console.log(satoshi.provider); // 'fontshare'
* ```
*/
export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont {
const category = mapFontshareCategory(apiFont.category);
const subset = mapFontshareScript(apiFont.script);
const subsets = subset ? [subset] : [];
// Extract variant names from styles
const variants = apiFont.styles.map(style => {
const weightLabel = style.weight.label;
const isItalic = style.is_italic;
return (isItalic ? `${weightLabel}italic` : weightLabel) as UnifiedFontVariant;
});
// Map styles to URLs
const styles: FontStyleUrls = {};
for (const style of apiFont.styles) {
if (style.is_variable) {
// Variable font - store as primary variant
styles.regular = style.file;
break;
}
const weight = style.weight.number;
const isItalic = style.is_italic;
if (weight === 400 && !isItalic) {
styles.regular = style.file;
} else if (weight === 400 && isItalic) {
styles.italic = style.file;
} else if (weight >= 700 && !isItalic) {
styles.bold = style.file;
} else if (weight >= 700 && isItalic) {
styles.boldItalic = style.file;
}
}
// Extract variable font axes
const axes = apiFont.axes.map(axis => ({
name: axis.name,
property: axis.property,
default: axis.range_default,
min: axis.range_left,
max: axis.range_right,
}));
// Extract tags
const tags = apiFont.font_tags.map(tag => tag.name);
return {
id: apiFont.slug,
name: apiFont.name,
provider: 'fontshare',
category,
subsets,
variants,
styles,
metadata: {
cachedAt: Date.now(),
version: apiFont.version,
lastModified: apiFont.inserted_at,
popularity: apiFont.views,
},
features: {
isVariable: apiFont.axes.length > 0,
axes: axes.length > 0 ? axes : undefined,
tags: tags.length > 0 ? tags : undefined,
},
};
}
/**
* Normalize multiple Google Fonts to unified model
*
* @param apiFonts - Array of Google Font items
* @returns Array of unified fonts
*/
export function normalizeGoogleFonts(
apiFonts: GoogleFontItem[],
): UnifiedFont[] {
return apiFonts.map(normalizeGoogleFont);
}
/**
* Normalize multiple Fontshare fonts to unified model
*
* @param apiFonts - Array of Fontshare font items
* @returns Array of unified fonts
*/
export function normalizeFontshareFonts(
apiFonts: FontshareFont[],
): UnifiedFont[] {
return apiFonts.map(normalizeFontshareFont);
}
// Re-export UnifiedFont for backward compatibility
export type { UnifiedFont } from '../../model/types/normalize';

View File

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

View File

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

View File

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

View File

@@ -1,43 +1,3 @@
export type {
// Domain types
FontCategory,
FontCollectionFilters,
FontCollectionSort,
// Store types
FontCollectionState,
FontFeatures,
FontFiles,
FontItem,
FontMetadata,
FontProvider,
// Fontshare API types
FontshareApiModel,
FontshareAxis,
FontshareDesigner,
FontshareFeature,
FontshareFont,
FontshareLink,
FontsharePublisher,
FontshareStyle,
FontshareStyleProperties,
FontshareTag,
FontshareWeight,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
// Google Fonts API types
GoogleFontsApiModel,
// Normalization types
UnifiedFont,
UnifiedFontVariant,
} from './types';
export {
appliedFontsManager,
createUnifiedFontStore,
type FontConfigRequest,
type UnifiedFontStore,
unifiedFontStore,
} from './store';
export * from './const/const';
export * from './store';
export * from './types';

View File

@@ -1,73 +1,61 @@
/** @vitest-environment jsdom */
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
/**
* @vitest-environment jsdom
*/
import { AppliedFontsManager } from './appliedFontsStore.svelte';
import { FontFetchError } from './errors';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
class FakeBufferCache {
async get(_url: string): Promise<ArrayBuffer> {
return new ArrayBuffer(8);
}
evict(_url: string): void {}
clear(): void {}
}
/**
* 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);
}
evict(_url: string): void {}
clear(): void {}
}
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
id,
name: id,
url: `https://example.com/${id}.woff2`,
weight: 400,
...overrides,
});
describe('AppliedFontsManager', () => {
let manager: AppliedFontsManager;
let mockFontFaceSet: any;
let mockFetch: any;
let failUrls: Set<string>;
let eviction: FontEvictionPolicy;
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.useFakeTimers();
failUrls = new Set();
eviction = new FontEvictionPolicy({ ttl: 60000 });
mockFontFaceSet = { add: vi.fn(), delete: vi.fn() };
mockFontFaceSet = {
add: vi.fn(),
delete: vi.fn(),
};
// 1. Properly mock FontFace as a constructor function
// The actual implementation passes buffer (ArrayBuffer) as second arg, not URL string
const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) {
this.name = name;
this.bufferOrUrl = bufferOrUrl;
this.load = vi.fn().mockImplementation(() => {
// For error tests, we track which URLs should fail via failUrls
// The fetch mock will have already rejected for those URLs
return Promise.resolve(this);
});
});
vi.stubGlobal('FontFace', MockFontFace);
// 2. Mock document.fonts safely
Object.defineProperty(document, 'fonts', {
value: mockFontFaceSet,
configurable: true,
writable: true,
});
vi.stubGlobal('crypto', {
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any,
const MockFontFace = vi.fn(function(this: any, name: string, buffer: BufferSource) {
this.name = name;
this.buffer = buffer;
this.load = vi.fn().mockResolvedValue(this);
});
vi.stubGlobal('FontFace', MockFontFace);
// 3. Mock fetch to return fake ArrayBuffer data
mockFetch = vi.fn((url: string) => {
if (failUrls.has(url)) {
return Promise.reject(new Error('Network error'));
}
return Promise.resolve({
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
clone: () => ({
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
}),
} as Response);
});
vi.stubGlobal('fetch', mockFetch);
manager = new AppliedFontsManager();
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
});
afterEach(() => {
@@ -76,67 +64,255 @@ describe('AppliedFontsManager', () => {
vi.unstubAllGlobals();
});
it('should batch multiple font requests into a single process', async () => {
const configs = [
{ id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 },
{ id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 },
];
manager.touch(configs);
// Advance to trigger the 16ms debounced #processQueue
describe('touch()', () => {
it('queues and loads a new font', async () => {
manager.touch([makeConfig('roboto')]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('roboto', 400)).toBe('loaded');
});
it('batches multiple fonts into a single queue flush', async () => {
manager.touch([makeConfig('lato'), makeConfig('inter')]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('lato-400', 400)).toBe('loaded');
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
});
it('should handle font loading errors gracefully', async () => {
// Suppress expected console error for clean test logs
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
it('skips fonts that are already loaded', async () => {
manager.touch([makeConfig('lato')]);
await vi.advanceTimersByTimeAsync(50);
const failUrl = 'https://example.com/fail.ttf';
failUrls.add(failUrl);
manager.touch([makeConfig('lato')]);
await vi.advanceTimersByTimeAsync(50);
const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 };
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
});
manager.touch([config]);
it('skips fonts that are currently loading', async () => {
manager.touch([makeConfig('lato')]);
// simulate loading state before queue drains
manager.statuses.set('lato@400', 'loading');
manager.touch([makeConfig('lato')]);
await vi.advanceTimersByTimeAsync(50);
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
});
it('skips fonts that have exhausted retries', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
// exhaust all 3 retries
for (let i = 0; i < 3; i++) {
failManager.statuses.delete('broken@400');
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
}
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(failManager.getFontStatus('broken', 400)).toBe('error');
expect(mockFontFaceSet.add).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('does nothing after manager is destroyed', async () => {
manager.destroy();
manager.touch([makeConfig('roboto')]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.statuses.size).toBe(0);
});
});
describe('queue processing', () => {
it('filters non-critical weights in data-saver mode', async () => {
(navigator as any).connection = { saveData: true };
manager.touch([
makeConfig('light', { weight: 300 }),
makeConfig('regular', { weight: 400 }),
makeConfig('bold', { weight: 700 }),
]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('light', 300)).toBeUndefined();
expect(manager.getFontStatus('regular', 400)).toBe('loaded');
expect(manager.getFontStatus('bold', 700)).toBe('loaded');
delete (navigator as any).connection;
});
it('loads variable fonts in data-saver mode regardless of weight', async () => {
(navigator as any).connection = { saveData: true };
manager.touch([makeConfig('vf', { weight: 300, isVariable: true })]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('vf', 300, true)).toBe('loaded');
delete (navigator as any).connection;
});
});
describe('Phase 1 — fetch', () => {
it('sets status to error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(failManager.getFontStatus('broken', 400)).toBe('error');
consoleSpy.mockRestore();
});
it('logs a console error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('does not set error status or log for aborted fetches', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const abortingCache = {
async get(url: string): Promise<never> {
throw new FontFetchError(url, Object.assign(new Error('Aborted'), { name: 'AbortError' }));
},
evict() {},
clear() {},
};
const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction });
abortManager.touch([makeConfig('aborted')]);
await vi.advanceTimersByTimeAsync(50);
// status is left as 'loading' (not 'error') — abort is not a retriable failure
expect(abortManager.getFontStatus('aborted', 400)).not.toBe('error');
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('Phase 2 — parse', () => {
it('sets status to error on parse failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const FailingFontFace = vi.fn(function(this: any) {
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
});
vi.stubGlobal('FontFace', FailingFontFace);
manager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('broken', 400)).toBe('error');
spy.mockRestore();
consoleSpy.mockRestore();
});
it('should purge fonts after TTL expires', async () => {
const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
it('logs a console error on parse failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const FailingFontFace = vi.fn(function(this: any) {
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
});
vi.stubGlobal('FontFace', FailingFontFace);
manager.touch([config]);
manager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded');
// Move clock forward past TTL (5m) and Purge Interval (1m)
// advanceTimersByTimeAsync is key here; it handles the promises inside the interval
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('#purgeUnused', () => {
it('evicts fonts after TTL expires', async () => {
manager.touch([makeConfig('ephemeral')]);
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(61000);
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
expect(mockFontFaceSet.delete).toHaveBeenCalled();
});
it('should NOT purge fonts that are still being "touched"', async () => {
const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 };
it('removes the evicted key from the eviction policy', async () => {
manager.touch([makeConfig('ephemeral')]);
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(61000);
expect(Array.from(eviction.keys())).not.toContain('ephemeral@400');
});
it('refreshes TTL when font is re-touched before expiry', async () => {
const config = makeConfig('active');
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
// Advance 4 minutes
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);
await vi.advanceTimersByTimeAsync(40000);
manager.touch([config]); // refresh at t≈40s
// Refresh touch
manager.touch([config]);
// Advance another 2 minutes (Total 6 since start)
await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
await vi.advanceTimersByTimeAsync(25000); // purge at t≈60s sees only ~20s elapsed → not evicted
expect(manager.getFontStatus('active', 400)).toBe('loaded');
});
it('does not evict pinned fonts', async () => {
manager.touch([makeConfig('pinned')]);
await vi.advanceTimersByTimeAsync(50);
manager.pin('pinned', 400);
await vi.advanceTimersByTimeAsync(61000);
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
});
it('evicts font after it is unpinned and TTL expires', async () => {
manager.touch([makeConfig('toggled')]);
await vi.advanceTimersByTimeAsync(50);
manager.pin('toggled', 400);
manager.unpin('toggled', 400);
await vi.advanceTimersByTimeAsync(61000);
expect(manager.getFontStatus('toggled', 400)).toBeUndefined();
expect(mockFontFaceSet.delete).toHaveBeenCalled();
});
});
describe('destroy()', () => {
it('clears all statuses', async () => {
manager.touch([makeConfig('roboto')]);
await vi.advanceTimersByTimeAsync(50);
manager.destroy();
expect(manager.statuses.size).toBe(0);
});
it('removes all loaded fonts from document.fonts', async () => {
manager.touch([makeConfig('roboto'), makeConfig('inter')]);
await vi.advanceTimersByTimeAsync(50);
manager.destroy();
expect(mockFontFaceSet.delete).toHaveBeenCalledTimes(2);
});
it('prevents further loading after destroy', async () => {
manager.destroy();
manager.touch([makeConfig('roboto')]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.statuses.size).toBe(0);
});
});
});

View File

@@ -1,30 +1,26 @@
import { SvelteMap } from 'svelte/reactivity';
import {
type FontLoadRequestConfig,
type FontLoadStatus,
} from '../../types';
import {
FontFetchError,
FontParseError,
} from './errors';
import {
generateFontKey,
getEffectiveConcurrency,
loadFont,
yieldToMainThread,
} from './utils';
import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
/** Loading state of a font. Failed loads may be retried up to MAX_RETRIES. */
export type FontStatus = 'loading' | 'loaded' | 'error';
/** Configuration for a font load request. */
export interface FontConfigRequest {
/**
* Unique identifier for the font (e.g., "lato", "roboto").
*/
id: string;
/**
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
*/
name: string;
/**
* URL pointing to the font file (typically .ttf or .woff2).
*/
url: string;
/**
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
*/
weight: number;
/**
* Variable fonts load once per ID; static fonts load per weight.
*/
isVariable?: boolean;
interface AppliedFontsManagerDeps {
cache?: FontBufferCache;
eviction?: FontEvictionPolicy;
queue?: FontLoadQueue;
}
/**
@@ -51,14 +47,16 @@ export interface FontConfigRequest {
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
*/
export class AppliedFontsManager {
// Injected collaborators - each handles one concern for better testability
readonly #cache: FontBufferCache;
readonly #eviction: FontEvictionPolicy;
readonly #queue: FontLoadQueue;
// Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf`
#loadedFonts = new Map<string, FontFace>();
// Last-used timestamps for LRU cleanup. Key: `{id}@{weight}` or `{id}@vf`, Value: unix timestamp (ms)
#usageTracker = new Map<string, number>();
// Fonts queued for loading by `touch()`, processed by `#processQueue()`
#queue = new Map<string, FontConfigRequest>();
// Maps font key → URL so #purgeUnused() can evict from cache
#urlByKey = new Map<string, string>();
// Handle for scheduled queue processing (requestIdleCallback or setTimeout)
#timeoutId: ReturnType<typeof setTimeout> | null = null;
@@ -72,54 +70,80 @@ export class AppliedFontsManager {
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
#pendingType: 'idle' | 'timeout' | null = null;
// Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped
#retryCounts = new Map<string, number>();
readonly #MAX_RETRIES = 3;
readonly #PURGE_INTERVAL = 60000; // 60 seconds
readonly #TTL = 5 * 60 * 1000; // 5 minutes
readonly #CACHE_NAME = 'font-cache-v1'; // Versioned for future invalidation
readonly #PURGE_INTERVAL = 60000;
// Reactive status map for Svelte components to track font states
statuses = new SvelteMap<string, FontStatus>();
statuses = new SvelteMap<string, FontLoadStatus>();
// Starts periodic cleanup timer (browser-only).
constructor() {
constructor(
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
AppliedFontsManagerDeps = {},
) {
// Inject collaborators - defaults provided for production, fakes for testing
this.#cache = cache;
this.#eviction = eviction;
this.#queue = queue;
if (typeof window !== 'undefined') {
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
}
}
// Generates font key: `{id}@vf` for variable, `{id}@{weight}` for static.
#getFontKey(id: string, weight: number, isVariable: boolean): string {
return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`;
}
/**
* Requests fonts to be loaded. Updates usage tracking and queues new fonts.
*
* Retry behavior: 'loaded' and 'loading' fonts are skipped; 'error' fonts retry if count < MAX_RETRIES.
* Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms).
*/
touch(configs: FontConfigRequest[]) {
if (this.#abortController.signal.aborted) return;
touch(configs: FontLoadRequestConfig[]) {
if (this.#abortController.signal.aborted) {
return;
}
try {
const now = Date.now();
let hasNewItems = false;
for (const config of configs) {
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
this.#usageTracker.set(key, now);
const key = generateFontKey(config);
// Update last-used timestamp for LRU eviction policy
this.#eviction.touch(key, now);
const status = this.statuses.get(key);
if (status === 'loaded' || status === 'loading' || this.#queue.has(key)) continue;
if (status === 'error' && (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES) continue;
this.#queue.set(key, config);
// Skip fonts that are already loaded or currently loading
if (status === 'loaded' || status === 'loading') {
continue;
}
// Skip fonts already in the queue (avoid duplicates)
if (this.#queue.has(key)) {
continue;
}
// Skip error fonts that have exceeded max retry count
if (status === 'error' && this.#queue.isMaxRetriesReached(key)) {
continue;
}
// Queue this font for loading
this.#queue.enqueue(key, config);
hasNewItems = true;
}
if (hasNewItems && !this.#timeoutId) {
this.#scheduleProcessing();
}
} catch (error) {
console.error(error);
}
}
/**
* Schedules `#processQueue()` via `requestIdleCallback` (150ms timeout) when available,
* falling back to `setTimeout(16ms)` for ~60fps timing.
*/
#scheduleProcessing(): void {
if (typeof requestIdleCallback !== 'undefined') {
this.#timeoutId = requestIdleCallback(
() => this.#processQueue(),
@@ -131,44 +155,12 @@ export class AppliedFontsManager {
this.#pendingType = 'timeout';
}
}
}
/** Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() (Chrome/Edge) or MessageChannel fallback. */
async #yieldToMain(): Promise<void> {
// @ts-expect-error - scheduler not in TypeScript lib yet
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
await scheduler.yield();
} else {
await new Promise<void>(resolve => {
const ch = new MessageChannel();
ch.port1.onmessage = () => resolve();
ch.port2.postMessage(null);
});
}
}
/** Returns optimal concurrent fetches based on Network Information API: 1 for 2G, 2 for 3G, 4 for 4G/default. */
#getEffectiveConcurrency(): number {
const nav = navigator as any;
const conn = nav.connection;
if (!conn) return 4;
switch (conn.effectiveType) {
case 'slow-2g':
case '2g':
return 1;
case '3g':
return 2;
default:
return 4;
}
}
/** 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 {
const nav = navigator as any;
return nav.connection?.saveData === true;
return (navigator as any).connection?.saveData === true;
}
/**
@@ -179,148 +171,195 @@ export class AppliedFontsManager {
* Yielding: Chromium uses `isInputPending()` for optimal responsiveness; others yield every 8ms.
*/
async #processQueue() {
// Clear timer flags since we're now processing
this.#timeoutId = null;
this.#pendingType = null;
let entries = Array.from(this.#queue.entries());
if (!entries.length) return;
this.#queue.clear();
// Get all queued entries and clear the queue atomically
let entries = this.#queue.flush();
if (!entries.length) {
return;
}
// In data-saver mode, only load variable fonts and common weights (400, 700)
if (this.#shouldDeferNonCritical()) {
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
}
// Phase 1: Concurrent fetching (I/O bound, non-blocking)
const concurrency = this.#getEffectiveConcurrency();
// Determine optimal concurrent fetches based on network speed (1-4)
const concurrency = getEffectiveConcurrency();
const buffers = new Map<string, ArrayBuffer>();
// Fetch multiple font files in parallel since network I/O is non-blocking
for (let i = 0; i < entries.length; i += concurrency) {
const chunk = entries.slice(i, i + concurrency);
const results = await Promise.allSettled(
chunk.map(async ([key, config]) => {
this.statuses.set(key, 'loading');
const buffer = await this.#fetchFontBuffer(
config.url,
this.#abortController.signal,
);
buffers.set(key, buffer);
}),
);
for (let j = 0; j < results.length; j++) {
if (results[j].status === 'rejected') {
const [key, config] = chunk[j];
console.error(`Font fetch failed: ${config.name}`, (results[j] as PromiseRejectedResult).reason);
this.statuses.set(key, 'error');
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
}
}
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers);
}
// Phase 2: Sequential parsing (CPU-intensive, yields periodically)
// Parse buffers one at a time with periodic yields to avoid blocking UI
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
let lastYield = performance.now();
const YIELD_INTERVAL = 8; // ms
const YIELD_INTERVAL = 8;
for (const [key, config] of entries) {
const buffer = buffers.get(key);
if (!buffer) continue;
try {
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
const font = new FontFace(config.name, buffer, {
weight: weightRange,
style: 'normal',
display: 'swap',
});
await font.load();
document.fonts.add(font);
this.#loadedFonts.set(key, font);
this.statuses.set(key, 'loaded');
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') continue;
console.error(`Font parse failed: ${config.name}`, e);
this.statuses.set(key, 'error');
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
// Skip fonts that failed to fetch in phase 1
if (!buffer) {
continue;
}
await this.#processFont(key, config, buffer);
// Yield to main thread if needed (prevents UI blocking)
// Chromium: use isInputPending() for optimal responsiveness
// Others: yield every 8ms as fallback
const shouldYield = hasInputPending
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
: (performance.now() - lastYield > YIELD_INTERVAL);
: performance.now() - lastYield > YIELD_INTERVAL;
if (shouldYield) {
await this.#yieldToMain();
await yieldToMainThread();
lastYield = performance.now();
}
}
}
/**
* Fetches font with cache-aside pattern: checks Cache API first, falls back to network.
* Cache failures (private browsing, quota limits) are silently ignored.
* Fetches a chunk of fonts concurrently and populates `buffers` with successful results.
* Each promise carries its own key and config so results need no index correlation.
* Aborted fetches are silently skipped; other errors set status to `'error'` and increment retry.
*/
async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
async #fetchChunk(
chunk: Array<[string, FontLoadRequestConfig]>,
buffers: Map<string, ArrayBuffer>,
): Promise<void> {
const results = await Promise.all(
chunk.map(async ([key, config]) => {
this.statuses.set(key, 'loading');
try {
if (typeof caches !== 'undefined') {
const cache = await caches.open(this.#CACHE_NAME);
const cached = await cache.match(url);
if (cached) return cached.arrayBuffer();
const buffer = await this.#cache.get(config.url, this.#abortController.signal);
buffers.set(key, buffer);
return { ok: true as const, key };
} catch (reason) {
return { ok: false as const, key, config, reason };
}
}),
);
for (const result of results) {
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 (reason instanceof FontFetchError) {
console.error(`Font fetch failed: ${config.name}`, reason);
}
this.statuses.set(key, 'error');
this.#queue.incrementRetry(key);
}
} catch {
// Cache unavailable (private browsing, security restrictions) — fall through to network
}
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
/**
* Parses a fetched buffer into a {@link FontFace}, registers it with `document.fonts`,
* and updates reactive status. On failure, sets status to `'error'` and increments the retry count.
*/
async #processFont(key: string, config: FontLoadRequestConfig, buffer: ArrayBuffer): Promise<void> {
try {
if (typeof caches !== 'undefined') {
const cache = await caches.open(this.#CACHE_NAME);
await cache.put(url, response.clone());
const font = await loadFont(config, buffer);
this.#loadedFonts.set(key, font);
this.#urlByKey.set(key, config.url);
this.statuses.set(key, 'loaded');
} catch (e) {
if (e instanceof FontParseError) {
console.error(`Font parse failed: ${config.name}`, e);
this.statuses.set(key, 'error');
this.#queue.incrementRetry(key);
}
}
} catch {
// Cache write failed (quota, storage pressure) — return font anyway
}
return response.arrayBuffer();
}
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. */
/**
* Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted.
*/
#purgeUnused() {
const now = Date.now();
for (const [key, lastUsed] of this.#usageTracker) {
if (now - lastUsed < this.#TTL) continue;
// Iterate through all tracked font keys
for (const key of this.#eviction.keys()) {
// Skip fonts that are still within TTL or are pinned
if (!this.#eviction.shouldEvict(key, now)) {
continue;
}
// 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);
if (url) {
this.#cache.evict(url);
this.#urlByKey.delete(key);
}
// Clean up remaining state
this.#loadedFonts.delete(key);
this.#usageTracker.delete(key);
this.statuses.delete(key);
this.#retryCounts.delete(key);
this.#eviction.remove(key);
}
}
/** 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) {
return this.statuses.get(this.#getFontKey(id, weight, isVariable));
try {
return this.statuses.get(generateFontKey({ id, weight, isVariable }));
} catch (error) {
console.error(error);
}
}
/** Waits for all fonts to finish loading using document.fonts.ready. */
/**
* 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.
*/
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.
*/
async ready(): Promise<void> {
if (typeof document === 'undefined') return;
if (typeof document === 'undefined') {
return;
}
try {
await document.fonts.ready;
} catch {
// document.fonts.ready can reject in some edge cases
// (e.g., document unloaded). Silently resolve.
}
} 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();
// Cancel pending queue processing (idle callback or timeout)
if (this.#timeoutId !== null) {
if (this.#pendingType === 'idle' && typeof cancelIdleCallback !== 'undefined') {
cancelIdleCallback(this.#timeoutId as unknown as number);
@@ -331,24 +370,30 @@ export class AppliedFontsManager {
this.#pendingType = null;
}
// Stop periodic cleanup timer
if (this.#intervalId) {
clearInterval(this.#intervalId);
this.#intervalId = null;
}
// Remove all loaded fonts from document
if (typeof document !== 'undefined') {
for (const font of this.#loadedFonts.values()) {
document.fonts.delete(font);
}
}
// Clear all state and collaborators
this.#loadedFonts.clear();
this.#usageTracker.clear();
this.#retryCounts.clear();
this.statuses.clear();
this.#urlByKey.clear();
this.#cache.clear();
this.#eviction.clear();
this.#queue.clear();
this.statuses.clear();
}
}
/** Singleton instance — use throughout the application for unified font loading state. */
/**
* Singleton instance — use throughout the application for unified font loading state.
*/
export const appliedFontsManager = new AppliedFontsManager();

View File

@@ -0,0 +1,35 @@
/**
* Thrown by {@link FontBufferCache} when a font file cannot be retrieved from the network or cache.
*
* @property url - The URL that was requested.
* @property cause - The underlying error, if any.
* @property status - HTTP status code. Present on HTTP errors, absent on network failures.
*/
export class FontFetchError extends Error {
readonly name = 'FontFetchError';
constructor(
public readonly url: string,
public readonly cause?: unknown,
public readonly status?: number,
) {
super(status ? `HTTP ${status} fetching font: ${url}` : `Network error fetching font: ${url}`);
}
}
/**
* Thrown by {@link loadFont} when a font buffer cannot be parsed into a {@link FontFace}.
*
* @property fontName - The display name of the font that failed to parse.
* @property cause - The underlying error from the FontFace API.
*/
export class FontParseError extends Error {
readonly name = 'FontParseError';
constructor(
public readonly fontName: string,
public readonly cause?: unknown,
) {
super(`Failed to parse font: ${fontName}`);
}
}

View File

@@ -0,0 +1,68 @@
/**
* @vitest-environment jsdom
*/
import { FontFetchError } from '../../errors';
import { FontBufferCache } from './FontBufferCache';
const makeBuffer = () => new ArrayBuffer(8);
const makeFetcher = (overrides: Partial<Response> = {}) =>
vi.fn().mockResolvedValue({
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(makeBuffer()),
clone: () => ({ ok: true, status: 200, arrayBuffer: () => Promise.resolve(makeBuffer()) }),
...overrides,
} as Response);
describe('FontBufferCache', () => {
let cache: FontBufferCache;
let fetcher: ReturnType<typeof makeFetcher>;
beforeEach(() => {
fetcher = makeFetcher();
cache = new FontBufferCache({ fetcher });
});
it('returns buffer from memory on second call without fetching', async () => {
await cache.get('https://example.com/font.woff2');
await cache.get('https://example.com/font.woff2');
expect(fetcher).toHaveBeenCalledOnce();
});
it('throws FontFetchError on HTTP error with correct status', async () => {
const errorFetcher = makeFetcher({ ok: false, status: 404 });
const errorCache = new FontBufferCache({ fetcher: errorFetcher });
const err = await errorCache.get('https://example.com/font.woff2').catch(e => e);
expect(err).toBeInstanceOf(FontFetchError);
expect(err.status).toBe(404);
});
it('throws FontFetchError on network failure without status', async () => {
const networkFetcher = vi.fn().mockRejectedValue(new Error('network down'));
const networkCache = new FontBufferCache({ fetcher: networkFetcher });
const err = await networkCache.get('https://example.com/font.woff2').catch(e => e);
expect(err).toBeInstanceOf(FontFetchError);
expect(err.status).toBeUndefined();
});
it('evict removes url from memory so next call fetches again', async () => {
await cache.get('https://example.com/font.woff2');
cache.evict('https://example.com/font.woff2');
await cache.get('https://example.com/font.woff2');
expect(fetcher).toHaveBeenCalledTimes(2);
});
it('clear wipes all memory cache entries', async () => {
await cache.get('https://example.com/a.woff2');
await cache.get('https://example.com/b.woff2');
cache.clear();
await cache.get('https://example.com/a.woff2');
expect(fetcher).toHaveBeenCalledTimes(3);
});
});

View File

@@ -0,0 +1,105 @@
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.
*/
fetcher?: Fetcher;
/**
* Cache API cache name. Defaults to `'font-cache-v1'`.
*/
cacheName?: string;
}
/**
* Three-tier font buffer cache: in-memory → Cache API → network.
*
* - **Tier 1 (memory):** Fastest — no I/O. Populated after first successful fetch.
* - **Tier 2 (Cache API):** Persists across page loads. Silently skipped in private browsing.
* - **Tier 3 (network):** Raw fetch. Throws {@link FontFetchError} on failure.
*
* The `fetcher` option is injectable for testing — pass a `vi.fn()` to avoid real network calls.
*/
export class FontBufferCache {
#buffersByUrl = new Map<string, ArrayBuffer>();
readonly #fetcher: Fetcher;
readonly #cacheName: string;
constructor(
{ fetcher = globalThis.fetch.bind(globalThis), cacheName = 'font-cache-v1' }: FontBufferCacheOptions = {},
) {
this.#fetcher = fetcher;
this.#cacheName = cacheName;
}
/**
* Retrieves the font buffer for the given URL using the three-tier strategy.
* Stores the result in memory on success.
*
* @throws {@link FontFetchError} if the network request fails or returns a non-OK response.
*/
async get(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
// Tier 1: in-memory (fastest, no I/O)
const inMemory = this.#buffersByUrl.get(url);
if (inMemory) {
return inMemory;
}
// Tier 2: Cache API
try {
if (typeof caches !== 'undefined') {
const cache = await caches.open(this.#cacheName);
const cached = await cache.match(url);
if (cached) {
const buffer = await cached.arrayBuffer();
this.#buffersByUrl.set(url, buffer);
return buffer;
}
}
} catch {
// Cache unavailable (private browsing, security restrictions) — fall through to network
}
// Tier 3: network
let response: Response;
try {
response = await this.#fetcher(url, { signal });
} catch (cause) {
throw new FontFetchError(url, cause);
}
if (!response.ok) {
throw new FontFetchError(url, undefined, response.status);
}
try {
if (typeof caches !== 'undefined') {
const cache = await caches.open(this.#cacheName);
await cache.put(url, response.clone());
}
} catch {
// Cache write failed (quota, storage pressure) — return font anyway
}
const buffer = await response.arrayBuffer();
this.#buffersByUrl.set(url, buffer);
return buffer;
}
/**
* 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.
*/
clear(): void {
this.#buffersByUrl.clear();
}
}

View File

@@ -0,0 +1,69 @@
import { FontEvictionPolicy } from './FontEvictionPolicy';
describe('FontEvictionPolicy', () => {
let policy: FontEvictionPolicy;
const TTL = 1000;
const t0 = 100000;
beforeEach(() => {
policy = new FontEvictionPolicy({ ttl: TTL });
});
it('shouldEvict returns false within TTL', () => {
policy.touch('a@400', t0);
expect(policy.shouldEvict('a@400', t0 + TTL - 1)).toBe(false);
});
it('shouldEvict returns true at TTL boundary', () => {
policy.touch('a@400', t0);
expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true);
});
it('shouldEvict returns false for pinned key regardless of TTL', () => {
policy.touch('a@400', t0);
policy.pin('a@400');
expect(policy.shouldEvict('a@400', t0 + TTL * 10)).toBe(false);
});
it('shouldEvict returns true again after unpin past TTL', () => {
policy.touch('a@400', t0);
policy.pin('a@400');
policy.unpin('a@400');
expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true);
});
it('shouldEvict returns false for untracked key', () => {
expect(policy.shouldEvict('never@touched', t0 + TTL * 100)).toBe(false);
});
it('keys returns all tracked keys', () => {
policy.touch('a@400', t0);
policy.touch('b@vf', t0);
expect(Array.from(policy.keys())).toEqual(expect.arrayContaining(['a@400', 'b@vf']));
});
it('remove deletes key from tracking so it no longer appears in keys()', () => {
policy.touch('a@400', t0);
policy.touch('b@vf', t0);
policy.remove('a@400');
expect(Array.from(policy.keys())).not.toContain('a@400');
expect(Array.from(policy.keys())).toContain('b@vf');
});
it('remove unpins the key so a subsequent touch + TTL would evict it', () => {
policy.touch('a@400', t0);
policy.pin('a@400');
policy.remove('a@400');
// re-touch and check it can be evicted again
policy.touch('a@400', t0);
expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true);
});
it('clear resets all state', () => {
policy.touch('a@400', t0);
policy.pin('a@400');
policy.clear();
expect(Array.from(policy.keys())).toHaveLength(0);
expect(policy.shouldEvict('a@400', t0 + TTL * 10)).toBe(false);
});
});

View File

@@ -0,0 +1,88 @@
interface FontEvictionPolicyOptions {
/**
* TTL in milliseconds. Defaults to 5 minutes.
*/
ttl?: number;
}
/**
* Tracks font usage timestamps and pinned keys to determine when a font should be evicted.
*
* Pure data — no browser APIs. Accepts explicit `now` timestamps so tests
* never need fake timers.
*/
export class FontEvictionPolicy {
#usageTracker = new Map<string, number>();
#pinnedFonts = new Set<string>();
readonly #TTL: number;
constructor({ ttl = 5 * 60 * 1000 }: FontEvictionPolicyOptions = {}) {
this.#TTL = ttl;
}
/**
* Records the last-used time for a font key.
* @param key - Font key in `{id}@{weight}` or `{id}@vf` format.
* @param now - Current timestamp in ms. Defaults to `Date.now()`.
*/
touch(key: string, now: number = Date.now()): void {
this.#usageTracker.set(key, now);
}
/**
* 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.
*/
unpin(key: string): void {
this.#pinnedFonts.delete(key);
}
/**
* Returns `true` if the font should be evicted.
* A font is evicted when its TTL has elapsed and it is not pinned.
* Returns `false` for untracked keys.
*
* @param key - Font key to check.
* @param now - Current timestamp in ms (pass explicitly for deterministic tests).
*/
shouldEvict(key: string, now: number): boolean {
const lastUsed = this.#usageTracker.get(key);
if (lastUsed === undefined) {
return false;
}
if (this.#pinnedFonts.has(key)) {
return false;
}
return now - lastUsed >= this.#TTL;
}
/**
* 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.
*/
remove(key: string): void {
this.#usageTracker.delete(key);
this.#pinnedFonts.delete(key);
}
/**
* Clears all usage timestamps and pinned keys.
*/
clear(): void {
this.#usageTracker.clear();
this.#pinnedFonts.clear();
}
}

View File

@@ -0,0 +1,65 @@
import type { FontLoadRequestConfig } from '../../../../types';
import { FontLoadQueue } from './FontLoadQueue';
const config = (id: string): FontLoadRequestConfig => ({
id,
name: id,
url: `https://example.com/${id}.woff2`,
weight: 400,
});
describe('FontLoadQueue', () => {
let queue: FontLoadQueue;
beforeEach(() => {
queue = new FontLoadQueue();
});
it('enqueue returns true for a new key', () => {
expect(queue.enqueue('a@400', config('a'))).toBe(true);
});
it('enqueue returns false for an already-queued key', () => {
queue.enqueue('a@400', config('a'));
expect(queue.enqueue('a@400', config('a'))).toBe(false);
});
it('has returns true after enqueue, false after flush', () => {
queue.enqueue('a@400', config('a'));
expect(queue.has('a@400')).toBe(true);
queue.flush();
expect(queue.has('a@400')).toBe(false);
});
it('flush returns all entries and atomically clears the queue', () => {
queue.enqueue('a@400', config('a'));
queue.enqueue('b@700', config('b'));
const entries = queue.flush();
expect(entries).toHaveLength(2);
expect(queue.has('a@400')).toBe(false);
expect(queue.has('b@700')).toBe(false);
});
it('isMaxRetriesReached returns false below MAX_RETRIES', () => {
queue.incrementRetry('a@400');
queue.incrementRetry('a@400');
expect(queue.isMaxRetriesReached('a@400')).toBe(false);
});
it('isMaxRetriesReached returns true at MAX_RETRIES (3)', () => {
queue.incrementRetry('a@400');
queue.incrementRetry('a@400');
queue.incrementRetry('a@400');
expect(queue.isMaxRetriesReached('a@400')).toBe(true);
});
it('clear resets queue and retry counts', () => {
queue.enqueue('a@400', config('a'));
queue.incrementRetry('a@400');
queue.incrementRetry('a@400');
queue.incrementRetry('a@400');
queue.clear();
expect(queue.has('a@400')).toBe(false);
expect(queue.isMaxRetriesReached('a@400')).toBe(false);
});
});

View File

@@ -0,0 +1,65 @@
import type { FontLoadRequestConfig } from '../../../../types';
/**
* Manages the font load queue and per-font retry counts.
*
* Scheduling (when to drain the queue) is handled by the orchestrator —
* this class is purely concerned with what is queued and whether retries are exhausted.
*/
export class FontLoadQueue {
#queue = new Map<string, FontLoadRequestConfig>();
#retryCounts = new Map<string, number>();
readonly #MAX_RETRIES = 3;
/**
* Adds a font to the queue.
* @returns `true` if the key was newly enqueued, `false` if it was already present.
*/
enqueue(key: string, config: FontLoadRequestConfig): boolean {
if (this.#queue.has(key)) {
return false;
}
this.#queue.set(key, config);
return true;
}
/**
* Atomically snapshots and clears the queue.
* @returns All queued entries at the time of the call.
*/
flush(): Array<[string, FontLoadRequestConfig]> {
const entries = Array.from(this.#queue.entries());
this.#queue.clear();
return entries;
}
/**
* 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.
*/
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.
*/
isMaxRetriesReached(key: string): boolean {
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES;
}
/**
* Clears all queued fonts and resets all retry counts.
*/
clear(): void {
this.#queue.clear();
this.#retryCounts.clear();
}
}

View File

@@ -0,0 +1,25 @@
import { generateFontKey } from './generateFontKey';
describe('generateFontKey', () => {
it('should throw an error if font id is not provided', () => {
const config = { weight: 400, isVariable: false };
// @ts-expect-error
expect(() => generateFontKey(config)).toThrow('Font id is required');
});
it('should generate a font key for a variable font', () => {
const config = { id: 'Roboto', weight: 400, isVariable: true };
expect(generateFontKey(config)).toBe('roboto@vf');
});
it('should throw an error if font weight is not provided and is not a variable font', () => {
const config = { id: 'Roboto', isVariable: false };
// @ts-expect-error
expect(() => generateFontKey(config)).toThrow('Font weight is required');
});
it('should generate a font key for a non-variable font', () => {
const config = { id: 'Roboto', weight: 400, isVariable: false };
expect(generateFontKey(config)).toBe('roboto@400');
});
});

View File

@@ -0,0 +1,22 @@
import type { FontLoadRequestConfig } from '../../../../types';
export type PartialConfig = Pick<FontLoadRequestConfig, 'id' | 'weight' | 'isVariable'>;
/**
* Generates a font key for a given font load request configuration.
* @param config - The font load request configuration.
* @returns The generated font key.
*/
export function generateFontKey(config: PartialConfig): string {
if (!config.id) {
throw new Error('Font id is required');
}
if (config.isVariable) {
return `${config.id.toLowerCase()}@vf`;
}
if (!config.weight) {
throw new Error('Font weight is required');
}
return `${config.id.toLowerCase()}@${config.weight}`;
}

View File

@@ -0,0 +1,41 @@
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import {
Concurrency,
getEffectiveConcurrency,
} from './getEffectiveConcurrency';
describe('getEffectiveConcurrency', () => {
beforeEach(() => {
const nav = navigator as any;
nav.connection = null;
});
it('should return MAX when connection is not available', () => {
const nav = navigator as any;
nav.connection = null;
expect(getEffectiveConcurrency()).toBe(Concurrency.MAX);
});
it('should return MIN for slow-2g or 2g connection', () => {
const nav = navigator as any;
nav.connection = { effectiveType: 'slow-2g' };
expect(getEffectiveConcurrency()).toBe(Concurrency.MIN);
});
it('should return AVERAGE for 3g connection', () => {
const nav = navigator as any;
nav.connection = { effectiveType: '3g' };
expect(getEffectiveConcurrency()).toBe(Concurrency.AVERAGE);
});
it('should return MAX for other connection types', () => {
const nav = navigator as any;
nav.connection = { effectiveType: '4g' };
expect(getEffectiveConcurrency()).toBe(Concurrency.MAX);
});
});

View File

@@ -0,0 +1,26 @@
export enum Concurrency {
MIN = 1,
AVERAGE = 2,
MAX = 4,
}
/**
* Calculates the amount of fonts for concurrent download based on the user internet connection
*/
export function getEffectiveConcurrency(): number {
const nav = navigator as any;
const connection = nav.connection;
if (!connection) {
return Concurrency.MAX;
}
switch (connection.effectiveType) {
case 'slow-2g':
case '2g':
return Concurrency.MIN;
case '3g':
return Concurrency.AVERAGE;
default:
return Concurrency.MAX;
}
}

View File

@@ -0,0 +1,4 @@
export { generateFontKey } from './generateFontKey/generateFontKey';
export { getEffectiveConcurrency } from './getEffectiveConcurrency/getEffectiveConcurrency';
export { loadFont } from './loadFont/loadFont';
export { yieldToMainThread } from './yieldToMainThread/yieldToMainThread';

View File

@@ -0,0 +1,95 @@
/**
* @vitest-environment jsdom
*/
import { FontParseError } from '../../errors';
import { loadFont } from './loadFont';
describe('loadFont', () => {
let mockFontInstance: any;
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockFontFaceSet = { add: vi.fn(), delete: vi.fn() };
Object.defineProperty(document, 'fonts', { value: mockFontFaceSet, configurable: true, writable: true });
const MockFontFace = vi.fn(
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) {
this.name = name;
this.buffer = buffer;
this.options = options;
this.load = vi.fn().mockResolvedValue(this);
mockFontInstance = this;
},
);
vi.stubGlobal('FontFace', MockFontFace);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('constructs FontFace with exact weight for static fonts', async () => {
const buffer = new ArrayBuffer(8);
await loadFont({ name: 'Roboto', weight: 400 }, buffer);
expect(FontFace).toHaveBeenCalledWith('Roboto', buffer, expect.objectContaining({ weight: '400' }));
});
it('constructs FontFace with weight range for variable fonts', async () => {
const buffer = new ArrayBuffer(8);
await loadFont({ name: 'Roboto', weight: 400, isVariable: true }, buffer);
expect(FontFace).toHaveBeenCalledWith('Roboto', buffer, expect.objectContaining({ weight: '100 900' }));
});
it('sets style: normal and display: swap on FontFace options', async () => {
await loadFont({ name: 'Lato', weight: 700 }, new ArrayBuffer(8));
expect(FontFace).toHaveBeenCalledWith(
'Lato',
expect.anything(),
expect.objectContaining({ style: 'normal', display: 'swap' }),
);
});
it('passes the buffer as the second argument to FontFace', async () => {
const buffer = new ArrayBuffer(16);
await loadFont({ name: 'Inter', weight: 400 }, buffer);
expect(FontFace).toHaveBeenCalledWith('Inter', buffer, expect.anything());
});
it('calls font.load() and adds the font to document.fonts', async () => {
const buffer = new ArrayBuffer(8);
const result = await loadFont({ name: 'Inter', weight: 400 }, buffer);
expect(mockFontInstance.load).toHaveBeenCalledOnce();
expect(mockFontFaceSet.add).toHaveBeenCalledWith(mockFontInstance);
expect(result).toBe(mockFontInstance);
});
it('throws FontParseError when font.load() rejects', async () => {
const loadError = new Error('parse failed');
const MockFontFace = vi.fn(
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) {
this.load = vi.fn().mockRejectedValue(loadError);
},
);
vi.stubGlobal('FontFace', MockFontFace);
await expect(loadFont({ name: 'Broken', weight: 400 }, new ArrayBuffer(8))).rejects.toBeInstanceOf(
FontParseError,
);
});
it('throws FontParseError when document.fonts.add throws', async () => {
const addError = new Error('add failed');
mockFontFaceSet.add.mockImplementation(() => {
throw addError;
});
await expect(loadFont({ name: 'Broken', weight: 400 }, new ArrayBuffer(8))).rejects.toBeInstanceOf(
FontParseError,
);
});
});

View File

@@ -0,0 +1,27 @@
import type { FontLoadRequestConfig } from '../../../../types';
import { FontParseError } from '../../errors';
export type PartialConfig = Pick<FontLoadRequestConfig, 'weight' | 'name' | 'isVariable'>;
/**
* Loads a font from a buffer and adds it to the document's font collection.
* @param config - The font load request configuration.
* @param buffer - The buffer containing the font data.
* @returns A promise that resolves to the loaded `FontFace`.
* @throws {@link FontParseError} When the font buffer cannot be parsed or added to the document font set.
*/
export async function loadFont(config: PartialConfig, buffer: BufferSource): Promise<FontFace> {
try {
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
const font = new FontFace(config.name, buffer, {
weight: weightRange,
style: 'normal',
display: 'swap',
});
await font.load();
document.fonts.add(font);
return font;
} catch (error) {
throw new FontParseError(config.name, error);
}
}

View File

@@ -0,0 +1,17 @@
import { yieldToMainThread } from './yieldToMainThread';
describe('yieldToMainThread', () => {
it('uses scheduler.yield when available', async () => {
const mockYield = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('scheduler', { yield: mockYield });
await yieldToMainThread();
expect(mockYield).toHaveBeenCalledOnce();
vi.unstubAllGlobals();
});
it('falls back to MessageChannel when scheduler is unavailable', async () => {
// scheduler is not defined in jsdom by default
await expect(yieldToMainThread()).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,16 @@
/**
* Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback.
*/
export async function yieldToMainThread(): Promise<void> {
// @ts-expect-error - scheduler not in TypeScript lib yet
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
await scheduler.yield();
} else {
await new Promise<void>(resolve => {
const ch = new MessageChannel();
ch.port1.onmessage = () => resolve();
ch.port2.postMessage(null);
});
}
}

View File

@@ -1,210 +0,0 @@
import { queryClient } from '$shared/api/queryClient';
import {
type QueryKey,
QueryObserver,
type QueryObserverOptions,
type QueryObserverResult,
} from '@tanstack/query-core';
import type { UnifiedFont } from '../types';
/**
* Base class for font stores using TanStack Query
*
* Provides reactive font data fetching with caching, automatic refetching,
* and parameter binding. Extended by UnifiedFontStore for provider-agnostic
* font fetching.
*
* @template TParams - Type of query parameters
*/
export abstract class BaseFontStore<TParams extends Record<string, any>> {
/**
* Cleanup function for effects
* Call destroy() to remove effects and prevent memory leaks
*/
cleanup: () => void;
/** Reactive parameter bindings from external sources */
#bindings = $state<(() => Partial<TParams>)[]>([]);
/** Internal parameter state */
#internalParams = $state<TParams>({} as TParams);
/**
* Merged params from internal state and all bindings
* Automatically updates when bindings or internal params change
*/
params = $derived.by(() => {
let merged = { ...this.#internalParams };
// Merge all binding results into params
for (const getter of this.#bindings) {
const bindingResult = getter();
merged = { ...merged, ...bindingResult };
}
return merged as TParams;
});
/** TanStack Query result state */
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
/** TanStack Query observer instance */
protected observer: QueryObserver<UnifiedFont[], Error>;
/** Shared query client */
protected qc = queryClient;
/**
* Creates a new base font store
* @param initialParams - Initial query parameters
*/
constructor(initialParams: TParams) {
this.#internalParams = initialParams;
this.observer = new QueryObserver(this.qc, this.getOptions());
// Sync TanStack Query state -> Svelte state
this.observer.subscribe(r => {
this.result = r;
});
// Sync Svelte state changes -> TanStack Query options
this.cleanup = $effect.root(() => {
$effect(() => {
this.observer.setOptions(this.getOptions());
});
});
}
/**
* Must be implemented by child class
* Returns the query key for TanStack Query caching
*/
protected abstract getQueryKey(params: TParams): QueryKey;
/**
* Must be implemented by child class
* Fetches font data from API
*/
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
/**
* Gets TanStack Query options
* @param params - Query parameters (defaults to current params)
*/
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
return {
queryKey: this.getQueryKey(params),
queryFn: () => this.fetchFn(params),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
};
}
/** Array of fonts (empty array if loading/error) */
get fonts() {
return this.result.data ?? [];
}
/** Whether currently fetching initial data */
get isLoading() {
return this.result.isLoading;
}
/** Whether any fetch is in progress (including refetches) */
get isFetching() {
return this.result.isFetching;
}
/** Whether last fetch resulted in an error */
get isError() {
return this.result.isError;
}
/** Whether no fonts are loaded (not loading and empty array) */
get isEmpty() {
return !this.isLoading && this.fonts.length === 0;
}
/**
* Add a reactive parameter binding
* @param getter - Function that returns partial params to merge
* @returns Unbind function to remove the binding
*/
addBinding(getter: () => Partial<TParams>) {
this.#bindings.push(getter);
return () => {
this.#bindings = this.#bindings.filter(b => b !== getter);
};
}
/**
* Update query parameters
* @param newParams - Partial params to merge with existing
*/
setParams(newParams: Partial<TParams>) {
this.#internalParams = { ...this.params, ...newParams };
}
/**
* Invalidate cache and refetch
*/
invalidate() {
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
}
/**
* Clean up effects and observers
*/
destroy() {
this.cleanup();
}
/**
* Manually trigger a refetch
*/
async refetch() {
await this.observer.refetch();
}
/**
* Prefetch data with different parameters
*/
async prefetch(params: TParams) {
await this.qc.prefetchQuery(this.getOptions(params));
}
/**
* Cancel ongoing queries
*/
cancel() {
this.qc.cancelQueries({
queryKey: this.getQueryKey(this.params),
});
}
/**
* Clear cache for current params
*/
clearCache() {
this.qc.removeQueries({
queryKey: this.getQueryKey(this.params),
});
}
/**
* Get cached data without triggering fetch
*/
getCachedData() {
return this.qc.getQueryData<UnifiedFont[]>(
this.getQueryKey(this.params),
);
}
/**
* Set data manually (optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
this.qc.setQueryData(
this.getQueryKey(this.params),
updater,
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,564 @@
import { QueryClient } from '@tanstack/query-core';
import { flushSync } from 'svelte';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import {
generateMixedCategoryFonts,
generateMockFonts,
} from '../../../lib/mocks/fonts.mock';
import type { UnifiedFont } from '../../types';
import { FontStore } from './fontStore.svelte';
vi.mock('$shared/api/queryClient', () => ({
queryClient: new QueryClient({
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
}),
}));
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
import { queryClient } from '$shared/api/queryClient';
import { fetchProxyFonts } from '../../../api';
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
const makeResponse = (
fonts: UnifiedFont[],
meta: { total?: number; limit?: number; offset?: number } = {},
): FontPage => ({
fonts,
total: meta.total ?? fonts.length,
limit: meta.limit ?? 10,
offset: meta.offset ?? 0,
});
function makeStore(params = {}) {
return new FontStore({ limit: 10, ...params });
}
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
fetch.mockResolvedValue(makeResponse(fonts, meta));
const store = makeStore(params);
await store.refetch();
flushSync();
return store;
}
describe('FontStore', () => {
afterEach(() => {
queryClient.clear();
vi.resetAllMocks();
});
describe('construction', () => {
it('stores initial params', () => {
const store = makeStore({ limit: 20 });
expect(store.params.limit).toBe(20);
store.destroy();
});
it('defaults limit to 50 when not provided', () => {
const store = new FontStore();
expect(store.params.limit).toBe(50);
store.destroy();
});
it('starts with empty fonts', () => {
const store = makeStore();
expect(store.fonts).toEqual([]);
store.destroy();
});
it('starts with isEmpty false — initial fetch is in progress', () => {
// The observer starts fetching immediately on construction.
// isEmpty must be false so the UI shows a loader, not "no results".
const store = makeStore();
expect(store.isEmpty).toBe(false);
store.destroy();
});
});
describe('state after fetch', () => {
it('exposes loaded fonts', async () => {
const store = await fetchedStore({}, generateMockFonts(7));
expect(store.fonts).toHaveLength(7);
store.destroy();
});
it('isEmpty is false when fonts are present', async () => {
const store = await fetchedStore();
expect(store.isEmpty).toBe(false);
store.destroy();
});
it('isLoading is false after fetch', async () => {
const store = await fetchedStore();
expect(store.isLoading).toBe(false);
store.destroy();
});
it('isFetching is false after fetch', async () => {
const store = await fetchedStore();
expect(store.isFetching).toBe(false);
store.destroy();
});
it('isError is false on success', async () => {
const store = await fetchedStore();
expect(store.isError).toBe(false);
store.destroy();
});
it('error is null on success', async () => {
const store = await fetchedStore();
expect(store.error).toBeNull();
store.destroy();
});
});
describe('error states', () => {
it('isError is false before any fetch', () => {
const store = makeStore();
expect(store.isError).toBe(false);
store.destroy();
});
it('wraps network failures in FontNetworkError', async () => {
fetch.mockRejectedValue(new Error('network down'));
const store = makeStore();
await store.refetch().catch(() => {});
flushSync();
expect(store.error).toBeInstanceOf(FontNetworkError);
expect(store.isError).toBe(true);
store.destroy();
});
it('exposes FontResponseError for falsy response', async () => {
const store = makeStore();
fetch.mockResolvedValue(null);
await store.refetch().catch(() => {});
flushSync();
expect(store.error).toBeInstanceOf(FontResponseError);
expect((store.error as FontResponseError).field).toBe('response');
store.destroy();
});
it('exposes FontResponseError for missing fonts field', async () => {
fetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 });
const store = makeStore();
await store.refetch().catch(() => {});
flushSync();
expect(store.error).toBeInstanceOf(FontResponseError);
expect((store.error as FontResponseError).field).toBe('response.fonts');
store.destroy();
});
it('exposes FontResponseError for non-array fonts', async () => {
fetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 });
const store = makeStore();
await store.refetch().catch(() => {});
flushSync();
expect(store.error).toBeInstanceOf(FontResponseError);
expect((store.error as FontResponseError).received).toBe('bad');
store.destroy();
});
});
describe('font accumulation', () => {
it('replaces fonts when refetching the first page', async () => {
const store = makeStore();
fetch.mockResolvedValue(makeResponse(generateMockFonts(3)));
await store.refetch();
flushSync();
const second = generateMockFonts(2);
fetch.mockResolvedValue(makeResponse(second));
await store.refetch();
flushSync();
// refetch at offset=0 re-fetches all pages; only one page loaded → new data replaces old
expect(store.fonts).toHaveLength(2);
expect(store.fonts[0].id).toBe(second[0].id);
store.destroy();
});
it('appends fonts after nextPage', async () => {
const page1 = generateMockFonts(3);
const store = await fetchedStore({ limit: 3 }, page1, { total: 6, limit: 3, offset: 0 });
const page2 = generateMockFonts(3).map((f, i) => ({ ...f, id: `p2-${i}` }));
fetch.mockResolvedValue(makeResponse(page2, { total: 6, limit: 3, offset: 3 }));
await store.nextPage();
flushSync();
expect(store.fonts).toHaveLength(6);
expect(store.fonts.slice(0, 3).map(f => f.id)).toEqual(page1.map(f => f.id));
expect(store.fonts.slice(3).map(f => f.id)).toEqual(page2.map(f => f.id));
store.destroy();
});
});
describe('pagination state', () => {
it('returns zero-value defaults before any fetch', () => {
const store = makeStore();
expect(store.pagination).toMatchObject({ total: 0, hasMore: false, page: 1, totalPages: 0 });
store.destroy();
});
it('reflects response metadata after fetch', async () => {
const store = await fetchedStore({}, generateMockFonts(10), { total: 30, limit: 10, offset: 0 });
expect(store.pagination.total).toBe(30);
expect(store.pagination.hasMore).toBe(true);
expect(store.pagination.page).toBe(1);
expect(store.pagination.totalPages).toBe(3);
store.destroy();
});
it('hasMore is false on the last page', async () => {
const store = await fetchedStore({}, generateMockFonts(10), { total: 10, limit: 10, offset: 0 });
expect(store.pagination.hasMore).toBe(false);
store.destroy();
});
it('page count increments after nextPage', async () => {
const store = await fetchedStore({ limit: 10 }, generateMockFonts(10), { total: 30, limit: 10, offset: 0 });
expect(store.pagination.page).toBe(1);
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
await store.nextPage();
flushSync();
expect(store.pagination.page).toBe(2);
store.destroy();
});
});
describe('setParams', () => {
it('merges updates into existing params', () => {
const store = makeStore({ limit: 10 });
store.setParams({ limit: 20 });
expect(store.params.limit).toBe(20);
store.destroy();
});
it('retains unmodified params', () => {
const store = makeStore({ limit: 10 });
store.setCategories(['serif']);
store.setParams({ limit: 25 });
expect(store.params.categories).toEqual(['serif']);
store.destroy();
});
});
describe('filter change resets', () => {
it('clears accumulated fonts when a filter changes', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
store.setSearch('roboto');
flushSync();
// TQ switches to a new queryKey → data.pages reset → fonts = []
expect(store.fonts).toHaveLength(0);
store.destroy();
});
it('isEmpty is false immediately after filter change — fetch is in progress', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
// Hang the next fetch so we can observe the transitioning state
fetch.mockReturnValue(new Promise(() => {}));
store.setSearch('roboto');
flushSync();
// fonts = [] AND isFetching = true → isEmpty must be false (no "no results" flash)
expect(store.isEmpty).toBe(false);
store.destroy();
});
it('does NOT reset fonts when the same filter value is set again', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
store.setCategories(['serif']);
flushSync();
// First change: clears fonts (expected)
store.setCategories(['serif']); // same value — same queryKey — TQ keeps data.pages
flushSync();
// Because queryKey hasn't changed, TQ returns cached data — fonts restored from cache
// (actual font count depends on cache; key assertion is no extra reset)
expect(store.isError).toBe(false);
store.destroy();
});
});
describe('staleTime in buildOptions', () => {
it('is 5 minutes with no active filters', () => {
const store = makeStore();
expect((store as any).buildOptions().staleTime).toBe(5 * 60 * 1000);
store.destroy();
});
it('is 0 when a search query is active', () => {
const store = makeStore();
store.setSearch('roboto');
expect((store as any).buildOptions().staleTime).toBe(0);
store.destroy();
});
it('is 0 when a category filter is active', () => {
const store = makeStore();
store.setCategories(['serif']);
expect((store as any).buildOptions().staleTime).toBe(0);
store.destroy();
});
it('gcTime is 10 minutes always', () => {
const store = makeStore();
expect((store as any).buildOptions().gcTime).toBe(10 * 60 * 1000);
store.destroy();
});
});
describe('buildQueryKey', () => {
it('omits empty-string params', () => {
const store = makeStore();
store.setSearch('');
const [root, normalized] = (store as any).buildQueryKey(store.params);
expect(root).toBe('fonts');
expect(normalized).not.toHaveProperty('q');
store.destroy();
});
it('omits empty-array params', () => {
const store = makeStore();
store.setProviders([]);
const [, normalized] = (store as any).buildQueryKey(store.params);
expect(normalized).not.toHaveProperty('providers');
store.destroy();
});
it('includes non-empty filter values', () => {
const store = makeStore();
store.setCategories(['serif']);
const [, normalized] = (store as any).buildQueryKey(store.params);
expect(normalized).toHaveProperty('categories', ['serif']);
store.destroy();
});
it('does not include offset (offset is the TQ page param, not a query key component)', () => {
const store = makeStore();
const [, normalized] = (store as any).buildQueryKey(store.params);
expect(normalized).not.toHaveProperty('offset');
store.destroy();
});
});
describe('destroy', () => {
it('does not throw', () => {
const store = makeStore();
expect(() => store.destroy()).not.toThrow();
});
it('is idempotent', () => {
const store = makeStore();
store.destroy();
expect(() => store.destroy()).not.toThrow();
});
});
describe('refetch', () => {
it('triggers a fetch', async () => {
const store = makeStore();
fetch.mockResolvedValue(makeResponse(generateMockFonts(3)));
await store.refetch();
expect(fetch).toHaveBeenCalled();
store.destroy();
});
it('uses params current at call time', async () => {
const store = makeStore({ limit: 10 });
store.setParams({ limit: 20 });
fetch.mockResolvedValue(makeResponse(generateMockFonts(20)));
await store.refetch();
expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 }));
store.destroy();
});
});
describe('nextPage', () => {
let store: FontStore;
beforeEach(async () => {
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
store = new FontStore({ limit: 10 });
await store.refetch();
flushSync();
});
afterEach(() => {
store.destroy();
});
it('fetches the next page and appends fonts', async () => {
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
await store.nextPage();
flushSync();
expect(store.fonts).toHaveLength(20);
expect(store.pagination.offset).toBe(10);
});
it('is a no-op when hasMore is false', async () => {
// Set up a store where all fonts fit in one page (hasMore = false)
queryClient.clear();
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
store = new FontStore({ limit: 10 });
await store.refetch();
flushSync();
expect(store.pagination.hasMore).toBe(false);
await store.nextPage(); // should not trigger another fetch
expect(store.fonts).toHaveLength(10);
});
});
describe('prevPage and goToPage', () => {
it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
store.prevPage();
expect(store.fonts).toHaveLength(5); // unchanged
store.destroy();
});
it('goToPage is a no-op — infinite scroll does not support arbitrary page jumps', async () => {
const store = await fetchedStore({}, generateMockFonts(5));
store.goToPage(3);
expect(store.fonts).toHaveLength(5); // unchanged
store.destroy();
});
});
describe('prefetch', () => {
it('triggers a fetch for the provided params', async () => {
const store = makeStore();
fetch.mockResolvedValue(makeResponse(generateMockFonts(5)));
await store.prefetch({ limit: 5 });
expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 5, offset: 0 }));
store.destroy();
});
});
describe('getCachedData / setQueryData', () => {
it('getCachedData returns undefined before any fetch', () => {
queryClient.clear();
const store = new FontStore({ limit: 10 });
expect(store.getCachedData()).toBeUndefined();
store.destroy();
});
it('getCachedData returns flattened fonts after fetch', async () => {
const store = await fetchedStore();
expect(store.getCachedData()).toHaveLength(5);
store.destroy();
});
it('setQueryData writes to cache', () => {
const store = makeStore();
const font = generateMockFonts(1)[0];
store.setQueryData(() => [font]);
expect(store.getCachedData()).toHaveLength(1);
store.destroy();
});
it('setQueryData updater receives existing flattened fonts', async () => {
const store = await fetchedStore();
const updater = vi.fn((old: UnifiedFont[] | undefined) => old ?? []);
store.setQueryData(updater);
expect(updater).toHaveBeenCalledWith(expect.any(Array));
store.destroy();
});
});
describe('invalidate', () => {
it('calls invalidateQueries', async () => {
const store = await fetchedStore();
const spy = vi.spyOn(queryClient, 'invalidateQueries');
store.invalidate();
expect(spy).toHaveBeenCalledOnce();
store.destroy();
});
});
describe('setLimit', () => {
it('updates the limit param', () => {
const store = makeStore({ limit: 10 });
store.setLimit(25);
expect(store.params.limit).toBe(25);
store.destroy();
});
});
describe('filter shortcut methods', () => {
let store: FontStore;
beforeEach(() => {
store = makeStore();
});
afterEach(() => {
store.destroy();
});
it('setProviders updates providers param', () => {
store.setProviders(['google']);
expect(store.params.providers).toEqual(['google']);
});
it('setCategories updates categories param', () => {
store.setCategories(['serif']);
expect(store.params.categories).toEqual(['serif']);
});
it('setSubsets updates subsets param', () => {
store.setSubsets(['cyrillic']);
expect(store.params.subsets).toEqual(['cyrillic']);
});
it('setSearch sets q param', () => {
store.setSearch('roboto');
expect(store.params.q).toBe('roboto');
});
it('setSearch with empty string clears q', () => {
store.setSearch('roboto');
store.setSearch('');
expect(store.params.q).toBeUndefined();
});
it('setSort updates sort param', () => {
store.setSort('popularity');
expect(store.params.sort).toBe('popularity');
});
});
describe('category getters', () => {
it('each getter returns only fonts of that category', async () => {
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total
fetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
const store = makeStore({ limit: 50 });
await store.refetch();
flushSync();
expect(store.sansSerifFonts.every(f => f.category === 'sans-serif')).toBe(true);
expect(store.serifFonts.every(f => f.category === 'serif')).toBe(true);
expect(store.displayFonts.every(f => f.category === 'display')).toBe(true);
expect(store.handwritingFonts.every(f => f.category === 'handwriting')).toBe(true);
expect(store.monospaceFonts.every(f => f.category === 'monospace')).toBe(true);
expect(store.sansSerifFonts).toHaveLength(2);
store.destroy();
});
});
});

View File

@@ -0,0 +1,364 @@
import { queryClient } from '$shared/api/queryClient';
import {
type InfiniteData,
InfiniteQueryObserver,
type InfiniteQueryObserverResult,
type QueryFunctionContext,
} from '@tanstack/query-core';
import {
type ProxyFontsParams,
type ProxyFontsResponse,
fetchProxyFonts,
} from '../../../api';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
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.
*/
type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
export class FontStore {
#params = $state<FontStoreParams>({ limit: 50 });
#result = $state<FontStoreResult>({} as FontStoreResult);
#observer: InfiniteQueryObserver<
ProxyFontsResponse,
Error,
InfiniteData<ProxyFontsResponse, PageParam>,
readonly unknown[],
PageParam
>;
#qc = queryClient;
#unsubscribe: () => void;
constructor(params: FontStoreParams = {}) {
this.#params = { limit: 50, ...params };
this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions());
this.#unsubscribe = this.#observer.subscribe(r => {
this.#result = r;
});
}
/**
* 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;
}
/**
* 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);
if (!last) {
return {
total: 0,
limit: this.#params.limit ?? 50,
offset: 0,
hasMore: false,
page: 1,
totalPages: 0,
};
}
return {
total: last.total,
limit: last.limit,
offset: last.offset,
hasMore: this.#result.hasNextPage,
page: pages!.length,
totalPages: Math.ceil(last.total / last.limit),
};
}
/**
* Cleans up subscriptions and destroys the observer
*/
destroy() {
this.#unsubscribe();
this.#observer.destroy();
}
/**
* 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) });
}
/**
* 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;
}
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>>(
key,
old => {
const flatFonts = old?.pages.flatMap(p => p.fonts);
const newFonts = updater(flatFonts);
// Re-distribute the updated fonts back into the existing page structure
// Define the first page. If old data exists, we merge into the first page template.
const limit = typeof this.#params.limit === 'number' ? this.#params.limit : 50;
const template = old?.pages[0] ?? {
total: newFonts.length,
limit,
offset: 0,
};
const updatedPage: ProxyFontsResponse = {
...template,
fonts: newFonts,
total: newFonts.length, // Synchronize total with the new font count
};
return {
pages: [updatedPage],
pageParams: [{ offset: 0 }],
};
},
);
}
/**
* 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 });
}
/**
* Fetch the next page of results if available
*/
async nextPage(): Promise<void> {
await this.#observer.fetchNextPage();
}
/**
* 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 });
}
/**
* 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 buildQueryKey(params: FontStoreParams): readonly unknown[] {
const filtered: Record<string, any> = {};
for (const [key, value] of Object.entries(params)) {
// Ensure we DO NOT 'continue' or skip the limit key here.
// The limit is a fundamental part of the data identity.
if (
value !== undefined
&& value !== null
&& value !== ''
&& !(Array.isArray(value) && value.length === 0)
) {
filtered[key] = value;
}
}
return ['fonts', filtered];
}
private buildOptions(params = this.#params) {
const activeParams = { ...params };
const hasFilters = !!(
activeParams.q
|| (Array.isArray(activeParams.providers) && activeParams.providers.length > 0)
|| (Array.isArray(activeParams.categories) && activeParams.categories.length > 0)
|| (Array.isArray(activeParams.subsets) && activeParams.subsets.length > 0)
);
return {
queryKey: this.buildQueryKey(activeParams),
queryFn: ({ pageParam }: QueryFunctionContext<readonly unknown[], PageParam>) =>
this.fetchPage({ ...activeParams, ...pageParam }),
initialPageParam: { offset: 0 } as PageParam,
getNextPageParam: (lastPage: ProxyFontsResponse): PageParam | undefined => {
const next = lastPage.offset + lastPage.limit;
return next < lastPage.total ? { offset: next } : undefined;
},
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
};
}
private async fetchPage(params: ProxyFontsParams): Promise<ProxyFontsResponse> {
let response: ProxyFontsResponse;
try {
response = await fetchProxyFonts(params);
} catch (cause) {
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);
}
return {
fonts: response.fonts,
total: response.total ?? 0,
limit: response.limit ?? params.limit ?? 50,
offset: response.offset ?? params.offset ?? 0,
};
}
}
export function createFontStore(params: FontStoreParams = {}): FontStore {
return new FontStore(params);
}
export const fontStore = new FontStore({ limit: 50 });

View File

@@ -1,20 +1,12 @@
/**
* ============================================================================
* UNIFIED FONT STORE EXPORTS
* ============================================================================
*
* Single export point for the unified font store infrastructure.
*/
// Applied fonts manager
export * from './appliedFontsStore/appliedFontsStore.svelte';
// Primary store (unified)
export {
createUnifiedFontStore,
type UnifiedFontStore,
unifiedFontStore,
} from './unifiedFontStore.svelte';
// Batch font store
export { BatchFontStore } from './batchFontStore.svelte';
// Applied fonts manager (CSS loading - unchanged)
// Single FontStore
export {
appliedFontsManager,
type FontConfigRequest,
} from './appliedFontsStore/appliedFontsStore.svelte';
createFontStore,
FontStore,
fontStore,
} from './fontStore/fontStore.svelte';

View File

@@ -1,373 +0,0 @@
/**
* Unified font store
*
* Single source of truth for font data, powered by the proxy API.
* Extends BaseFontStore for TanStack Query integration and reactivity.
*
* Key features:
* - Provider-agnostic (proxy API handles provider logic)
* - Reactive to filter changes
* - Optimistic updates via TanStack Query
* - Pagination support
* - Provider-specific shortcuts for common operations
*/
import type { QueryObserverOptions } from '@tanstack/query-core';
import type { ProxyFontsParams } from '../../api';
import { fetchProxyFonts } from '../../api';
import type { UnifiedFont } from '../types';
import { BaseFontStore } from './baseFontStore.svelte';
/**
* Unified font store wrapping TanStack Query with Svelte 5 runes
*
* Extends BaseFontStore to provide:
* - Reactive state management
* - TanStack Query integration for caching
* - Dynamic parameter binding for filters
* - Pagination support
*
* @example
* ```ts
* const store = new UnifiedFontStore({
* provider: 'google',
* category: 'sans-serif',
* limit: 50
* });
*
* // Access reactive state
* $effect(() => {
* console.log(store.fonts);
* console.log(store.isLoading);
* console.log(store.pagination);
* });
*
* // Update parameters
* store.setCategories(['serif']);
* store.nextPage();
* ```
*/
export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
/**
* Store pagination metadata separately from fonts
* This is a workaround for TanStack Query's type system
*/
#paginationMetadata = $state<
{
total: number;
limit: number;
offset: number;
} | null
>(null);
/**
* Accumulated fonts from all pages (for infinite scroll)
*/
#accumulatedFonts = $state<UnifiedFont[]>([]);
/**
* Pagination metadata (derived from proxy API response)
*/
readonly pagination = $derived.by(() => {
if (this.#paginationMetadata) {
const { total, limit, offset } = this.#paginationMetadata;
return {
total,
limit,
offset,
hasMore: offset + limit < total,
page: Math.floor(offset / limit) + 1,
totalPages: Math.ceil(total / limit),
};
}
return {
total: 0,
limit: this.params.limit || 50,
offset: this.params.offset || 0,
hasMore: false,
page: 1,
totalPages: 0,
};
});
/**
* Track previous filter params to detect changes and reset pagination
*/
#previousFilterParams = $state<string>('');
/**
* Cleanup function for the filter tracking effect
*/
#filterCleanup: (() => void) | null = null;
constructor(initialParams: ProxyFontsParams = {}) {
super(initialParams);
// Track filter params (excluding pagination params)
// Wrapped in $effect.root() to prevent effect_orphan error
this.#filterCleanup = $effect.root(() => {
$effect(() => {
const filterParams = JSON.stringify({
providers: this.params.providers,
categories: this.params.categories,
subsets: this.params.subsets,
q: this.params.q,
});
// If filters changed, reset offset and invalidate cache
if (filterParams !== this.#previousFilterParams) {
if (this.#previousFilterParams) {
if (this.params.offset !== 0) {
this.setParams({ offset: 0 });
}
this.#accumulatedFonts = [];
this.invalidate();
}
this.#previousFilterParams = filterParams;
}
});
// Effect: Sync state from Query result (Handles Cache Hits)
$effect(() => {
const data = this.result.data;
const offset = this.params.offset || 0;
// When we have data and we are at the start (offset 0),
// we must ensure accumulatedFonts matches the fresh (or cached) data.
// This fixes the issue where cache hits skip fetchFn side-effects.
if (offset === 0 && data && data.length > 0) {
this.#accumulatedFonts = data;
}
});
});
}
/**
* Clean up both parent and child effects
*/
destroy() {
// Call parent cleanup (TanStack observer effect)
super.destroy();
// Call filter tracking effect cleanup
if (this.#filterCleanup) {
this.#filterCleanup();
this.#filterCleanup = null;
}
}
/**
* Query key for TanStack Query caching
* Normalizes params to treat empty arrays/strings as undefined
*/
protected getQueryKey(params: ProxyFontsParams) {
// Normalize params to treat empty arrays/strings as undefined
const normalized = Object.entries(params).reduce((acc, [key, value]) => {
if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) {
return acc;
}
return { ...acc, [key]: value };
}, {});
// Return a consistent key
return ['unifiedFonts', normalized] as const;
}
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
const hasFilters = !!(params.q || params.providers || params.categories || params.subsets);
return {
queryKey: this.getQueryKey(params),
queryFn: () => this.fetchFn(params),
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
};
}
/**
* Fetch function that calls the proxy API
* Returns the full response including pagination metadata
*/
protected async fetchFn(params: ProxyFontsParams): Promise<UnifiedFont[]> {
const response = await fetchProxyFonts(params);
// Validate response structure
if (!response) {
console.error('[UnifiedFontStore] fetchProxyFonts returned undefined', { params });
throw new Error('Proxy API returned undefined response');
}
if (!response.fonts) {
console.error('[UnifiedFontStore] response.fonts is undefined', { response });
throw new Error('Proxy API response missing fonts array');
}
if (!Array.isArray(response.fonts)) {
console.error('[UnifiedFontStore] response.fonts is not an array', {
fonts: response.fonts,
});
throw new Error('Proxy API fonts is not an array');
}
// Store pagination metadata separately for derived values
this.#paginationMetadata = {
total: response.total ?? 0,
limit: response.limit ?? this.params.limit ?? 50,
offset: response.offset ?? this.params.offset ?? 0,
};
// Accumulate fonts for infinite scroll
// Note: For offset === 0, we rely on the $effect above to handle the reset/init
// This prevents race conditions and double-setting.
if (params.offset !== 0) {
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
}
return response.fonts;
}
/**
* Get all accumulated fonts (for infinite scroll)
*/
get fonts(): UnifiedFont[] {
return this.#accumulatedFonts;
}
/**
* Check if loading initial data
*/
get isLoading(): boolean {
return this.result.isLoading;
}
/**
* Check if fetching (including background refetches)
*/
get isFetching(): boolean {
return this.result.isFetching;
}
/**
* Check if error occurred
*/
get isError(): boolean {
return this.result.isError;
}
/**
* Check if result is empty (not loading and no fonts)
*/
get isEmpty(): boolean {
return !this.isLoading && this.fonts.length === 0;
}
/**
* Set providers filter
*/
setProviders(providers: ProxyFontsParams['providers']) {
this.setParams({ providers });
}
/**
* Set categories filter
*/
setCategories(categories: ProxyFontsParams['categories']) {
this.setParams({ categories });
}
/**
* Set subsets filter
*/
setSubsets(subsets: ProxyFontsParams['subsets']) {
this.setParams({ subsets });
}
/**
* Set search query
*/
setSearch(search: string) {
this.setParams({ q: search || undefined });
}
/**
* Set sort order
*/
setSort(sort: ProxyFontsParams['sort']) {
this.setParams({ sort });
}
/**
* Go to next page
*/
nextPage() {
if (this.pagination.hasMore) {
this.setParams({
offset: this.pagination.offset + this.pagination.limit,
});
}
}
/**
* Go to previous page
*/
prevPage() {
if (this.pagination.page > 1) {
this.setParams({
offset: this.pagination.offset - this.pagination.limit,
});
}
}
/**
* Go to specific page
*/
goToPage(page: number) {
if (page >= 1 && page <= this.pagination.totalPages) {
this.setParams({
offset: (page - 1) * this.pagination.limit,
});
}
}
/**
* Set limit (items per page)
*/
setLimit(limit: number) {
this.setParams({ limit });
}
get sansSerifFonts() {
return this.fonts.filter(f => f.category === 'sans-serif');
}
get serifFonts() {
return this.fonts.filter(f => f.category === 'serif');
}
get displayFonts() {
return this.fonts.filter(f => f.category === 'display');
}
get handwritingFonts() {
return this.fonts.filter(f => f.category === 'handwriting');
}
get monospaceFonts() {
return this.fonts.filter(f => f.category === 'monospace');
}
}
/**
* Factory function to create unified font store
*/
export function createUnifiedFontStore(params: ProxyFontsParams = {}) {
return new UnifiedFontStore(params);
}
/**
* Singleton instance for global use
* Initialized with a default limit to prevent fetching all fonts at once
*/
export const unifiedFontStore = new UnifiedFontStore({
limit: 50,
offset: 0,
});

View File

@@ -1,68 +0,0 @@
/**
* Common font domain types
*
* Shared types for font entities across providers (Google, Fontshare).
* Includes categories, subsets, weights, and filter types.
*/
import type { FontCategory as FontshareFontCategory } from './fontshare';
import type { FontCategory as GoogleFontCategory } from './google';
/**
* Unified font category across all providers
*/
export type FontCategory = GoogleFontCategory | FontshareFontCategory;
/**
* Font provider identifier
*/
export type FontProvider = 'google' | 'fontshare';
/**
* Character subset support
*/
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
/**
* Combined filter state for font queries
*/
export interface FontFilters {
/** Selected font providers */
providers: FontProvider[];
/** Selected font categories */
categories: FontCategory[];
/** Selected character subsets */
subsets: FontSubset[];
}
/** Filter group identifier */
export type FilterGroup = 'providers' | 'categories' | 'subsets';
/** Filter type including search query */
export type FilterType = FilterGroup | 'searchQuery';
/**
* Numeric font weights (100-900)
*/
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
/**
* Italic variant with weight: "100italic", "400italic", "700italic", etc.
*/
export type FontWeightItalic = `${FontWeight}italic`;
/**
* All possible font variant identifiers
*
* Includes:
* - Numeric weights: "400", "700", etc.
* - Italic variants: "400italic", "700italic", etc.
* - Legacy names: "regular", "italic", "bold", "bolditalic"
*/
export type FontVariant =
| FontWeight
| FontWeightItalic
| 'regular'
| 'italic'
| 'bold'
| 'bolditalic';

View File

@@ -0,0 +1,227 @@
/**
* Font domain types
*
* Shared types for font entities across providers (Google, Fontshare).
* Includes categories, subsets, weights, and the unified font model.
*/
/**
* Unified font category across all providers
*/
export type FontCategory =
| 'sans-serif'
| 'serif'
| 'display'
| 'handwriting'
| 'monospace'
| 'slab'
| 'script';
/**
* Font provider identifier
*/
export type FontProvider = 'google' | 'fontshare';
/**
* Character subset support
*/
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
/**
* Combined filter state for font queries
*/
export interface FontFilters {
/**
* Active font providers to fetch from
*/
providers: FontProvider[];
/**
* Visual classifications (sans, serif, etc.)
*/
categories: FontCategory[];
/**
* Character sets required for the sample text
*/
subsets: FontSubset[];
}
/**
* Filter group identifier
*/
export type FilterGroup = 'providers' | 'categories' | 'subsets';
/**
* Filter type including search query
*/
export type FilterType = FilterGroup | 'searchQuery';
/**
* Numeric font weights (100-900)
*/
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
/**
* Italic variant with weight: "100italic", "400italic", "700italic", etc.
*/
export type FontWeightItalic = `${FontWeight}italic`;
/**
* All possible font variant identifiers
*
* Includes:
* - Numeric weights: "400", "700", etc.
* - Italic variants: "400italic", "700italic", etc.
* - Legacy names: "regular", "italic", "bold", "bolditalic"
*/
export type FontVariant =
| FontWeight
| FontWeightItalic
| 'regular'
| 'italic'
| 'bold'
| 'bolditalic';
/**
* Standardized font variant alias
*/
export type UnifiedFontVariant = FontVariant;
/**
* Font style URLs
*/
export interface FontStyleUrls {
/**
* URL for the regular (400) weight
*/
regular?: string;
/**
* URL for the italic (400) style
*/
italic?: string;
/**
* URL for the bold (700) weight
*/
bold?: string;
/**
* URL for the bold-italic (700) style
*/
boldItalic?: string;
/**
* Mapping for all other numeric/custom variants
*/
variants?: Partial<Record<UnifiedFontVariant, string>>;
}
/**
* Font metadata
*/
export interface FontMetadata {
/**
* Epoch timestamp of last successful fetch
*/
cachedAt: number;
/**
* Semantic version string from upstream
*/
version?: string;
/**
* ISO date string of last remote update
*/
lastModified?: string;
/**
* Raw ranking integer from provider
*/
popularity?: number;
/**
* Normalized score (0-100) used for global sorting
*/
popularityScore?: number;
}
/**
* Font features (variable fonts, axes, tags)
*/
export interface FontFeatures {
/**
* Whether the font supports fluid weight/width axes
*/
isVariable?: boolean;
/**
* 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;
}>;
/**
* Descriptive keywords for search indexing
*/
tags?: string[];
}
/**
* Unified font model
*
* Combines Google Fonts and Fontshare data into a common interface
* for consistent font handling across the application.
*/
export interface UnifiedFont {
/**
* Unique ID (family name for Google, slug for Fontshare)
*/
id: string;
/**
* Canonical family name for CSS font-family
*/
name: string;
/**
* Upstream data source
*/
provider: FontProvider;
/**
* Display label for provider badges
*/
providerBadge?: string;
/**
* Primary typographic category
*/
category: FontCategory;
/**
* All supported character sets
*/
subsets: FontSubset[];
/**
* List of available weights and styles
*/
variants: UnifiedFontVariant[];
/**
* Remote assets for font loading
*/
styles: FontStyleUrls;
/**
* Technical metadata and rankings
*/
metadata: FontMetadata;
/**
* Variable font details and tags
*/
features: FontFeatures;
}

View File

@@ -1,468 +0,0 @@
/**
* ============================================================================
* FONTHARE API TYPES
* ============================================================================
*/
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2/fonts' as const;
export type FontCategory = 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script';
/**
* Model of Fontshare API response
* @see https://fontshare.com
*
* Fontshare API uses 'fonts' key instead of 'items' for the array
*/
export interface FontshareApiModel {
/**
* Number of items returned in current page/response
*/
count: number;
/**
* Total number of items available across all pages
*/
count_total: number;
/**
* Indicates if there are more items available beyond this page
*/
has_more: boolean;
/**
* Array of fonts (Fontshare uses 'fonts' key, not 'items')
*/
fonts: FontshareFont[];
}
/**
* Individual font metadata from Fontshare API
*/
export interface FontshareFont {
/**
* Unique identifier for the font
* UUID v4 format (e.g., "20e9fcdc-1e41-4559-a43d-1ede0adc8896")
*/
id: string;
/**
* Display name of the font family
* Examples: "Satoshi", "General Sans", "Clash Display"
*/
name: string;
/**
* Native/localized name of the font (if available)
* Often null for Latin-script fonts
*/
native_name: string | null;
/**
* URL-friendly identifier for the font
* Used in URLs: e.g., "satoshi", "general-sans", "clash-display"
*/
slug: string;
/**
* Font category classification
* Examples: "Sans", "Serif", "Display", "Script"
*/
category: string;
/**
* Script/writing system supported by the font
* Examples: "latin", "arabic", "devanagari"
*/
script: string;
/**
* Font publisher/foundry information
*/
publisher: FontsharePublisher;
/**
* Array of designers who created this font
* Multiple designers may have collaborated on a single font
*/
designers: FontshareDesigner[];
/**
* Related font families (if any)
* Often null, as fonts are typically independent
*/
related_families: string | null;
/**
* Whether to display publisher as the designer instead of individual designers
*/
display_publisher_as_designer: boolean;
/**
* Whether trial downloads are enabled for this font
*/
trials_enabled: boolean;
/**
* Whether to show Latin-specific metrics
*/
show_latin_metrics: boolean;
/**
* Type of license for this font
* Examples: "itf_ffl" (ITF Free Font License)
*/
license_type: string;
/**
* Comma-separated list of languages supported by this font
* Example: "Afar, Afrikaans, Albanian, Aranese, Aromanian, Aymara, ..."
*/
languages: string;
/**
* ISO 8601 timestamp when the font was added to Fontshare
* Format: "2021-03-12T20:49:05Z"
*/
inserted_at: string;
/**
* HTML-formatted story/description about the font
* Contains marketing text, design philosophy, and usage recommendations
*/
story: string;
/**
* Version of the font family
* Format: "1.0", "1.2", etc.
*/
version: string;
/**
* Total number of times this font has been viewed
*/
views: number;
/**
* Number of views in the recent time period
*/
views_recent: number;
/**
* Whether this font is marked as "hot"/trending
*/
is_hot: boolean;
/**
* Whether this font is marked as new
*/
is_new: boolean;
/**
* Whether this font is in the shortlisted collection
*/
is_shortlisted: boolean | null;
/**
* Whether this font is marked as top/popular
*/
is_top: boolean;
/**
* Variable font axes (for variable fonts)
* Empty array [] for static fonts
*/
axes: FontshareAxis[];
/**
* Tags/categories for this font
* Examples: ["Magazines", "Branding", "Logos", "Posters"]
*/
font_tags: FontshareTag[];
/**
* OpenType features available in this font
*/
features: FontshareFeature[];
/**
* Array of available font styles/variants
* Each style represents a different font file (weight, italic, variable)
*/
styles: FontshareStyle[];
}
/**
* Publisher/foundry information
*/
export interface FontsharePublisher {
/**
* Description/bio of the publisher
* Example: "Indian Type Foundry (ITF) creates retail and custom multilingual fonts..."
*/
bio: string;
/**
* Publisher email (if available)
*/
email: string | null;
/**
* Unique publisher identifier
* UUID format
*/
id: string;
/**
* Publisher links (social media, website, etc.)
*/
links: FontshareLink[];
/**
* Publisher name
* Example: "Indian Type Foundry"
*/
name: string;
}
/**
* Designer information
*/
export interface FontshareDesigner {
/**
* Designer bio/description
*/
bio: string;
/**
* Designer links (Twitter, website, etc.)
*/
links: FontshareLink[];
/**
* Designer name
*/
name: string;
}
/**
* Link information
*/
export interface FontshareLink {
/**
* Name of the link platform/site
* Examples: "Twitter", "GitHub", "Website"
*/
name: string;
/**
* URL of the link (may be null)
*/
url: string | null;
}
/**
* Font tag/category
*/
export interface FontshareTag {
/**
* Tag name
* Examples: "Magazines", "Branding", "Logos", "Posters"
*/
name: string;
}
/**
* OpenType feature
*/
export interface FontshareFeature {
/**
* Feature name (descriptive name or null)
* Examples: "Alternate t", "All Alternates", or null
*/
name: string | null;
/**
* Whether this feature is on by default
*/
on_by_default: boolean;
/**
* OpenType feature tag (4-character code)
* Examples: "ss01", "frac", "liga", "aalt", "case"
*/
tag: string;
}
/**
* Variable font axis (for variable fonts)
* Defines the range and properties of a variable font axis (e.g., weight)
*/
export interface FontshareAxis {
/**
* Name of the axis
* Example: "wght" (weight axis)
*/
name: string;
/**
* CSS property name for the axis
* Example: "wght"
*/
property: string;
/**
* Default value for the axis
* Example: 420.0, 650.0, 700.0
*/
range_default: number;
/**
* Minimum value for the axis
* Example: 300.0, 100.0, 200.0
*/
range_left: number;
/**
* Maximum value for the axis
* Example: 900.0, 700.0, 800.0
*/
range_right: number;
}
/**
* Individual font style/variant
* Each style represents a single downloadable font file
*/
export interface FontshareStyle {
/**
* Unique identifier for this style
* UUID format
*/
id: string;
/**
* Whether this is the default style for the font family
* Typically, one style per font is marked as default
*/
default: boolean;
/**
* CDN URL to the font file
* Protocol-relative URL: "//cdn.fontshare.com/wf/..."
* Note: URL starts with "//" (protocol-relative), may need protocol prepended
*/
file: string;
/**
* Whether this style is italic
* false for upright, true for italic styles
*/
is_italic: boolean;
/**
* Whether this is a variable font
* Variable fonts have adjustable axes (weight, slant, etc.)
*/
is_variable: boolean;
/**
* Typography properties for this style
* Contains measurements like cap height, x-height, ascenders/descenders
* May be empty object {} for some styles
*/
properties: FontshareStyleProperties | Record<string, never>;
/**
* Weight information for this style
*/
weight: FontshareWeight;
}
/**
* Typography/measurement properties for a font style
*/
export interface FontshareStyleProperties {
/**
* Distance from baseline to the top of ascenders
* Example: 1010, 990, 1000
*/
ascending_leading: number | null;
/**
* Height of uppercase letters (cap height)
* Example: 710, 680, 750
*/
cap_height: number | null;
/**
* Distance from baseline to the bottom of descenders (negative value)
* Example: -203, -186, -220
*/
descending_leading: number | null;
/**
* Body height of the font
* Often null in Fontshare data
*/
body_height: number | null;
/**
* Maximum character width in the font
* Example: 1739, 1739, 1739
*/
max_char_width: number | null;
/**
* Height of lowercase x-height
* Example: 480, 494, 523
*/
x_height: number | null;
/**
* Maximum Y coordinate (top of ascenders)
* Example: 1010, 990, 1026
*/
y_max: number | null;
/**
* Minimum Y coordinate (bottom of descenders)
* Example: -240, -250, -280
*/
y_min: number | null;
}
/**
* Weight information for a font style
*/
export interface FontshareWeight {
/**
* Display label for the weight
* Examples: "Light", "Regular", "Bold", "Variable", "Variable Italic"
*/
label: string;
/**
* Internal name for the weight
* Examples: "Light", "Regular", "Bold", "Variable", "VariableItalic"
*/
name: string;
/**
* Native/localized name for the weight (if available)
* Often null for Latin-script fonts
*/
native_name: string | null;
/**
* Numeric weight value
* Examples: 300, 400, 700, 0 (for variable fonts), 1, 2
* Note: This matches the `weight` property
*/
number: number;
/**
* Numeric weight value (duplicate of `number`)
* Appears to be redundant with `number` field
*/
weight: number;
}

View File

@@ -1,99 +0,0 @@
/**
* ============================================================================
* GOOGLE FONTS API TYPES
* ============================================================================
*/
import type { FontVariant } from './common';
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
/**
* Model of google fonts api response
*/
export interface GoogleFontsApiModel {
/**
* Array of font items returned by the Google Fonts API
* Contains all font families matching the requested query parameters
*/
items: FontItem[];
}
/**
* Individual font from Google Fonts API
*/
export interface FontItem {
/**
* Font family name (e.g., "Roboto", "Open Sans", "Lato")
* This is the name used in CSS font-family declarations
*/
family: string;
/**
* Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace")
* Useful for grouping and filtering fonts by style
*/
category: FontCategory;
/**
* Available font variants for this font family
* Array of strings representing available weights and styles
* Examples: ["regular", "italic", "100", "200", "300", "400", "500", "600", "700", "800", "900", "100italic", "900italic"]
* The keys in the `files` object correspond to these variant values
*/
variants: FontVariant[];
/**
* Supported character subsets for this font
* Examples: ["latin", "latin-ext", "cyrillic", "greek", "arabic", "devanagari", "vietnamese", "hebrew", "thai", etc.]
* Determines which character sets are included in the font files
*/
subsets: string[];
/**
* Font version identifier
* Format: "v" followed by version number (e.g., "v31", "v20", "v1")
* Used to track font updates and cache busting
*/
version: string;
/**
* Last modification date of the font
* Format: ISO 8601 date string (e.g., "2024-01-15", "2023-12-01")
* Indicates when the font was last updated by the font foundry
*/
lastModified: string;
/**
* Mapping of font variants to their downloadable URLs
* Keys correspond to values in the `variants` array
* Examples:
* - "regular" → "https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me4W..."
* - "700" → "https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmWUlf..."
* - "700italic" → "https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzA..."
*/
files: FontFiles;
/**
* URL to the font menu preview image
* Typically a PNG showing the font family name in the font
* Example: "https://fonts.gstatic.com/l/font?kit=KFOmCnqEu92Fr1Me4W...&s=i2"
*/
menu: string;
}
/**
* Type alias for backward compatibility
* Google Fonts API font item
*/
export type GoogleFontItem = FontItem;
/**
* Google Fonts API file mapping
* Dynamic keys that match the variants array
*
* Examples:
* - { "regular": "...", "italic": "...", "700": "...", "700italic": "..." }
* - { "400": "...", "400italic": "...", "900": "..." }
*/
export type FontFiles = Partial<Record<FontVariant, string>>;

View File

@@ -1,54 +1,20 @@
/**
* ============================================================================
* SINGLE EXPORT POINT
* ============================================================================
*
* This is the single export point for all Font types.
* All imports should use: `import { X } from '$entities/Font/model/types'`
*/
// Domain types
// Font domain and model types
export type {
FilterGroup,
FilterType,
FontCategory,
FontFeatures,
FontFilters,
FontMetadata,
FontProvider,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
} from './common';
// Google Fonts API types
export type {
FontFiles,
FontItem,
GoogleFontItem,
GoogleFontsApiModel,
} from './google';
// Fontshare API types
export type {
FontshareApiModel,
FontshareAxis,
FontshareDesigner,
FontshareFeature,
FontshareFont,
FontshareLink,
FontsharePublisher,
FontshareStyle,
FontshareStyleProperties,
FontshareTag,
FontshareWeight,
} from './fontshare';
export { FONTSHARE_API_URL } from './fontshare';
// Normalization types
export type {
FontFeatures,
FontMetadata,
FontStyleUrls,
UnifiedFont,
UnifiedFontVariant,
} from './normalize';
} from './font';
// Store types
export type {
@@ -56,3 +22,6 @@ export type {
FontCollectionSort,
FontCollectionState,
} from './store';
export * from './store/appliedFonts';
export * from './typography';

View File

@@ -1,108 +0,0 @@
/**
* ============================================================================
* NORMALIZATION TYPES
* ============================================================================
*/
import type {
FontCategory,
FontProvider,
FontSubset,
FontVariant,
} from './common';
/**
* Font variant types (standardized)
*/
export type UnifiedFontVariant = FontVariant;
/**
* Font style URLs
*/
export interface LegacyFontStyleUrls {
/** Regular weight URL */
regular?: string;
/** Italic URL */
italic?: string;
/** Bold weight URL */
bold?: string;
/** Bold italic URL */
boldItalic?: string;
}
export interface FontStyleUrls extends LegacyFontStyleUrls {
variants?: Partial<Record<UnifiedFontVariant, string>>;
}
/**
* Font metadata
*/
export interface FontMetadata {
/** Timestamp when font was cached */
cachedAt: number;
/** Font version from provider */
version?: string;
/** Last modified date from provider */
lastModified?: string;
/** Popularity rank (if available from provider) */
popularity?: number;
/**
* Normalized popularity score (0-100)
*
* Normalized across all fonts for consistent ranking
* Higher values indicate more popular fonts
*/
popularityScore?: number;
}
/**
* Font features (variable fonts, axes, tags)
*/
export interface FontFeatures {
/** Whether this is a variable font */
isVariable?: boolean;
/** Variable font axes (for Fontshare) */
axes?: Array<{
name: string;
property: string;
default: number;
min: number;
max: number;
}>;
/** Usage tags (for Fontshare) */
tags?: string[];
}
/**
* Unified font model
*
* Combines Google Fonts and Fontshare data into a common interface
* for consistent font handling across the application.
*/
export interface UnifiedFont {
/** Unique identifier (Google: family name, Fontshare: slug) */
id: string;
/** Font display name */
name: string;
/** Font provider (google | fontshare) */
provider: FontProvider;
/**
* Provider badge display name
*
* Human-readable provider name for UI display
* e.g., "Google Fonts" or "Fontshare"
*/
providerBadge?: string;
/** Font category classification */
category: FontCategory;
/** Supported character subsets */
subsets: FontSubset[];
/** Available font variants (weights, styles) */
variants: UnifiedFontVariant[];
/** URL mapping for font file downloads */
styles: FontStyleUrls;
/** Additional metadata */
metadata: FontMetadata;
/** Advanced font features */
features: FontFeatures;
}

View File

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

View File

@@ -0,0 +1,30 @@
/**
* Configuration for a font load request.
*/
export interface FontLoadRequestConfig {
/**
* Unique identifier for the font (e.g., "lato", "roboto").
*/
id: string;
/**
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
*/
name: string;
/**
* URL pointing to the font file (typically .ttf or .woff2).
*/
url: string;
/**
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
*/
weight: number;
/**
* Variable fonts load once per ID; static fonts load per weight.
*/
isVariable?: boolean;
}
/**
* Loading state of a font.
*/
export type FontLoadStatus = 'loading' | 'loaded' | 'error';

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,10 +15,10 @@ import type {
import { fade } from 'svelte/transition';
import { getFontUrl } from '../../lib';
import {
type FontConfigRequest,
type FontLoadRequestConfig,
type UnifiedFont,
appliedFontsManager,
unifiedFontStore,
fontStore,
} from '../../model';
interface Props extends
@@ -50,44 +50,58 @@ let {
}: Props = $props();
const isLoading = $derived(
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
fontStore.isFetching || fontStore.isLoading,
);
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
const configs: FontConfigRequest[] = [];
visibleItems.forEach(item => {
const url = getFontUrl(item, weight);
if (url) {
configs.push({
id: item.id,
name: item.name,
weight,
url,
isVariable: item.features?.isVariable,
});
}
});
// Auto-register fonts with the manager
appliedFontsManager.touch(configs);
let visibleFonts = $state<UnifiedFont[]>([]);
function handleInternalVisibleChange(items: UnifiedFont[]) {
visibleFonts = items;
// Forward the call to any external listener
// onVisibleItemsChange?.(visibleItems);
onVisibleItemsChange?.(items);
}
// Re-touch whenever visible set or weight changes — fixes weight-change gap
$effect(() => {
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
const url = getFontUrl(item, weight);
if (!url) {
return [];
}
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
});
if (configs.length > 0) {
appliedFontsManager.touch(configs);
}
});
// Pin visible fonts so the eviction policy never removes on-screen entries.
// Cleanup captures the snapshot values, so a weight change unpins the old
// weight before pinning the new one.
$effect(() => {
const w = weight;
const fonts = visibleFonts;
for (const f of fonts) {
appliedFontsManager.pin(f.id, w, f.features?.isVariable);
}
return () => {
for (const f of fonts) {
appliedFontsManager.unpin(f.id, w, f.features?.isVariable);
}
};
});
/**
* Load more fonts by moving to the next page
*/
function loadMore() {
if (
!unifiedFontStore.pagination.hasMore
|| unifiedFontStore.isFetching
!fontStore.pagination.hasMore
|| fontStore.isFetching
) {
return;
}
unifiedFontStore.nextPage();
fontStore.nextPage();
}
/**
@@ -97,17 +111,17 @@ function loadMore() {
* of the loaded items. Only fetches if there are more pages available.
*/
function handleNearBottom(_lastVisibleIndex: number) {
const { hasMore } = unifiedFontStore.pagination;
const { hasMore } = fontStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items
if (hasMore && !unifiedFontStore.isFetching) {
if (hasMore && !fontStore.isFetching) {
loadMore();
}
}
</script>
<div class="relative w-full h-full">
{#if skeleton && isLoading && unifiedFontStore.fonts.length === 0}
{#if skeleton && isLoading && fontStore.fonts.length === 0}
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
<div transition:fade={{ duration: 300 }}>
{@render skeleton()}
@@ -115,8 +129,8 @@ function handleNearBottom(_lastVisibleIndex: number) {
{:else}
<!-- VirtualList persists during pagination - no destruction/recreation -->
<VirtualList
items={unifiedFontStore.fonts}
total={unifiedFontStore.pagination.total}
items={fontStore.fonts}
total={fontStore.pagination.total}
isLoading={isLoading}
onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Filters from './Filters.svelte';
const { Story } = defineMeta({
title: 'Features/Filters',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Renders the full list of filter groups managed by filterManager. Each group maps to a collapsible FilterGroup with checkboxes. No props — reads directly from the filterManager singleton.',
},
story: { inline: false },
},
layout: 'padded',
},
argTypes: {},
});
</script>
<Story name="Default">
{#snippet template()}
<Filters />
{/snippet}
</Story>

View File

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

View File

@@ -0,0 +1,39 @@
<script module>
import Providers from '$shared/lib/storybook/Providers.svelte';
import { defineMeta } from '@storybook/addon-svelte-csf';
import FilterControls from './FilterControls.svelte';
const { Story } = defineMeta({
title: 'Features/FilterControls',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Sort options and Reset_Filters button rendered below the filter list. Reads sort state from sortStore and dispatches resets via filterManager. Requires responsive context — wrap with Providers.',
},
story: { inline: false },
},
layout: 'padded',
},
argTypes: {},
});
</script>
<Story name="Default">
{#snippet template()}
<Providers>
<FilterControls />
</Providers>
{/snippet}
</Story>
<Story name="Mobile layout">
{#snippet template()}
<Providers>
<div style="width: 375px;">
<FilterControls />
</div>
</Providers>
{/snippet}
</Story>

View File

@@ -4,12 +4,12 @@
Sits below the filter list, separated by a top border.
-->
<script lang="ts">
import { unifiedFontStore } from '$entities/Font';
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,
@@ -33,7 +33,7 @@ const {
$effect(() => {
const apiSort = sortStore.apiValue;
untrack(() => unifiedFontStore.setSort(apiSort));
untrack(() => fontStore.setSort(apiSort));
});
const responsive = getContext<ResponsiveManager>('responsive');
@@ -45,7 +45,7 @@ function handleReset() {
</script>
<div
class={cn(
class={clsx(
'flex flex-col md:flex-row justify-between items-start md:items-center',
'gap-1 md:gap-6',
'pt-6 mt-6 md:pt-8 md:mt-8',
@@ -61,13 +61,10 @@ function handleReset() {
{#each SORT_OPTIONS as option}
<Button
variant="ghost"
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
active={sortStore.value === option}
onclick={() => sortStore.set(option)}
class={cn(
'font-bold uppercase tracking-wide font-primary, px-0',
isMobileOrTabletPortrait ? 'text-[0.5625rem]' : 'text-[0.625rem]',
)}
class="tracking-wide px-0"
>
{option}
</Button>
@@ -78,12 +75,9 @@ function handleReset() {
<!-- Reset_Filters -->
<Button
variant="ghost"
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
onclick={handleReset}
class={cn(
'group text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest text-neutral-400',
isMobileOrTabletPortrait && 'px-0',
)}
class={clsx('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
iconPosition="left"
>
{#snippet icon()}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,16 @@
<!--
Component: TypographyMenu
Floating controls bar for typography settings.
Warm surface, sharp corners, Settings icon header, dividers between units.
Mobile: popover with slider controls anchored to settings button.
Desktop: inline bar with combo controls.
-->
<script lang="ts">
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
Button,
ComboControl,
@@ -17,15 +20,11 @@ import {
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import XIcon from '@lucide/svelte/icons/x';
import { Popover } from 'bits-ui';
import clsx from 'clsx';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
controlManager,
} from '../../model';
import { typographySettingsStore } from '../../model';
interface Props {
/**
@@ -37,67 +36,62 @@ interface Props {
* @default false
*/
hidden?: boolean;
/**
* Bindable popover open state
* @default false
*/
open?: boolean;
}
const { class: className, hidden = false }: Props = $props();
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
let isOpen = $state(false);
/**
* Sets the common font size multiplier based on the current responsive state.
*/
$effect(() => {
if (!responsive) return;
if (!responsive) {
return;
}
switch (true) {
case responsive.isMobile:
controlManager.multiplier = MULTIPLIER_S;
typographySettingsStore.multiplier = MULTIPLIER_S;
break;
case responsive.isTablet:
controlManager.multiplier = MULTIPLIER_M;
typographySettingsStore.multiplier = MULTIPLIER_M;
break;
case responsive.isDesktop:
controlManager.multiplier = MULTIPLIER_L;
typographySettingsStore.multiplier = MULTIPLIER_L;
break;
default:
controlManager.multiplier = MULTIPLIER_L;
typographySettingsStore.multiplier = MULTIPLIER_L;
}
});
</script>
{#if !hidden}
{#if responsive.isMobile}
<Popover.Root bind:open={isOpen}>
{#if responsive.isMobileOrTablet}
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<button
{...props}
class={cn(
'inline-flex items-center justify-center',
'size-8 p-0',
'border border-transparent rounded-none',
'transition-colors duration-150',
'hover:bg-white/50 dark:hover:bg-white/5',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
isOpen && 'bg-white dark:bg-[#1e1e1e] border-black/5 dark:border-white/10 shadow-sm',
className,
)}
>
<Button class={className} variant="primary" {...props}>
{#snippet icon()}
<Settings2Icon class="size-4" />
</button>
{/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-[#f3f0e9] dark:bg-[#1e1e1e]',
'border border-black/5 dark:border-white/10',
'bg-surface dark:bg-dark-card',
'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-[#ff3b30]" />
<Settings2Icon size={12} class="text-swiss-red" />
<span
class="text-[0.5625rem] font-mono uppercase tracking-widest font-bold text-[#1a1a1a] dark:text-[#e5e5e5]"
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-[#f3f0e9]/95 dark:bg-[#121212]/95 backdrop-blur-xl',
'border border-black/5 dark:border-white/10',
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
'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-[#1a1a1a] dark:text-[#e5e5e5] 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-[#ff3b30]"
class="text-swiss-red"
/>
<span
class="text-[0.5625rem] md:text-[0.625rem] font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
class="text-3xs md:text-2xs font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
>
GLOBAL_CONTROLS
</span>
</div>
<!-- Controls with dividers between each -->
{#each controlManager.controls as control, i (control.id)}
{#each typographySettingsStore.controls as control, i (control.id)}
{#if i > 0}
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
{/if}

View File

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

View File

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

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