Compare commits

...

556 Commits

Author SHA1 Message Date
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
Ilia Mashkov
6f65aa207e fix: stories errors
All checks were successful
Workflow / build (pull_request) Successful in 3m22s
Workflow / publish (pull_request) Has been skipped
2026-03-02 22:45:29 +03:00
Ilia Mashkov
87bba388dc chore(app): update config, dependencies, storybook, and app shell 2026-03-02 22:21:19 +03:00
Ilia Mashkov
55e2efc222 refactor(features, widgets): update ThemeManager, FontSampler, FontSearch, and SampleList 2026-03-02 22:20:48 +03:00
Ilia Mashkov
0fa3437661 refactor(SetupFont): reorganize TypographyMenu and add control tests 2026-03-02 22:20:29 +03:00
Ilia Mashkov
efe1b4f9df refactor(GetFonts): restructure filter API and add sort store 2026-03-02 22:19:59 +03:00
Ilia Mashkov
0dd08874bc refactor(ui): update shared components and add ControlGroup, SidebarContainer 2026-03-02 22:19:35 +03:00
Ilia Mashkov
13818d5844 refactor(shared): update utilities, API layer, and types 2026-03-02 22:19:13 +03:00
Ilia Mashkov
ac73fd5044 refactor(helpers): modernize reactive helpers and add tests 2026-03-02 22:18:59 +03:00
Ilia Mashkov
594af924c7 refactor(Breadcrumb): simplify entity structure and add tests 2026-03-02 22:18:41 +03:00
Ilia Mashkov
af4137f47f refactor(Font): consolidate API layer and update type structure 2026-03-02 22:18:21 +03:00
Ilia Mashkov
ba186d00a1 feat(ComparisonView): add redesigned font comparison widget 2026-03-02 22:18:05 +03:00
Ilia Mashkov
6cd325ce38 chore(ComparisonSlider): remove widget in favor of ComparisonView 2026-03-02 22:17:46 +03:00
Ilia Mashkov
0c3dcc243a chore(ui): remove obsolete UI components 2026-03-02 22:16:48 +03:00
Ilia Mashkov
e7225a6009 chore(shadcn): remove deprecated shadcn-svelte components 2026-03-02 22:16:18 +03:00
Ilia Mashkov
0d38a2dc9b fix(filters): remove unused import 2026-03-02 15:06:43 +03:00
Ilia Mashkov
ba20d6d264 fix(filters): use proxy fetch function 2026-03-02 15:06:06 +03:00
Ilia Mashkov
6d06f9f877 fix(filters): remove hardcoded fallback 2026-03-02 14:53:54 +03:00
Ilia Mashkov
db7ffd3246 feat(filters): support multiple values 2026-03-02 14:12:55 +03:00
Ilia Mashkov
37a528f0aa feat(filters): add dynamic filters store 2026-03-02 14:12:47 +03:00
Ilia Mashkov
85b14cd89b feat(SampleListSection): add LayoutSwitch to the header 2026-02-27 19:08:34 +03:00
Ilia Mashkov
8fbd6f5935 feat(SampleList): remove unused code 2026-02-27 19:07:53 +03:00
Ilia Mashkov
80a9802c42 fix(VirtualList): don't render top spacer if topPad is bellow 0 2026-02-27 19:07:13 +03:00
Ilia Mashkov
fe5940adbf chore: add exports 2026-02-27 18:44:07 +03:00
Ilia Mashkov
f7fe71f8e3 feat(Badge): rewrite Badge component to new design 2026-02-27 18:43:56 +03:00
Ilia Mashkov
db518a6469 feat(Divider): adjust colors 2026-02-27 18:42:54 +03:00
Ilia Mashkov
5946f66e69 chore(FontSamler): rewrite component to use existed shared ui label wrappers 2026-02-27 18:42:20 +03:00
Ilia Mashkov
2046394906 chore: add exports 2026-02-27 18:41:20 +03:00
Ilia Mashkov
887ca6e5e1 feat(LayoutSwitch): create a ui component to switch SampleList layout 2026-02-27 18:40:46 +03:00
Ilia Mashkov
c86b5f5db8 feat(LayoutManager): create layout manager with persistent store support to manage SampleList items layout 2026-02-27 18:40:08 +03:00
Ilia Mashkov
f0aa89097e feat(TypographyMenu): rewrite from hidden class to if based rendering 2026-02-27 18:39:09 +03:00
Ilia Mashkov
80feda41a3 feat(createResponsiveManager): rewrote ifs to switch case 2026-02-27 18:35:40 +03:00
Ilia Mashkov
3a813b019b chore: rename 2026-02-27 13:00:58 +03:00
Ilia Mashkov
fb6cd495d3 feat(VirtualList): add different layout support 2026-02-27 13:00:03 +03:00
Ilia Mashkov
44bbac4695 feat(Section) add headerContent snippet 2026-02-27 12:50:16 +03:00
Ilia Mashkov
8fa376ef94 feat(handleTitleStatusChanged): create reusable handler for sections title status management 2026-02-27 12:49:13 +03:00
Ilia Mashkov
9f84769fba chore: add/delete imports/exports 2026-02-27 12:48:14 +03:00
Ilia Mashkov
1b0451faff chore: delete unused code 2026-02-27 12:46:52 +03:00
Ilia Mashkov
338f4e106c feat(FontSearch): create a component that wraps SampleList with Section 2026-02-27 12:45:07 +03:00
Ilia Mashkov
fbf6f3dcb4 feat(SampleList): create a component that wraps SampleList with Section 2026-02-27 12:44:57 +03:00
Ilia Mashkov
d516a383e1 chore(IconButon): delete unusual code 2026-02-27 12:43:21 +03:00
Ilia Mashkov
12718593e3 feat(FontSampler): refactor component to align it with new design 2026-02-27 12:42:18 +03:00
Ilia Mashkov
9983be650a feat(TypographyMenu): refactor component to align it with new design 2026-02-27 12:41:58 +03:00
Ilia Mashkov
e85f6639ff feat(FilterControls): refactor component to align it with new design 2026-02-27 12:41:05 +03:00
Ilia Mashkov
3a9bd0c465 chore: fix imports 2026-02-27 12:40:37 +03:00
Ilia Mashkov
9af81c3f17 feat(FontSearch): refactor component to align it with new design 2026-02-27 12:40:09 +03:00
Ilia Mashkov
248ca7d818 fix(SearchBar): change component variant according to redesign 2026-02-27 12:39:20 +03:00
Ilia Mashkov
38f4243739 feat(FilterGroup): refactor CheckboxFilter component to FilterGroup 2026-02-27 12:38:19 +03:00
Ilia Mashkov
0ca5115d10 feat(Button): add tertiary variant 2026-02-27 12:25:25 +03:00
Ilia Mashkov
f8f295e5a0 feat(Button): add tertiary variant and change ghost variant styles 2026-02-27 12:25:16 +03:00
Ilia Mashkov
bf79cbb26f feat(storybook): create ThemeDecorator to support themeManager logic in storybook 2026-02-27 12:24:16 +03:00
Ilia Mashkov
661f3f0ae3 feat(Layout): add ThemeManager support 2026-02-27 12:23:31 +03:00
Ilia Mashkov
7b8b41021c feat(ThemeSwitch): create ThemeSwitch component that uses ThemeMager toggle to switch theme 2026-02-27 12:22:37 +03:00
Ilia Mashkov
c4daf47628 feat(ThemeManager): create ThemeManager that uses persistent storage to store preferred user theme 2026-02-27 12:21:44 +03:00
Ilia Mashkov
4f4afaebdf fix(app.css): delete unused theme, move font variables, fix dark theme behavior 2026-02-27 12:20:12 +03:00
Ilia Mashkov
ea858dfdda feat(Section): component redesign 2026-02-25 10:04:25 +03:00
Ilia Mashkov
629dd15628 feat(SearchBar): component redesign 2026-02-25 10:03:34 +03:00
Ilia Mashkov
81d228290b feat(app): change font variables 2026-02-25 10:02:41 +03:00
Ilia Mashkov
ff39299499 feat(Layout): change fonts link for a new one 2026-02-25 10:02:18 +03:00
Ilia Mashkov
750b8ae7b8 chore: replace font-name with variable 2026-02-25 10:01:26 +03:00
Ilia Mashkov
7aa1ddd405 chore: replace font-name with variable 2026-02-25 10:01:00 +03:00
Ilia Mashkov
121eab54d9 chore(Label): add separate file for Label types 2026-02-25 10:00:36 +03:00
Ilia Mashkov
f134a343be chore(Import): add separate files for Import types 2026-02-25 10:00:18 +03:00
Ilia Mashkov
b891f4c64b chore(Button): add separate files for Button types and export 2026-02-25 09:59:52 +03:00
Ilia Mashkov
e125b2c795 feat(FontSampler): redesign component, remuve unused code, add stories 2026-02-25 09:59:19 +03:00
Ilia Mashkov
d9925da96f feat(Section): add SectionSeparator component 2026-02-25 09:58:24 +03:00
Ilia Mashkov
bd480f9592 feat(Section): add SectionTitle component 2026-02-25 09:58:14 +03:00
Ilia Mashkov
8d571042d8 feat(Section): move SectionHeader component to Section ui shared component 2026-02-25 09:57:51 +03:00
Ilia Mashkov
2a65cedd0a chore: replace font-name with variable 2026-02-25 09:56:59 +03:00
Ilia Mashkov
560eda6ac2 feat(ComboControl): replace ComboControl with redesigned ComboControlV2 2026-02-25 09:55:46 +03:00
Ilia Mashkov
5dbebc2b77 feat(Button): wrapper arround Button to create toggle components 2026-02-24 18:07:10 +03:00
Ilia Mashkov
98101217db feat(Button): wrapper arround Button to create square buttons with icons 2026-02-24 18:06:24 +03:00
Ilia Mashkov
cd5abea56c feat(ButtonGroup): container for buttons 2026-02-24 18:04:15 +03:00
Ilia Mashkov
7cb9ae9ede feat(StatGroup): wrapper around Stat to group a list of stats 2026-02-24 18:03:40 +03:00
Ilia Mashkov
043db46eaf feat(Divider): universal divider 2026-02-24 18:02:52 +03:00
Ilia Mashkov
8617f2c117 feat(SectionHeader): Header for page sections 2026-02-24 18:02:24 +03:00
Ilia Mashkov
089dc73abe feat(StatusIndicator): Label wrapper for status display 2026-02-24 18:01:48 +03:00
Ilia Mashkov
cec166182c feat(Metric): Label wrapper for metrics 2026-02-24 18:01:06 +03:00
Ilia Mashkov
eac47fb99d feat(Stat): Label wrapper for statistics 2026-02-24 18:00:43 +03:00
Ilia Mashkov
83f2bdcdda feat(TechText): component for technical text 2026-02-24 18:00:01 +03:00
Ilia Mashkov
12d57c59c1 feat(Label): component redesign with complete storybook coverage 2026-02-24 17:59:18 +03:00
Ilia Mashkov
d36ab3c993 feat(Button): shared button component with different sizes and variants 2026-02-24 17:58:56 +03:00
Ilia Mashkov
3e8e8a70c7 feat(Input): component redesign with complete storybook coverage 2026-02-24 17:58:00 +03:00
Ilia Mashkov
2ee49b7cbd feat(Slider): component redesign with complete storybook coverage 2026-02-24 17:57:40 +03:00
Ilia Mashkov
10437a2bf3 feat: new css variables for light and dark theme 2026-02-24 17:56:55 +03:00
Ilia Mashkov
acd656ddd1 feat(Badge): create Badge ui component 2026-02-22 11:26:11 +03:00
Ilia Mashkov
7f2fcb1797 feat(DotIndicator): create DotIndicator ui component 2026-02-22 11:25:53 +03:00
Ilia Mashkov
12222634d3 feat(MicroLabel): create MicroLabel ui component 2026-02-22 11:25:33 +03:00
Ilia Mashkov
0c8b8e989f chore: rewrite existing shared/ui stories using snippet template pattern 2026-02-22 11:25:02 +03:00
30bbfa7e11 Merge pull request 'feature/test-coverage' (#27) from feature/test-coverage into main
All checks were successful
Workflow / build (push) Successful in 59s
Workflow / publish (push) Successful in 56s
Reviewed-on: #27
2026-02-22 07:46:54 +00:00
Ilia Mashkov
eff3979372 chore: delete unused code
All checks were successful
Workflow / build (pull_request) Successful in 1m17s
Workflow / publish (pull_request) Has been skipped
2026-02-22 10:45:14 +03:00
Ilia Mashkov
da79dd2e35 feat: storybook cases and mocks 2026-02-19 13:58:12 +03:00
Ilia Mashkov
9d1f59d819 feat(IconButton): add conditional rendering 2026-02-19 13:55:11 +03:00
Ilia Mashkov
935b065843 feat(app): add --font-sans variable 2026-02-19 13:54:37 +03:00
Ilia Mashkov
d15b2ffe3f test(createVirtualizer): test coverage for virtual list logic 2026-02-18 20:54:34 +03:00
Ilia Mashkov
51ea8a9902 test(smoothScroll): cast mock to the proper type 2026-02-18 20:40:00 +03:00
Ilia Mashkov
e81cadb32a feat(smoothScroll): cover smoothScroll util with unit tests 2026-02-18 20:20:24 +03:00
Ilia Mashkov
1c3908f89e test(createPersistentStore): cover createPersistentStore helper with unit tests 2026-02-18 20:19:47 +03:00
Ilia Mashkov
206e609a2d test(createEntityStore): cover createEntityStore helper with unit tests 2026-02-18 20:19:26 +03:00
Ilia Mashkov
ff71d1c8c9 test(splitArray): add unit tests for splitArray util 2026-02-18 20:18:18 +03:00
Ilia Mashkov
24ca2f6c41 test(throttle): add unit tests for throttle util 2026-02-18 20:17:33 +03:00
Ilia Mashkov
3abe5723c7 test(appliedFontStore): change mockFetch 2026-02-18 20:16:50 +03:00
4f181d1d92 Merge pull request 'feature/ux-improvements' (#26) from feature/ux-improvements into main
All checks were successful
Workflow / build (push) Successful in 1m2s
Workflow / publish (push) Successful in 1m0s
Reviewed-on: #26
2026-02-18 14:43:03 +00:00
Ilia Mashkov
aa4796079a feat(Page): add new Section props for sticky titles
All checks were successful
Workflow / build (pull_request) Successful in 3m11s
Workflow / publish (pull_request) Has been skipped
2026-02-18 17:40:20 +03:00
Ilia Mashkov
f18454f9b3 feat(Layout): change fonts link and remove max-width for main 2026-02-18 17:39:24 +03:00
Ilia Mashkov
e3924d43d8 feat(Section): add a styickyTitle feature and change the section layout 2026-02-18 17:36:38 +03:00
Ilia Mashkov
0f6a4d6587 chore: add/delete imports/exports 2026-02-18 17:35:53 +03:00
Ilia Mashkov
8f4faa3328 feat(Input): create index file with type exports 2026-02-18 17:35:26 +03:00
Ilia Mashkov
5867028be6 feat(app): add variable value for mono font 2026-02-18 17:34:47 +03:00
Ilia Mashkov
b8d019b824 feat(ComparisonSlider): add labels 2026-02-18 17:03:44 +03:00
Ilia Mashkov
45ed0d5601 fix(Footnote): use classes every time 2026-02-18 17:03:17 +03:00
Ilia Mashkov
9f91fed692 feat(Input): tweak styles 2026-02-18 17:02:32 +03:00
Ilia Mashkov
201280093f feat(ComparisonSlider): change color for selected font in font list 2026-02-18 17:01:57 +03:00
Ilia Mashkov
55b27973a2 feat(ComparisonSlider): add selected fonts name for mobile controls and labels everywhere 2026-02-18 17:00:25 +03:00
Ilia Mashkov
5fa79e06e9 feat(ComparisonSlider): slightly tweak styles 2026-02-18 16:59:46 +03:00
Ilia Mashkov
ee0749e828 feat(ComparisonSlider): slightly tweak styles 2026-02-18 16:59:31 +03:00
Ilia Mashkov
5dae5fb7ea feat(ComparisonSlider): increase minimal height for large screens 2026-02-18 16:58:31 +03:00
Ilia Mashkov
20f65ee396 feat(FontSampler): slight font style tweaks for font name 2026-02-18 16:57:52 +03:00
Ilia Mashkov
010b8ad04b feat(FontSearch): make filters open by default 2026-02-18 16:57:03 +03:00
Ilia Mashkov
ce1dcd92ab feat(Label): create shared Label component 2026-02-18 16:56:26 +03:00
Ilia Mashkov
ce609728c3 feat(SidebarMenu): tweak styles 2026-02-18 16:55:57 +03:00
Ilia Mashkov
147df04c22 feat(Slider): tweak styles for a knob and add slider label 2026-02-18 16:55:11 +03:00
Ilia Mashkov
f356851d97 chore: remove lenis package 2026-02-18 16:53:40 +03:00
Ilia Mashkov
411dbfefcb feat(ComparisonSlider): rotate icon for the mobile and slightly tweak styles 2026-02-18 16:52:50 +03:00
Ilia Mashkov
a65d692139 feat(app): style default scrollbar 2026-02-18 11:18:54 +03:00
Ilia Mashkov
3330f13228 fix(SearchBar): restore proper padding 2026-02-18 11:18:17 +03:00
Ilia Mashkov
ad6e1da292 fix(ComparisonSlider): change the way width is calculated to avoid transform:scale issues 2026-02-16 15:30:00 +03:00
Ilia Mashkov
ac8f0456b0 chore(VirtualLisr): remove unused imports and change comment 2026-02-16 15:07:19 +03:00
Ilia Mashkov
77668f507c feat(appliedFontsStore): add extensive documentation, implement optimization and usage of browser apis to ensure flawless ux and avoid ui freezing 2026-02-16 15:06:49 +03:00
Ilia Mashkov
23831efbe6 feat(Controls): add Drawer wrapper for mobiles 2026-02-16 14:16:52 +03:00
Ilia Mashkov
42854b4950 feat(FontList): tweak styles slightly 2026-02-16 14:16:30 +03:00
Ilia Mashkov
c45429f38d feat(SampleList): add skeleton snippet 2026-02-16 14:15:47 +03:00
Ilia Mashkov
4d57f2084c feat(VirtualList): add estimated total size calculation 2026-02-16 14:15:19 +03:00
Ilia Mashkov
bee529dff8 fix(createVirtualizer): fix scroll issues that make scroll position jump when new page of fonts loads. Add some optimizations e.g. common ResizeObserver 2026-02-16 14:14:06 +03:00
Ilia Mashkov
1f793278d1 chore: remove comment 2026-02-16 14:12:00 +03:00
Ilia Mashkov
4f76a03e33 feat(FontVirtualList): make skeleton a snippet prop 2026-02-16 14:11:29 +03:00
Ilia Mashkov
940e20515b chore: remove unused code 2026-02-15 23:23:52 +03:00
Ilia Mashkov
f15114a78b fix(Input): change the way input types are exporting 2026-02-15 23:22:44 +03:00
Ilia Mashkov
6ba37c9e4a feat(ComparisonSlider): add perspective manager and tweak styles 2026-02-15 23:15:50 +03:00
Ilia Mashkov
858daff860 feat(ComparisonSlider): create a scrollable list of fonts with clever controls 2026-02-15 23:11:10 +03:00
Ilia Mashkov
b7f54b503c feat(Controls): rework component to use SidebarMenu 2026-02-15 23:10:07 +03:00
Ilia Mashkov
17de544bdb feat(ComparisonSlider): add a toggle button that shows selected fonts and opens the sidebar menu with settings 2026-02-15 23:09:21 +03:00
Ilia Mashkov
a0ac52a348 feat(SidebarMenu): create a shared sidebar menu that slides to the screen 2026-02-15 23:08:22 +03:00
Ilia Mashkov
99966d2de9 feat(TypographyControls): drasticaly reduce animations, keep only the container functional 2026-02-15 23:07:23 +03:00
Ilia Mashkov
72334a3d05 feat(ComboControlV2): hide input when control is reduced 2026-02-15 23:05:58 +03:00
Ilia Mashkov
8780b6932c chore: formatting 2026-02-15 23:04:47 +03:00
Ilia Mashkov
5d2c05e192 feat(PerspectivePlan): add a wrapper to work with perspective manager styles 2026-02-15 23:04:24 +03:00
Ilia Mashkov
1031b96ec5 chore: add exports/imports 2026-02-15 23:03:09 +03:00
Ilia Mashkov
4fdc99a15a feat(createPerspectiveManager): create perspective manager to work with perspective, moving objects along the z axis 2026-02-15 23:02:49 +03:00
Ilia Mashkov
9e74a2c2c6 feat(createCharacterComparison): create type CharacterComparison and export it 2026-02-15 23:01:43 +03:00
Ilia Mashkov
aa3f467821 feat(Input): add tailwind variants with sizes, update stories 2026-02-15 23:00:12 +03:00
Ilia Mashkov
6001f50cf5 feat(Slider): change thumb shape to circle 2026-02-15 22:57:29 +03:00
Ilia Mashkov
c2d0992015 feat(FontVirtualList): move logic related to loading next batch of fonts to the FontVirtualContainer 2026-02-15 22:56:37 +03:00
Ilia Mashkov
bc56265717 feat(ComparisonSlider): add out animation for SliderLine 2026-02-15 22:54:07 +03:00
Ilia Mashkov
2f45dc3620 feat(Controls): remove isLoading flag 2026-02-12 12:20:52 +03:00
Ilia Mashkov
d282448c53 feat(CharacterSlot): remove touch from characters 2026-02-12 12:20:06 +03:00
Ilia Mashkov
f2e8de1d1d feat(comparisonStore): add the check before loading 2026-02-12 12:19:11 +03:00
Ilia Mashkov
cee2a80c41 feat(FontListItem): delete springs to imrove performance 2026-02-12 11:24:16 +03:00
Ilia Mashkov
8b02333c01 feat(createVirtualizer): slidthly improve batching with version trigger 2026-02-12 11:23:27 +03:00
Ilia Mashkov
0e85851cfd fix(FontApplicator): remove unused prop 2026-02-12 11:21:04 +03:00
Ilia Mashkov
7dce7911c0 feat(FontSampler): remove backdrop filter since it's not being used and bad for performance 2026-02-12 11:16:01 +03:00
Ilia Mashkov
5e3929575d feat(FontApplicator): remove IntersectionObserver to ease the product, font applying logic is entirely in the VirtualList 2026-02-12 11:14:22 +03:00
Ilia Mashkov
d3297d519f feat(SampleList): add throttling to the checkPosition function 2026-02-12 11:11:22 +03:00
Ilia Mashkov
21d8273967 feat(VirtualList): add throttling 2026-02-12 10:32:25 +03:00
Ilia Mashkov
cdb2c355c0 fix: add types for env variables 2026-02-12 10:31:23 +03:00
Ilia Mashkov
3423eebf77 feat: install lenis 2026-02-12 10:31:02 +03:00
Ilia Mashkov
08d474289b chore: add export/import 2026-02-12 10:30:43 +03:00
Ilia Mashkov
2e6fc0e858 feat(throttle): add tohrottling util 2026-02-12 10:29:52 +03:00
Ilia Mashkov
173816b5c0 feat(lenis): add smooth scroll solution 2026-02-12 10:29:08 +03:00
Ilia Mashkov
d749f86edc feat: add color variables and use them acros the project 2026-02-10 23:19:27 +03:00
Ilia Mashkov
8aad8942fc feat(BreadcrumbHeader): add anchor to scroll to the section from the breadcrumb 2026-02-10 21:19:30 +03:00
Ilia Mashkov
0eebe03bf8 feat(Page): add id and pass it to scrollBreadcrumbStore 2026-02-10 21:18:49 +03:00
Ilia Mashkov
2508168a3e feat(Section): add id prop and pass it to onTitleStatusChange callback 2026-02-10 21:17:50 +03:00
Ilia Mashkov
a557e15759 feat(scrollBreadcrumbStore): add id field and comments 2026-02-10 21:16:32 +03:00
Ilia Mashkov
a5b9238306 chore: add export/import 2026-02-10 21:15:52 +03:00
Ilia Mashkov
f01299f3d1 feat(smoothScroll): add util to smoothly scroll to the id after anchor click 2026-02-10 21:15:39 +03:00
223dff2cda Merge pull request 'fixes/mobile-comparator' (#25) from fixes/mobile-comparator into main
All checks were successful
Workflow / build (push) Successful in 1m5s
Workflow / publish (push) Successful in 33s
Reviewed-on: #25
2026-02-10 16:21:43 +00:00
Ilia Mashkov
945132b6f5 feat(ComparisonSlider): add untrack to the effect to limit triggers
All checks were successful
Workflow / build (pull_request) Successful in 1m26s
Workflow / publish (pull_request) Has been skipped
2026-02-10 18:15:42 +03:00
Ilia Mashkov
e1117667d2 feat(ComparisonSlider): add appearance animation to the slider line 2026-02-10 18:14:43 +03:00
Ilia Mashkov
1c2fca784f chore: remove unused code and add animation 2026-02-10 18:14:17 +03:00
Ilia Mashkov
3f0761aca7 chore: remove unused props 2026-02-10 18:13:03 +03:00
Ilia Mashkov
0db13404e2 feat(ComparisonSlider): add effect with apply fonts logic to ensure that even when controls are hiddent fonts are applied 2026-02-10 18:12:17 +03:00
Ilia Mashkov
e39ed86a04 feat(ExpanableWrapper): add onResize prop and trigger it in ResizeObserver 2026-02-10 18:10:52 +03:00
Ilia Mashkov
b43aa99f3e feat(comparisonStore): add checkFontsLoading method to improve isLoading flag 2026-02-10 18:09:59 +03:00
Ilia Mashkov
0a52bd6f6b feat(FontApplicator): switch from props to derived state from comparisonStore, apply the fonts 2026-02-10 18:09:13 +03:00
Ilia Mashkov
4734b1120a feat(ComboControl): reduce horizontal padding 2026-02-10 18:05:48 +03:00
Ilia Mashkov
7aa9fbd394 feat(appliedFontsStore): explicidly state usage of woff2 2026-02-10 18:05:13 +03:00
1eef9eff07 Merge pull request 'feature/initial-font-load' (#24) from feature/initial-font-load into main
All checks were successful
Workflow / build (push) Successful in 58s
Workflow / publish (push) Successful in 30s
Reviewed-on: #24
2026-02-10 10:10:53 +00:00
Ilia Mashkov
aefe03d811 feat: use class for barlow font with fallbacks
All checks were successful
Workflow / build (pull_request) Successful in 1m9s
Workflow / publish (pull_request) Has been skipped
2026-02-10 13:09:42 +03:00
Ilia Mashkov
e90b2bede5 feat(Page): add appearance animation that is slightly delayed to ensure font loading and lack of FOIT 2026-02-10 13:09:09 +03:00
Ilia Mashkov
bb8d2d685c feat(Layout): add font loading flag and change head links to prevent FOUT 2026-02-10 13:08:07 +03:00
Ilia Mashkov
c8d249d6ce feat(app.css): add fallbacks for the fonts to prevent FOUT 2026-02-10 13:04:26 +03:00
e3050097c6 Merge pull request 'fixes/immediate' (#23) from fixes/immediate into main
All checks were successful
Workflow / build (push) Successful in 58s
Workflow / publish (push) Successful in 30s
Reviewed-on: #23
2026-02-10 08:50:43 +00:00
Ilia Mashkov
faf9b8570b fix(createCharacterComparison): change line break logic to ensure correct text wrap
All checks were successful
Workflow / build (pull_request) Successful in 1m14s
Workflow / publish (pull_request) Has been skipped
2026-02-10 11:47:54 +03:00
Ilia Mashkov
1fc9572f3d feat(appliedFontStore): use FontFace constructor, improve the performance and add test coverage for basic logic 2026-02-10 10:14:46 +03:00
Ilia Mashkov
d006c662a9 feat(FontApplicator): add system fonts and change animation 2026-02-10 10:12:58 +03:00
Ilia Mashkov
422363d329 chore: remove unused code 2026-02-09 17:33:09 +03:00
Ilia Mashkov
61c67acfb8 fix(SampleList): render TypographyMenu every time but hide it when needed 2026-02-09 16:49:56 +03:00
Ilia Mashkov
6945169279 feat(TypographyMenu): add props hidden to hide component but fire the logic 2026-02-09 16:49:06 +03:00
Ilia Mashkov
055b02f720 fix: indentation 2026-02-09 16:48:33 +03:00
Ilia Mashkov
7018b6a836 fix(Logo): add fallback for the safari and chrome for text-justify:inter-character rule 2026-02-09 16:48:11 +03:00
Ilia Mashkov
5d8869b3f2 fix(ComparisonSlider): remove blur inside the sliders line and add gpu acceleration. imrove animation duration 2026-02-09 16:47:19 +03:00
Ilia Mashkov
cb740df1b2 feat: add caddyfile
All checks were successful
Workflow / build (push) Successful in 1m1s
Workflow / publish (push) Successful in 32s
2026-02-09 15:27:14 +03:00
Ilia Mashkov
d40170cfad fix: caddy setup in dockerfile
Some checks failed
Workflow / build (push) Successful in 1m3s
Workflow / publish (push) Failing after 11s
2026-02-09 15:22:57 +03:00
Ilia Mashkov
3787ae260f fix: update dockerfile with env variable for node linker
All checks were successful
Workflow / build (push) Successful in 56s
Workflow / publish (push) Successful in 51s
2026-02-09 14:28:55 +03:00
Ilia Mashkov
a8858f6199 fix: update dockerfile with corepack so we can use yarn v4
Some checks failed
Workflow / build (push) Successful in 1m0s
Workflow / publish (push) Failing after 27s
2026-02-09 14:21:33 +03:00
Ilia Mashkov
b1de03106f chore: add publish job for cicd
Some checks failed
Workflow / build (push) Successful in 57s
Workflow / publish (push) Failing after 15s
2026-02-09 12:51:01 +03:00
Ilia Mashkov
f3e9777267 feat: switch to caddy
All checks were successful
Workflow / build (push) Successful in 58s
2026-02-09 11:37:47 +03:00
Ilia Mashkov
c4abe84b0a feat: add env variable to Dockerfile
All checks were successful
Workflow / build (push) Successful in 55s
2026-02-09 10:52:37 +03:00
Ilia Mashkov
1bd996659e feat: change Dockerfile server to python one
All checks were successful
Workflow / build (push) Successful in 56s
2026-02-09 10:44:51 +03:00
Ilia Mashkov
e810135fc5 feat: create Dockerfile
All checks were successful
Workflow / build (push) Successful in 58s
2026-02-09 10:17:48 +03:00
Ilia Mashkov
fc5a5c44e7 feat: edit readme.md
All checks were successful
Workflow / build (push) Successful in 56s
2026-02-09 09:57:41 +03:00
d64de6f06b Merge pull request 'feature/responsive' (#22) from feature/responsive into main
All checks were successful
Workflow / build (push) Successful in 58s
Reviewed-on: #22
2026-02-09 06:49:24 +00:00
Ilia Mashkov
10788cf754 feat(Layout): add basic title for project
All checks were successful
Workflow / build (pull_request) Successful in 1m15s
2026-02-09 09:44:47 +03:00
Ilia Mashkov
8eca240982 feat(Layout): add custom favicon 2026-02-09 09:39:58 +03:00
Ilia Mashkov
6f840fbad8 chore(TypographyMenu): use 2nd version of combo control 2026-02-09 09:32:43 +03:00
Ilia Mashkov
a7d08a9329 feat(TypographyMenu): add snippets to reduce repetitions 2026-02-09 09:32:08 +03:00
Ilia Mashkov
df2d6bae3b feat(Input): create ghost variant styling 2026-02-09 09:31:25 +03:00
Ilia Mashkov
ce9665a842 feat(ComboControlV2): merge two version of component into one with reduced prop that regulate appearance 2026-02-09 09:30:34 +03:00
Ilia Mashkov
b4e97da3a0 feat(ComparisonSlider): slightly tweak styles 2026-02-08 14:32:21 +03:00
Ilia Mashkov
b3c0898735 feat(ComparisonSlider): add orientation prop value 2026-02-08 14:32:01 +03:00
Ilia Mashkov
f4875d7324 feat(ComboControlV2): rewrite controls to use custom bits-ui slider 2026-02-08 14:31:15 +03:00
Ilia Mashkov
b16928ac80 feat(Slider): create reusable slider component - a styled version of bits-ui slider 2026-02-08 14:18:17 +03:00
Ilia Mashkov
7f01a9cc85 feat(Drawer): add default padding classes for content snippet 2026-02-07 19:26:46 +03:00
Ilia Mashkov
a1bc359c7f feat(Input): move extended left padding into SearchBar classes 2026-02-07 19:18:49 +03:00
Ilia Mashkov
662d4ac626 chore: remove unused code 2026-02-07 19:15:30 +03:00
Ilia Mashkov
4d7ae6c1c6 feat(TypographyMenu): merge SetupFontMenu and TypographyMenu into one component, add drawer logic for mobile resolution 2026-02-07 19:15:04 +03:00
Ilia Mashkov
cb0e89b257 feat(SetupFont): add multiplier constants 2026-02-07 19:12:39 +03:00
Ilia Mashkov
204aa75959 feat(SampleList): move TypographyMenu to SampleList to show/hide it when list is visible on a screen 2026-02-07 18:39:52 +03:00
Ilia Mashkov
b72ec8afdf chore(FontSearch): remove unused code 2026-02-07 18:21:19 +03:00
Ilia Mashkov
fa08986d60 chore(SearchBar): remove unused code 2026-02-07 18:19:16 +03:00
Ilia Mashkov
359617212d feat: shadcn drawer dependencies 2026-02-07 18:17:09 +03:00
Ilia Mashkov
beff194e5b fix(Layout): fix import path 2026-02-07 18:16:44 +03:00
Ilia Mashkov
f24c93c105 chore: add exports/imports 2026-02-07 18:16:08 +03:00
Ilia Mashkov
c16ef4acbf chore: remove unused code 2026-02-07 18:15:45 +03:00
Ilia Mashkov
c91ced3617 chore(Page): uncomment compararison slider 2026-02-07 18:15:14 +03:00
Ilia Mashkov
a48c9bce0c feat(ComparisonSlider): slightly tweak line styles for better mobile UX 2026-02-07 18:14:39 +03:00
Ilia Mashkov
152be85e34 feat(ComparisonSlider): add separate typographyManager instance into comparisonStore and use its controls in the slider. Improve mobile usability using Drawer for all the settings 2026-02-07 18:14:07 +03:00
Ilia Mashkov
b09b89f4fc feat(ExpandableWrapper): slightly change wrapper styles for better UX on mobile 2026-02-07 18:08:49 +03:00
Ilia Mashkov
1a23ec2f28 feat(ComboControlV2): add orientation prop and remove unused code 2026-02-07 18:07:28 +03:00
Ilia Mashkov
86ea9cd887 chore(SetupFont): move initial typography control config into constants 2026-02-07 18:06:13 +03:00
Ilia Mashkov
10919a9881 feat(controlManager): add getters for controls and custom storageId parameter for persistent storage 2026-02-07 18:05:14 +03:00
Ilia Mashkov
180abd150d chore(TypographyMenu): move component to SetupFont feature layer 2026-02-07 18:03:54 +03:00
Ilia Mashkov
c4bfb1db56 chore(SearchBar): replace input with reusable one 2026-02-07 18:02:32 +03:00
Ilia Mashkov
98a94e91ed feat(Input): create reusable input component 2026-02-07 18:01:48 +03:00
Ilia Mashkov
a1b7f78fc4 feat(Drawer): create reusable Drawer component with snippets for trigger and content 2026-02-07 18:01:20 +03:00
Ilia Mashkov
41c5ceb848 feat(drawer): add shadcn drawer 2026-02-07 18:00:38 +03:00
Ilia Mashkov
780d76dced fix(TypographyMenu): correct responsive settings 2026-02-07 11:28:52 +03:00
Ilia Mashkov
49f5564cc9 feat(controlManager): integrate persistent storage into control manager to keep typography settings between sessions 2026-02-07 11:28:13 +03:00
Ilia Mashkov
0ff8aec8f9 chore: add export/import 2026-02-07 11:26:53 +03:00
Ilia Mashkov
597ff7ec90 feat(createTypographyControl): add generic for identficator 2026-02-07 11:26:18 +03:00
Ilia Mashkov
46a3c3e8fc feat(ComboControl): add reduced flag that removes increase/decrease buttons keeping the slider popover 2026-02-07 11:24:44 +03:00
Ilia Mashkov
4891cd3bbd feat(PersistentStore): add type for PersistentStore 2026-02-07 11:23:12 +03:00
Ilia Mashkov
70f2f82df0 feat: add props type 2026-02-06 15:57:03 +03:00
Ilia Mashkov
0d572708c0 chore: replace custom components with footnote and logo components 2026-02-06 15:56:48 +03:00
Ilia Mashkov
492c3573d0 feat(Footnote): add component for footnote text 2026-02-06 15:55:46 +03:00
Ilia Mashkov
a1080d3b34 feat(Logo): add a separate component for project logo 2026-02-06 15:36:52 +03:00
Ilia Mashkov
fedf3f88e7 feat: add tailwind responsive classes 2026-02-06 14:48:44 +03:00
Ilia Mashkov
a26bcbecff feat(responsiveManager): add a manager to monitor responsive state and give access to responsive state flags 2026-02-06 14:20:32 +03:00
Ilia Mashkov
352f30a558 feat(VirtualList): add will-change: transform to absolute positioned components 2026-02-06 13:38:03 +03:00
Ilia Mashkov
8580884896 fix(createVirtualizer): change resize and scroll logic to support mobile and tablet screens 2026-02-06 13:37:20 +03:00
Ilia Mashkov
84417e440f fix(Layout): hide x overflow 2026-02-06 13:36:15 +03:00
8fda47ed57 Merge pull request 'feature/loading' (#21) from feature/loading into main
All checks were successful
Workflow / build (push) Successful in 52s
Reviewed-on: #21
2026-02-06 09:20:07 +00:00
Ilia Mashkov
1b9fe14f01 fix(FontSampler): comment unused button
All checks were successful
Workflow / build (pull_request) Successful in 1m9s
2026-02-06 12:17:11 +03:00
Ilia Mashkov
3537f6f62c fix(FontSearch): change button size to normal 2026-02-06 12:16:42 +03:00
Ilia Mashkov
88f4cd97f9 feat(SampleList): remove text loader and add a prop isLoading 2026-02-06 12:05:29 +03:00
Ilia Mashkov
9167629616 chore: change export/import 2026-02-06 12:04:53 +03:00
Ilia Mashkov
b304e841de feat(ComparisonSlider): integrate loader and add animations for appearance/disappearance 2026-02-06 12:04:32 +03:00
Ilia Mashkov
3ed63562b7 feat(Loader): create loader component with spinner and optional message 2026-02-06 12:03:06 +03:00
Ilia Mashkov
4b440496ba feat(comparisonStore): add isLoading getter 2026-02-06 11:55:31 +03:00
Ilia Mashkov
e4aacf609e feat(VirtualList): add isLoading prop 2026-02-06 11:55:05 +03:00
Ilia Mashkov
51c2b6b5da chore(Page): change icon 2026-02-06 11:54:36 +03:00
Ilia Mashkov
195ae09fa2 feat(Spinner): add shadcn spinner component 2026-02-06 11:54:09 +03:00
Ilia Mashkov
b9eccbf627 feat(Skeleton): create skeleton component and integrate it into FontVirtualList 2026-02-06 11:53:59 +03:00
Ilia Mashkov
63888e510c feat(Spinner): add shadcn spinner component 2026-02-06 11:43:39 +03:00
cf8d3dffb9 Merge pull request 'fix/filtration' (#20) from fix/filtration into main
All checks were successful
Workflow / build (push) Successful in 51s
Reviewed-on: #20
2026-02-05 08:51:44 +00:00
Ilia Mashkov
1e2daa410c fix(baseFontStore): fix the filtration problem when results didnt update after filter was deselected
All checks were successful
Workflow / build (pull_request) Successful in 1m5s
2026-02-05 11:45:36 +03:00
Ilia Mashkov
adf6dc93ea feat(appliedFontsStore): improvement that allow to use correct urls for variable fonts and fixes font weight problems 2026-02-05 11:44:16 +03:00
Ilia Mashkov
596a023d24 chore: add export/import 2026-02-05 11:40:59 +03:00
Ilia Mashkov
8195e9baa8 feat(getFontUrl): create a helper function to choose font url 2026-02-05 11:40:23 +03:00
Ilia Mashkov
0554fcada7 feat(normalize): use type UnifiedFontVariant instead of string 2026-02-05 11:39:56 +03:00
Ilia Mashkov
9a794b626b feat(normalize): use type FontVariant instead of string 2026-02-05 11:39:20 +03:00
Ilia Mashkov
40346aa9aa chore(Font): move font types related to weight to common types 2026-02-05 11:38:38 +03:00
Ilia Mashkov
2b7f21711b feat(BreadcrumbHeader): add a logo and change the animation
All checks were successful
Workflow / build (push) Successful in 55s
2026-02-04 10:49:13 +03:00
Ilia Mashkov
69ae955131 feat(Page): move breadcrumb header to the layout and add a logo section 2026-02-04 10:48:40 +03:00
Ilia Mashkov
12844432ac feat(Section): add a snippet prop for description 2026-02-04 10:47:04 +03:00
a9aba10f09 Merge pull request 'feature/comparison-slider' (#19) from feature/comparison-slider into main
All checks were successful
Workflow / build (push) Successful in 53s
Reviewed-on: #19
2026-02-02 09:23:45 +00:00
Ilia Mashkov
778839d35e feat(Page): switch some sections
All checks were successful
Workflow / build (pull_request) Successful in 1m9s
2026-02-02 12:21:23 +03:00
Ilia Mashkov
92fb314615 feat(TypographyMenu): add comments and delete outdated code 2026-02-02 12:20:57 +03:00
Ilia Mashkov
6f0b69ff45 chore: incorporate renewed appliderFontStore and comparisonStore logic 2026-02-02 12:20:19 +03:00
Ilia Mashkov
2cd38797b9 feat(FontSearch): add IconButton instead of regular Button and delete unused code 2026-02-02 12:20:01 +03:00
Ilia Mashkov
6f231999e0 chore: add export/import and remove unused ones 2026-02-02 12:19:05 +03:00
Ilia Mashkov
31a72d90ea chore: incorporate renewed appliderFontStore and comparisonStore logic 2026-02-02 12:18:20 +03:00
Ilia Mashkov
072690270f chore(SearchBar): delete unused code and slightly tweak appearance 2026-02-02 12:16:51 +03:00
Ilia Mashkov
eaf9d069c5 feat(VirtualList): incoroprate new logic related to window scroll and separation of isVisible flag 2026-02-02 12:16:04 +03:00
Ilia Mashkov
4a94f7bd09 feat(FontListItem): separate isVisible flags into two (partial and fully) 2026-02-02 12:13:58 +03:00
Ilia Mashkov
918e792e41 fix(Layout): temporaly remove ScrollArea to fix virtual list 2026-02-02 12:13:07 +03:00
Ilia Mashkov
c9c8b9abfc feat(Section): add logic that triggers a callback when sections title moves out of the viewport 2026-02-02 12:11:48 +03:00
Ilia Mashkov
a392b575cc chore: migrate from direct <link> with css towards font-face approach 2026-02-02 12:10:38 +03:00
Ilia Mashkov
961475dea0 refactor(appliedFontsStore): migrate from direct <link> with css towards font-face approach 2026-02-02 12:10:12 +03:00
Ilia Mashkov
5496fd2680 chore: delete unused code 2026-02-02 12:09:16 +03:00
Ilia Mashkov
f90f1e39e0 feat(createVirtualizer): refine virtualizer logic, add useWindowScroll flag to use window scroll 2026-02-02 12:04:19 +03:00
Ilia Mashkov
ca161dfbd4 feat(ComparisonSlider): migrate from displayStore to comparisonStore 2026-02-02 12:02:33 +03:00
Ilia Mashkov
ac2d0c32a4 chore: add import/export 2026-02-02 12:00:58 +03:00
Ilia Mashkov
54d22d650d chore: add import/export 2026-02-02 12:00:19 +03:00
Ilia Mashkov
a9c63f2544 feat(Breadcrumb): create new entity that contains logic related to breadcrumb-like navigation 2026-02-02 11:59:57 +03:00
Ilia Mashkov
70f57283a8 feat(comparisonStore): replace displayStore with comparisonStore that has only the logic related to ComparisonSlider 2026-02-02 11:58:50 +03:00
Ilia Mashkov
d43c873dc9 feat(createPersistentStore): add a solution to keep user info between sections using browser storage 2026-02-02 11:57:00 +03:00
Ilia Mashkov
9501dbf281 chore: add import/export 2026-02-01 16:13:13 +03:00
Ilia Mashkov
0ac6acd174 feat(proxyFonts): add fetchFontsById function that fetches batch of fonts 2026-02-01 16:12:37 +03:00
Ilia Mashkov
5bb41c7e4c chore: comment typo 2026-02-01 11:58:22 +03:00
Ilia Mashkov
eed3339b0d feat(FontSearch): refactor component styles 2026-02-01 11:57:56 +03:00
Ilia Mashkov
d94e3cefb2 feat(SearchBar): move away from popover due to unnecessary complication and ux problems 2026-02-01 11:56:39 +03:00
Ilia Mashkov
cfb586f539 feat(SampleList): move font list display into widget layer 2026-02-01 11:55:46 +03:00
Ilia Mashkov
6e975e5f8e feat(VirtualList): add animate logic 2026-02-01 11:54:40 +03:00
Ilia Mashkov
142e4f0a19 feat(Page): display all components without conditions 2026-02-01 11:53:57 +03:00
Ilia Mashkov
59b85eead0 chore: remove unnecessary comments 2026-02-01 11:52:58 +03:00
Ilia Mashkov
010643e398 chore: add import/export 2026-02-01 11:52:32 +03:00
Ilia Mashkov
27f637531b feat(FontListItem): use children instead of the direct representation of the font 2026-02-01 11:52:09 +03:00
Ilia Mashkov
91fa08074b feat(VirtualList): incorporate shadcn scroll area to replace default scoll bar 2026-01-31 11:53:18 +03:00
Ilia Mashkov
c246f70fe9 feat(Labels): change the styles of the component 2026-01-31 11:48:58 +03:00
Ilia Mashkov
b1ce734f19 feat(VirtualList): VirtualList now supports pagination, it loads batches when user scrolls near the end of current batch 2026-01-31 11:48:14 +03:00
Ilia Mashkov
3add50a190 feat(VirtualList): add auto-pagination and correct scrollbar height
- Add 'total' prop to VirtualList for accurate scrollbar height in pagination scenarios
- Add 'onNearBottom' callback to trigger auto-loading when user scrolls near end
- Update FontVirtualList to forward the new props
- Implement auto-pagination in SuggestedFonts component (remove manual Load More button)
- Display loading indicator when fetching next batch
- Show accurate font count (e.g., "Showing 150 of 1920 fonts")

Key changes:
- VirtualList now uses total count for height calculation instead of items.length
- Auto-fetches next page when user scrolls within 5 items of the end
- Only fetches if hasMore is true and not already fetching
- Backward compatible: total defaults to items.length when not provided
2026-01-30 19:22:21 +03:00
Ilia Mashkov
ef48d9815c feat(Page): add Section wrappers to page widgets 2026-01-30 17:46:21 +03:00
Ilia Mashkov
818dfdb55e feat(Layout): increase bottom gap for TypographyMenu 2026-01-30 17:45:08 +03:00
Ilia Mashkov
42e1271647 feat(ContentEditable): add comments 2026-01-30 17:44:18 +03:00
Ilia Mashkov
8ef9226dd2 feat(IconButton): slightlyu change the style 2026-01-30 17:43:46 +03:00
Ilia Mashkov
f0c0a9de45 feat(ComparisonSlider): move in/out animation to Section component 2026-01-30 17:43:19 +03:00
Ilia Mashkov
730eba138d feat(FontSampler): refactor styles of FontSampler component 2026-01-30 17:42:06 +03:00
Ilia Mashkov
18f265974e chore: add import/export 2026-01-30 17:40:26 +03:00
Ilia Mashkov
705723b009 feat(Section): create a section wrapper for a page 2026-01-30 17:40:11 +03:00
Ilia Mashkov
75ea5ab382 chore: change dprint formatting 2026-01-30 01:09:39 +03:00
Ilia Mashkov
f07b699926 feat(FontDisplay): add animation on displayed fonts list order change 2026-01-30 00:56:58 +03:00
Ilia Mashkov
b031e560af feat(FontSampler): add delete button to remove font from the list of selected fonts, improve styling 2026-01-30 00:56:21 +03:00
Ilia Mashkov
fbaf596fef fix(createCharacterComparison): improve characters measurment for better magnifying presicion 2026-01-30 00:54:40 +03:00
Ilia Mashkov
1a2c44fb97 chore: add import/export 2026-01-30 00:53:06 +03:00
Ilia Mashkov
04602f0372 feat(ComboControl): use IconButton component 2026-01-30 00:52:42 +03:00
Ilia Mashkov
433fd2f7e6 feat(IconButton): create IconButton component to reuse styles for small buttons 2026-01-30 00:52:17 +03:00
Ilia Mashkov
87c4e04458 feat(controlManager): add getter for letter spacing value 2026-01-30 00:48:29 +03:00
Ilia Mashkov
fb843c87af chore: add import/export for letter spacing constant 2026-01-30 00:48:07 +03:00
Ilia Mashkov
b2af3683bc chore: change default font size 2026-01-30 00:47:44 +03:00
Ilia Mashkov
90f11d8d16 chore(Labels): formatting 2026-01-30 00:47:07 +03:00
Ilia Mashkov
a3f9bc12a0 feat(CharacterSlot): slightly increase magnifying effect 2026-01-30 00:46:43 +03:00
Ilia Mashkov
6634f6df1e feature(SliderLine): complete rework of the slider line, now it look like a magnifying glass 2026-01-30 00:45:45 +03:00
Ilia Mashkov
3f7ce63736 feat(FontListItem): slightly change badge styling 2026-01-30 00:44:01 +03:00
Ilia Mashkov
c665a579be chore: update gitignore 2026-01-30 00:43:16 +03:00
Ilia Mashkov
ac7f094d13 chore: delete unused code 2026-01-30 00:42:58 +03:00
Ilia Mashkov
c06aad1a8a fix: Correct dynamic import paths in fallback function
- Use  path aliases instead of relative paths
- Fixes module resolution errors when importing from other files
- Ensures fallback to Fontshare API works correctly
2026-01-29 15:23:59 +03:00
Ilia Mashkov
471e186e70 fix: Fix undefined query data and add fallback to Fontshare API
- Add gcTime parameter to TanStack Query config
- Add response validation in fetchFn with detailed logging
- Add fallback to Fontshare API when proxy fails
- Add USE_PROXY_API flag for easy switching
- Fix FontVirtualList generic type constraint
- Simplify font registration logic
- Add comprehensive console logging for debugging

Fixes: Query data cannot be undefined error
2026-01-29 15:20:51 +03:00
Ilia Mashkov
dc72b9e048 chore(fonts): Complete Proxy API Integration (All 7 Phases)
Summary of all phases implemented:

Phase 1 - Proxy API Client (Days 1-2)
✓ Created src/entities/Font/api/proxy/proxyFonts.ts
✓ Implemented fetchProxyFonts with pagination support
✓ Implemented fetchProxyFontById convenience function
✓ Added TypeScript interfaces: ProxyFontsParams, ProxyFontsResponse

Phase 2 - Unified Font Store (Days 3-4)
✓ Implemented UnifiedFontStore extending BaseFontStore
✓ Added pagination support with derived metadata
✓ Added provider-specific shortcuts (setProvider, setCategory, etc.)
✓ Added pagination methods (nextPage, prevPage, goToPage)
✓ Added category getter shortcuts (sansSerifFonts, serifFonts, etc.)

Phase 3 - Filter Mapper (Day 5)
✓ Updated mapManagerToParams to return Partial<ProxyFontsParams>
✓ Map providers array to single provider value
✓ Map categories array to single category value
✓ Map subsets array to single subset value

Phase 4 - UI Components (Days 6-7)
✓ Replaced fontshareStore with unifiedFontStore in FontSearch.svelte
✓ Replaced fontshareStore with unifiedFontStore in SuggestedFonts.svelte
✓ Updated entity exports to include unifiedFontStore
✓ Updated model exports to include unified store exports

Phase 5 - Integration Testing (Days 8-9)
✓ Verified type checking with no new errors
✓ Verified linting with no new issues
✓ All code compiles correctly

Phase 6 - Cleanup and Removal (Days 10-11)
✓ Deleted googleFontsStore.svelte.ts
✓ Deleted fontshareStore.svelte.ts
✓ Deleted fetchGoogleFonts.svelte.ts
✓ Deleted fetchFontshareFonts.svelte.ts
✓ Deleted services/index.ts
✓ Removed deprecated exports from Font entity
✓ Removed deprecated exports from model index

Phase 7 - Final Verification (Day 12)
✓ Type checking passes (7 pre-existing errors in shadcn)
✓ Linting passes (3 pre-existing warnings)
✓ No remaining references to old stores
✓ All code compiles correctly

Architecture Changes:
- Single unified font store replacing separate Google/Fontshare stores
- Proxy API handles provider aggregation and normalization
- Simplified parameter mapping to single values
- TanStack Query caching maintained
- Backward compatibility preserved for API functions

Benefits:
- Reduced code duplication
- Single source of truth for font data
- Easier to maintain and extend
- Provider-agnostic UI components
2026-01-29 14:48:00 +03:00
Ilia Mashkov
07a37af71a feat(fonts): implement Phase 6 - Cleanup and Removal
- Deleted src/entities/Font/model/store/googleFontsStore.svelte.ts
- Deleted src/entities/Font/model/store/fontshareStore.svelte.ts
- Deleted src/entities/Font/model/services/fetchGoogleFonts.svelte.ts
- Deleted src/entities/Font/model/services/fetchFontshareFonts.svelte.ts
- Deleted src/entities/Font/model/services/index.ts
- Updated Font entity exports to remove deprecated stores
- Updated model index exports to remove deprecated services
- Updated store index to only export unified store

Phase 6/7: Proxy API Integration for GlyphDiff
2026-01-29 14:47:03 +03:00
Ilia Mashkov
d6607e5705 feat(fonts): implement Phase 4 - Update UI Components
- Replaced fontshareStore with unifiedFontStore in FontSearch.svelte
- Replaced fontshareStore with unifiedFontStore in SuggestedFonts.svelte
- Updated Font entity exports to include unifiedFontStore
- Updated model exports to include unified store exports
- Kept fontshareStore exports as deprecated for backward compatibility

Phase 4/7: Proxy API Integration for GlyphDiff
2026-01-29 14:43:07 +03:00
Ilia Mashkov
10801a641a feat(fonts): implement Phase 3 - Update Filter Mapper
- Changed return type to Partial<ProxyFontsParams>
- Map providers array to single provider value (or undefined)
- Map categories array to single category value (or undefined)
- Map subsets array to single subset value (or undefined)
- Added type assertions for proper TypeScript compatibility

Phase 3/7: Proxy API Integration for GlyphDiff
2026-01-29 14:40:31 +03:00
Ilia Mashkov
98eab35615 fix(fonts): remove unused FontCategory import from unifiedFontStore 2026-01-29 14:38:33 +03:00
Ilia Mashkov
7fbeef68e2 feat(fonts): implement Phase 2 - Unified Font Store
- Implemented UnifiedFontStore extending BaseFontStore
- Added pagination support with derived metadata
- Added provider-specific shortcuts (setProvider, setCategory, etc.)
- Added pagination methods (nextPage, prevPage, goToPage)
- Added category getter shortcuts (sansSerifFonts, serifFonts, etc.)
- Updated store exports to include unified store
- Fixed typo in googleFontsStore.svelte.ts (createGoogleFontsStore)

Phase 2/7: Proxy API Integration for GlyphDiff
2026-01-29 14:38:07 +03:00
Ilia Mashkov
7078cb6f8c feat(fonts): implement Phase 1 - Create Proxy API Client
- Created src/entities/Font/api/proxy/proxyFonts.ts
- Implemented fetchProxyFonts function with full pagination support
- Implemented fetchProxyFontById convenience function
- Added TypeScript interfaces: ProxyFontsParams, ProxyFontsResponse
- Added comprehensive JSDoc documentation
- Updated src/entities/Font/api/index.ts to export proxy API

Phase 1/7: Proxy API Integration for GlyphDiff
2026-01-29 14:33:12 +03:00
Ilia Mashkov
0b0489fa26 chore(ExpandableWrapper): add ts-ignore to stories 2026-01-26 13:08:56 +03:00
Ilia Mashkov
2022213921 feat(displayedFontsStore): fix store to work with fontA and fontB to compare 2026-01-26 12:56:35 +03:00
Ilia Mashkov
6725a3b391 feat(Layout): increase container width 2026-01-26 12:55:52 +03:00
Ilia Mashkov
2eddb656a9 chore(FontComparer): delete unused code 2026-01-26 12:55:27 +03:00
Ilia Mashkov
5973d241aa feat(Page): render ComparisonSlider directly 2026-01-26 12:54:40 +03:00
Ilia Mashkov
75a9c16070 feat(ComparisonWrapper): remove props and add checks for fonts absence 2026-01-26 12:54:01 +03:00
Ilia Mashkov
31e4c64193 chore(ComparisonSlider): add comments 2026-01-26 12:52:40 +03:00
Ilia Mashkov
48e25fffa7 feat(ExpandableWrapper): fix keyboard support, tweak styles and animation 2026-01-26 12:52:23 +03:00
Ilia Mashkov
407c741349 feat(ComparisonSlider): add blur background to controls 2026-01-26 12:49:15 +03:00
Ilia Mashkov
13e114fafe feat(TypographyMenu): add appearance/disappearance animation 2026-01-26 12:47:10 +03:00
Ilia Mashkov
1484ea024e chore(ComparisonSlider): add comments and remove unused code 2026-01-26 12:46:12 +03:00
Ilia Mashkov
67db6e22a7 feat(ComparisonSlider): rewrite slider labels to include selects for compared fonts 2026-01-26 12:45:30 +03:00
Ilia Mashkov
192ce2d34a feat(select): add shadcn select component 2026-01-26 12:43:25 +03:00
Ilia Mashkov
2b820230bc feat(createCharacterComparison): add generic for font type and checks for the absence of the fonts 2026-01-26 12:34:27 +03:00
Ilia Mashkov
9b8ebed1c3 fix(breakIntoLines): add word break for long words 2026-01-25 11:42:05 +03:00
Ilia Mashkov
3d11f7317d feat(ExpandableWrapper): add stories 2026-01-25 08:23:11 +03:00
Ilia Mashkov
c07800cc96 chore: add export 2026-01-25 00:00:13 +03:00
Ilia Mashkov
b49bf0d397 feat(ScrollArea): add shadcn scroll area to layout 2026-01-24 23:58:10 +03:00
Ilia Mashkov
ed4ee8bb44 chore(ControlsWrapper): use new reusable wrapper 2026-01-24 23:57:16 +03:00
Ilia Mashkov
8a2059ac4a feat(ExtendableWrapper): create reusable extendable wrapper with animations 2026-01-24 23:56:26 +03:00
Ilia Mashkov
7ffc5d6a34 feat(Page): move search to page 2026-01-24 15:39:38 +03:00
Ilia Mashkov
08cccc5ede chore: add export 2026-01-24 15:39:10 +03:00
Ilia Mashkov
71266f8b22 chore(TypographyMenu): remove search from typography menu 2026-01-24 15:38:50 +03:00
Ilia Mashkov
d5221ad449 feat(SearchBar): improve styling 2026-01-24 15:38:01 +03:00
Ilia Mashkov
873b697e8c feat(ComboControl): Add tooltips and enhance intraction effects 2026-01-24 15:37:06 +03:00
Ilia Mashkov
3dce409034 feat(SetupFontMenu): add props 2026-01-24 15:36:13 +03:00
Ilia Mashkov
cf08f7adfa chore(FontSearch): move to widgets layer 2026-01-24 15:35:26 +03:00
Ilia Mashkov
4b01b1592d feat(ControlsWrapper): close ControlsWrapper on escape click 2026-01-24 15:34:17 +03:00
Ilia Mashkov
ecb4bea642 feat(FlterControls): enhance control with ux effects 2026-01-24 15:33:12 +03:00
Ilia Mashkov
e89c6369cb feat(Layout): add tooltip provider 2026-01-24 15:32:01 +03:00
Ilia Mashkov
18a311c6b1 chore: delete filters sidebar 2026-01-24 15:30:02 +03:00
Ilia Mashkov
732f77f504 feat(CheckboxFilter): use new transition function springySlideFade 2026-01-24 15:25:56 +03:00
Ilia Mashkov
b7992ca138 feat(app): add common animaition for ux elements that can interact with 2026-01-24 15:22:57 +03:00
Ilia Mashkov
32b1367877 feat(springySliderFade): add custom transition function for slide+fade 2026-01-24 15:16:04 +03:00
Ilia Mashkov
59b0d9c620 feat(FontListItem): refactor component to enhance UX with animations and move away from checkboxes to avoid scroll problems 2026-01-22 15:41:55 +03:00
Ilia Mashkov
be13a5c8a0 feat(VirtualList): add proximity and isVisible props 2026-01-22 15:40:37 +03:00
Ilia Mashkov
80efa49ad0 feat(SuggestedFonts): add proximity and isVisible props 2026-01-22 15:40:17 +03:00
Ilia Mashkov
7e9675be80 feat(createVirtualizer): add isVisible and proximity properties to VirtualItem, add filckering prevention check 2026-01-22 15:39:29 +03:00
Ilia Mashkov
ac979c816c feat(FontComparer): add apperance animation for ComparisonSlider 2026-01-22 15:37:49 +03:00
Ilia Mashkov
272c2c2d22 chore: delete unused code 2026-01-22 15:37:03 +03:00
Ilia Mashkov
a9e2898945 feat(typographyControl): add letter spacing control 2026-01-22 15:36:30 +03:00
Ilia Mashkov
1712134f64 feat(SearchBar): enhance searchbar styling 2026-01-22 15:35:18 +03:00
Ilia Mashkov
52111ee941 fix(ControlsWrapper): slight tweak in styles 2026-01-22 15:34:14 +03:00
Ilia Mashkov
e4970e43ba chore: switch to use of svelte native prefersReducedMotion media 2026-01-22 15:33:38 +03:00
Ilia Mashkov
b41c48da67 feat(app): change main font 2026-01-22 15:31:59 +03:00
Ilia Mashkov
1d0ca31262 chore: input path change 2026-01-21 21:57:04 +03:00
Ilia Mashkov
a5380333eb feat(createCharacterComparison): add support for font size change 2026-01-21 21:56:34 +03:00
Ilia Mashkov
46de3c6e87 chore(createTypographyControl): make some props optional 2026-01-21 21:54:48 +03:00
Ilia Mashkov
91300bdc25 feat(ComparisonSlider): Massively improve the slider and move it to the widgets layer 2026-01-21 21:52:55 +03:00
Ilia Mashkov
2ee66316f7 chore(controlManager): rewrite controlManager to classes 2026-01-21 21:51:22 +03:00
Ilia Mashkov
c6d20aae3d feat(ComboControlV2): crete ComboControlV2 - without increase/decrease buttons. Refresh styling of the original one 2026-01-21 21:50:30 +03:00
Ilia Mashkov
a0f184665d feat(ComparisonSlider): Improve Comparison slider's readability, incapsulate some code into separate components and snippets 2026-01-20 14:23:58 +03:00
Ilia Mashkov
d4d2d68d9a feat(appliedFontsStore): incorporate implemented font weight logic 2026-01-20 14:21:07 +03:00
Ilia Mashkov
55a560b785 feat(appliedFontsStore): implement the logic to update font link when font weight changes 2026-01-20 14:17:41 +03:00
Ilia Mashkov
c2542026a4 feat(FontComparer): create FontComparer component that loads fonts and displays ComparisonSlider 2026-01-20 09:40:42 +03:00
Ilia Mashkov
3f8fd357d8 feat(displayedFontStore): add logic for font pairs calculation 2026-01-20 09:39:30 +03:00
Ilia Mashkov
1bd2a4f2f8 fix(fontshareStore): add normalization to reduce amount of requests 2026-01-20 09:36:39 +03:00
Ilia Mashkov
746a377038 feat(FontVirtualList): add font pairs support 2026-01-20 09:35:44 +03:00
Ilia Mashkov
1b76284237 feat(PairSelector): implement PairSelector component that allows to choose the pair of fonts to compare 2026-01-20 09:33:57 +03:00
Ilia Mashkov
b5ad3249ae feat(ComparisonSlider): create reusable comparison slider that compare two fonts for the same text. Line breaking is supported 2026-01-20 09:32:12 +03:00
fb190f82b9 Merge pull request 'feature/storybook-coverage' (#18) from feature/storybook-coverage into main
All checks were successful
Workflow / build (push) Successful in 42s
Reviewed-on: #18
2026-01-18 18:00:41 +00:00
Ilia Mashkov
c0eed67618 chore(shared/ui): enhance stories with cases, controls and documentation
All checks were successful
Workflow / build (pull_request) Successful in 52s
2026-01-18 20:55:36 +03:00
Ilia Mashkov
e7f4304391 chore(storybook): increase height of autodoc stories window 2026-01-18 20:54:48 +03:00
Ilia Mashkov
488857e0ec chore: basic storybook coverage for shared/ui components 2026-01-18 20:08:13 +03:00
Ilia Mashkov
cca69a73ce fix(SearchBar): make id prop unnecessary 2026-01-18 20:07:37 +03:00
Ilia Mashkov
2444e05bb7 chore(storybook): align items inside decorator 2026-01-18 20:06:48 +03:00
Ilia Mashkov
72cc441c6f chore(CheckboxFilter): add stories for CheckboxFilter 2026-01-18 19:25:34 +03:00
Ilia Mashkov
06cb155b47 feat(storybook): add a global decorator for stories 2026-01-18 19:25:07 +03:00
Ilia Mashkov
50c7511698 fix(storybook): add aliases from vite config to storybook 2026-01-18 19:24:11 +03:00
993c63a39d Merge pull request 'feature/searchbar-enhance' (#17) from feature/searchbar-enhance into main
All checks were successful
Workflow / build (push) Successful in 39s
Reviewed-on: #17
2026-01-18 14:04:52 +00:00
Ilia Mashkov
8591985f62 feat(FontApplicator): implement an appearance animation based on existed intersection observer logic and add a reduced motion check
All checks were successful
Workflow / build (pull_request) Successful in 50s
2026-01-18 16:56:53 +03:00
Ilia Mashkov
9cbf4fdc48 doc: comments for codebase and updated documentation 2026-01-18 15:55:07 +03:00
Ilia Mashkov
8356e99382 chore: add import shortcuts 2026-01-18 15:53:44 +03:00
Ilia Mashkov
7ca45c2e63 chore: add import shortcuts 2026-01-18 15:53:16 +03:00
Ilia Mashkov
20f6e193f2 chore: minor changes 2026-01-18 15:01:19 +03:00
Ilia Mashkov
c04518300b chore: remove unused code 2026-01-18 15:00:54 +03:00
Ilia Mashkov
ee074036f6 chore: add import shortcuts 2026-01-18 15:00:26 +03:00
Ilia Mashkov
ba883ef9a8 fix(motion): edit MotionPreference to avoid errors 2026-01-18 15:00:07 +03:00
Ilia Mashkov
28a71452d1 fix(FontListItem): edit FontListItem to work with selectedFontsStore 2026-01-18 14:59:00 +03:00
Ilia Mashkov
b7ce100407 fix(FontSearch): edit component to render suggested fonts 2026-01-18 14:58:05 +03:00
Ilia Mashkov
96b26fb055 feat(FontDisplay): create a FontDisplay component to show selected font samples 2026-01-18 14:57:15 +03:00
Ilia Mashkov
5ef8d609ab feat(SuggestedFonts): create a component for Suggested Virtualized Font List 2026-01-18 14:56:25 +03:00
Ilia Mashkov
f457e5116f feat(displayedFontsStore): create store to manage displayed fonts sample and its content 2026-01-18 14:55:00 +03:00
Ilia Mashkov
e0e0d929bb chore: add import shortcuts 2026-01-18 14:53:14 +03:00
Ilia Mashkov
37ab7f795e feat(selectedFontsStore): create selectedFontsStore to manage selected fonts collection 2026-01-18 14:52:12 +03:00
Ilia Mashkov
af2ef77c30 feat(FontSampler): edit FontSampler to applt font-family through FontApplicator component 2026-01-18 14:48:36 +03:00
Ilia Mashkov
ad18a19c4b chore(FontSampler): delete unused prop 2026-01-18 14:47:31 +03:00
Ilia Mashkov
ef259c6fce chore: add import shortcuts 2026-01-18 14:39:38 +03:00
Ilia Mashkov
5d23a2af55 feat(EntityStore): create a helper for creation of an Entity Store to store and operate over values that have ids 2026-01-18 14:38:58 +03:00
Ilia Mashkov
df8eca6ef2 feat(splitArray): create a util to split an array based on a boolean resulting callback 2026-01-18 14:37:23 +03:00
Ilia Mashkov
7e62acce49 fix(ContentEditable): change logic to support controlled state 2026-01-18 14:35:35 +03:00
Ilia Mashkov
86e7b2c1ec feat(FontListItem): create FontListItem component that visualize selection of a certain font 2026-01-18 12:59:12 +03:00
Ilia Mashkov
da0612942c feat(FontApplicator): create FontApplicator component that register certain font and applies it to the children 2026-01-18 12:57:56 +03:00
Ilia Mashkov
0444f8c114 chore(FontVirtualList): transform FontList into reusable FontVirtualList component with appliedFontsManager support 2026-01-18 12:55:25 +03:00
Ilia Mashkov
6b4e0dbbd0 feat(ContentEditable): create ContentEditable shared component that displays text and allows editing 2026-01-18 12:51:55 +03:00
Ilia Mashkov
7389ec779d feat:(VirtualList) add onVisibleItemsChange prop that triggers when visibleItems list changes 2026-01-18 12:50:17 +03:00
Ilia Mashkov
4d04761d88 feat(appliedFontsStore): create Applied Fonts Manager to manage fonts download 2026-01-18 12:46:11 +03:00
Ilia Mashkov
32da012b26 feat(MotionPreference): Create common logic to store information about prefers-reduced-motion 2026-01-17 14:29:10 +03:00
Ilia Mashkov
71d320535e feat(FontView): integrate FontView into FontList 2026-01-17 09:21:34 +03:00
Ilia Mashkov
71c068bad2 feat(FontView): create a FontView component that adds a link to the head tag and applies font-family to the children 2026-01-17 09:20:58 +03:00
Ilia Mashkov
247b683c87 chore(FontSearch): documentation change 2026-01-17 09:19:47 +03:00
Ilia Mashkov
8c0c91deb7 feat(createVirtualizer): enhance logic with binary search and requestAnimationFrame 2026-01-16 17:48:33 +03:00
Ilia Mashkov
261c19db69 fix(SearchBar): change input behavior to turn off popover toggle on click on trigger and keep it open. Add doc 2026-01-16 17:47:05 +03:00
Ilia Mashkov
a85b3cf217 fix(VirtualList): change styles to show the correct scroll instantly 2026-01-16 17:46:06 +03:00
Ilia Mashkov
f02b19eff5 chore(createFilter): change format 2026-01-16 17:45:11 +03:00
Ilia Mashkov
4dbf91f600 chore(FontList): Move documentation and remove default height 2026-01-16 17:44:07 +03:00
Ilia Mashkov
0daf0bf3bf chore: minor vitest adjustment 2026-01-16 14:00:35 +03:00
Ilia Mashkov
14f9b87680 test(createDebouncedState): create test coverage for createDebouncedState 2026-01-16 14:00:20 +03:00
Ilia Mashkov
3cd9b36411 fix(createFilter): remove dirived from selectedProperties compute 2026-01-16 13:59:39 +03:00
Ilia Mashkov
4c8b5764b3 chore: delete unused code 2026-01-16 13:58:50 +03:00
Ilia Mashkov
62ae0799cc chore(lib): add export 2026-01-16 13:15:10 +03:00
Ilia Mashkov
53c71a437f chore: simplify scripts 2026-01-16 13:14:54 +03:00
Ilia Mashkov
1976affdff fix(tsconfig): add noEmit param to awoid errors 2026-01-16 13:14:33 +03:00
Ilia Mashkov
f3de6c49a3 chore: delete unused code 2026-01-16 12:41:30 +03:00
Ilia Mashkov
42e941083a doc(createDeboucnedState): add JSDoc for createDebouncedState 2026-01-16 12:38:57 +03:00
Ilia Mashkov
86adec01a0 doc(createVirtualizer): add JSDoc for createVirtualizer 2026-01-16 12:27:14 +03:00
Ilia Mashkov
b0812ff606 chore: delete unused code 2026-01-16 12:24:30 +03:00
Ilia Mashkov
deaf38f8ec fix(Page): remove unused code and misleading comments 2026-01-16 10:24:06 +03:00
fefaf3f4c7 Merge pull request 'feature/font-fetching-again' (#16) from feature/font-fetching-again into main
All checks were successful
Workflow / build (push) Successful in 36s
Reviewed-on: #16
2026-01-15 17:12:36 +00:00
Ilia Mashkov
56e6e450e8 fix(createVirtualizer): add correct type to offset array
All checks were successful
Workflow / build (pull_request) Successful in 49s
2026-01-15 20:10:44 +03:00
Ilia Mashkov
824581551f fix(createVirtualizer): change the way array is created 2026-01-15 20:07:58 +03:00
Ilia Mashkov
f97904f165 fix: minor changes 2026-01-15 20:06:51 +03:00
Ilia Mashkov
6129ad61f4 fix: minor changes 2026-01-15 20:05:55 +03:00
Ilia Mashkov
462abdd2bc chore: add README 2026-01-15 20:05:37 +03:00
Ilia Mashkov
429a9a0877 feature(VirtualList): remove tanstack virtual list solution, add self written one 2026-01-15 13:33:59 +03:00
Ilia Mashkov
925d2eec3e chore(workflow): delete comments 2026-01-14 16:06:02 +03:00
Ilia Mashkov
211ed073e6 chore: specify yarn version
All checks were successful
Workflow / build (push) Successful in 44s
2026-01-14 16:02:45 +03:00
Ilia Mashkov
976672ce5e fix(workflow): turn cache on 2026-01-14 16:02:24 +03:00
Ilia Mashkov
83397f3786 fix(workflow): remove cache
All checks were successful
Workflow / build (push) Successful in 38s
2026-01-14 15:54:19 +03:00
Ilia Mashkov
a72c0e8136 fix(workflow): remove cache
Some checks failed
Workflow / build (push) Failing after 40s
2026-01-14 15:47:56 +03:00
Ilia Mashkov
61dd62af2d feat(workflow): simplify workflow
Some checks failed
Workflow / build (push) Failing after 46s
2026-01-14 15:44:30 +03:00
Ilia Mashkov
147ddd226a feat(workflow): simplify workflow
Some checks failed
Workflow / build (push) Failing after 44s
2026-01-14 15:32:24 +03:00
Ilia Mashkov
c6b18f6dd3 fix: svelte check
Some checks failed
Build / build (push) Failing after 37s
Deploy Pipeline / pipeline (push) Failing after 34s
Lint / Lint Code (push) Failing after 28s
Test / Svelte Checks (push) Failing after 35s
2026-01-14 15:27:41 +03:00
c10bbb681a Merge pull request 'fix: lint warnings' (#15) from fixex/lint into main
Some checks failed
Build / build (push) Failing after 37s
Deploy Pipeline / pipeline (push) Failing after 35s
Lint / Lint Code (push) Failing after 28s
Test / Svelte Checks (push) Failing after 35s
Reviewed-on: #15
2026-01-14 12:15:53 +00:00
Ilia Mashkov
7678ab271d fix: lint warnings
Some checks failed
Build / build (pull_request) Failing after 49s
Lint / Lint Code (pull_request) Failing after 38s
Test / Svelte Checks (pull_request) Failing after 44s
2026-01-14 15:14:58 +03:00
360 changed files with 25044 additions and 7945 deletions

View File

@@ -1,562 +0,0 @@
# Gitea Actions CI/CD Setup
This document describes the CI/CD pipeline configuration for the GlyphDiff project using Gitea Actions (GitHub Actions compatible).
## Table of Contents
- [Overview](#overview)
- [Workflow Files](#workflow-files)
- [Workflow Triggers](#workflow-triggers)
- [Setup Instructions](#setup-instructions)
- [Self-Hosted Runner Setup](#self-hosted-runner-setup)
- [Caching Strategy](#caching-strategy)
- [Environment Variables](#environment-variables)
- [Troubleshooting](#troubleshooting)
## Overview
The CI/CD pipeline consists of four main workflows:
1. **Lint** - Code quality checks (oxlint, dprint formatting)
2. **Test** - Type checking and E2E tests (Playwright)
3. **Build** - Production build verification
4. **Deploy** - Deployment automation (optional/template)
All workflows are designed to run on both push and pull request events, with appropriate branch filtering and concurrency controls.
## Workflow Files
### `.gitea/workflows/lint.yml`
**Purpose**: Run code quality checks to ensure code style and formatting standards.
**Checks performed**:
- `oxlint` - Fast JavaScript/TypeScript linter
- `dprint check` - Code formatting verification
**Triggers**:
- Push to `main`, `develop`, `feature/*` branches
- Pull requests to `main` or `develop`
- Manual workflow dispatch
**Cache**: Node modules and Yarn cache
**Concurrency**: Cancels in-progress runs for the same branch when a new commit is pushed.
---
### `.gitea/workflows/test.yml`
**Purpose**: Run type checking and end-to-end tests.
**Jobs**:
#### 1. `type-check` job
- `tsc --noEmit` - TypeScript type checking
- `svelte-check --threshold warning` - Svelte component type checking
#### 2. `e2e-tests` job
- Installs Playwright browsers with system dependencies
- Runs E2E tests using Playwright
- Uploads test report artifacts (retained for 7 days)
- Uploads screenshots on test failure for debugging
**Triggers**: Same as lint workflow
**Cache**: Node modules and Yarn cache
**Artifacts**:
- `playwright-report` - Test execution report
- `playwright-screenshots` - Screenshots from failed tests
---
### `.gitea/workflows/build.yml`
**Purpose**: Verify that the production build completes successfully.
**Steps**:
1. Checkout repository
2. Setup Node.js v20 with Yarn caching
3. Install dependencies with `--frozen-lockfile`
4. Run `svelte-kit sync` to prepare SvelteKit
5. Build the project with `NODE_ENV=production`
6. Upload build artifacts (`.svelte-kit/output`, `.svelte-kit/build`)
7. Run the preview server and verify it responds (health check)
**Triggers**:
- Push to `main` or `develop` branches
- Pull requests to `main` or `develop`
- Manual workflow dispatch
**Cache**: Node modules and Yarn cache
**Artifacts**:
- `build-artifacts` - Compiled SvelteKit output (retained for 7 days)
---
### `.gitea/workflows/deploy.yml`
**Purpose**: Automated deployment pipeline (template configuration).
**Current state**: Placeholder configuration. Uncomment and customize one of the deployment examples.
**Pre-deployment checks**:
- Must pass linting workflow
- Must pass testing workflow
- Must pass build workflow
**Deployment examples included**:
1. **Docker container registry** - Build and push Docker image
2. **SSH deployment** - Deploy to server via SSH
3. **Vercel** - Deploy to Vercel platform
**Triggers**:
- Push to `main` branch
- Manual workflow dispatch with environment selection (staging/production)
**Secrets required** (configure in Gitea):
- For Docker: `REGISTRY_URL`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`
- For SSH: `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`
- For Vercel: `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`
## Workflow Triggers
### Branch-Specific Behavior
| Workflow | Push Triggers | PR Triggers | Runs on Merge |
| -------- | ------------------------------ | -------------------- | ------------- |
| Lint | `main`, `develop`, `feature/*` | To `main`, `develop` | Yes |
| Test | `main`, `develop`, `feature/*` | To `main`, `develop` | Yes |
| Build | `main`, `develop` | To `main`, `develop` | Yes |
| Deploy | `main` only | None | Yes |
### Concurrency Strategy
All workflows use concurrency groups based on the workflow name and branch reference:
```yaml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # or false for deploy workflow
```
This ensures:
- For lint/test/build: New commits cancel in-progress runs (saves resources)
- For deploy: Prevents concurrent deployments (ensures safety)
## Setup Instructions
### Step 1: Verify Gitea Actions is Enabled
1. Navigate to your Gitea instance
2. Go to **Site Administration****Actions**
3. Ensure Actions is enabled
4. Configure default runner settings if needed
### Step 2: Configure Repository Settings
1. Go to your repository in Gitea
2. Click **Settings****Actions**
3. Enable Actions for the repository if not already enabled
4. Set appropriate permissions for read/write access
### Step 3: Push Workflows to Repository
The workflow files are already in `.gitea/workflows/`. Commit and push them:
```bash
git add .gitea/workflows/
git commit -m "Add Gitea Actions CI/CD workflows"
git push origin main
```
### Step 4: Verify Workflows Run
1. Navigate to **Actions** tab in your repository
2. You should see the workflows trigger on the next push
3. Click into a workflow run to view logs and status
### Step 5: Configure Secrets (Optional - for deployment)
1. Go to repository **Settings****Secrets****Actions**
2. Click **Add New Secret**
3. Add secrets required for your deployment method
Example secrets for SSH deployment:
```
DEPLOY_HOST=your-server.com
DEPLOY_USER=deploy
DEPLOY_SSH_KEY=-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
```
## Self-Hosted Runner Setup
### Option 1: Using Gitea's Built-in Act Runner (Recommended)
Gitea provides `act_runner` (compatible with GitHub Actions runner).
#### Install act_runner
On Linux (Debian/Ubuntu):
```bash
wget -O /usr/local/bin/act_runner https://gitea.com/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64
chmod +x /usr/local/bin/act_runner
```
Verify installation:
```bash
act_runner --version
```
#### Register the Runner
1. In Gitea, navigate to repository **Settings****Actions****Runners**
2. Click **New Runner**
3. Copy the registration token
4. Run the registration command:
```bash
act_runner register \
--instance https://your-gitea-instance.com \
--token YOUR_REGISTRATION_TOKEN \
--name "linux-runner-1" \
--labels ubuntu-latest,linux,docker \
--no-interactive
```
#### Start the Runner as a Service
Create a systemd service file at `/etc/systemd/system/gitea-runner.service`:
```ini
[Unit]
Description=Gitea Actions Runner
After=network.target
[Service]
Type=simple
User=git
WorkingDirectory=/var/lib/gitea-runner
ExecStart=/usr/local/bin/act_runner daemon
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
```
Enable and start the service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable gitea-runner
sudo systemctl start gitea-runner
```
#### Check Runner Status
```bash
sudo systemctl status gitea-runner
```
Verify in Gitea: The runner should appear as **Online** with the `ubuntu-latest` label.
### Option 2: Using Self-Hosted Runners with Docker
If you prefer Docker-based execution:
#### Install Docker
```bash
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
```
#### Configure Runner to Use Docker
Ensure the runner has access to the Docker socket:
```bash
sudo usermod -aG docker act_runner_user
```
The workflows will now run containers inside the runner's Docker environment.
### Option 3: Using External Runners (GitHub Actions Runner Compatible)
If you want to use standard GitHub Actions runners:
```bash
# Download and configure GitHub Actions runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64-2.311.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz
tar xzf ./actions-runner-linux-x64-2.311.0.tar.gz
# Configure to point to Gitea instance
./config.sh --url https://your-gitea-instance.com --token YOUR_TOKEN
```
## Caching Strategy
### Node.js and Yarn Cache
All workflows use `actions/setup-node@v4` with built-in caching:
```yaml
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
```
This caches:
- `node_modules` directory
- Yarn cache directory (`~/.yarn/cache`)
- Reduces installation time from minutes to seconds on subsequent runs
### Playwright Cache
Playwright browsers are installed fresh each time. To cache Playwright (optional optimization):
```yaml
- name: Cache Playwright binaries
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-playwright-
```
## Environment Variables
### Default Environment Variables
The workflows use the following environment variables:
```bash
NODE_ENV=production # For build workflow
NODE_VERSION=20 # Node.js version used across all workflows
```
### Custom Environment Variables
To add custom environment variables:
1. Go to repository **Settings****Variables****Actions**
2. Click **Add New Variable**
3. Add variable name and value
4. Set scope (environment, repository, or organization)
Example for feature flags:
```
ENABLE_ANALYTICS=false
API_URL=https://api.example.com
```
Access in workflow:
```yaml
env:
API_URL: ${{ vars.API_URL }}
ENABLE_ANALYTICS: ${{ vars.ENABLE_ANALYTICS }}
```
## Troubleshooting
### Workflows Not Running
**Symptoms**: Workflows don't appear or don't trigger
**Solutions**:
1. Verify Actions is enabled in Gitea site administration
2. Check repository Settings → Actions is enabled
3. Verify workflow files are in `.gitea/workflows/` directory
4. Check workflow YAML syntax (no indentation errors)
### Runner Offline
**Symptoms**: Runner shows as **Offline** or **Idle**
**Solutions**:
1. Check runner service status: `sudo systemctl status gitea-runner`
2. Review runner logs: `journalctl -u gitea-runner -f`
3. Verify network connectivity to Gitea instance
4. Restart runner: `sudo systemctl restart gitea-runner`
### Linting Fails with Formatting Errors
**Symptoms**: `dprint check` fails on CI but passes locally
**Solutions**:
1. Ensure dprint configuration (`dprint.json`) is committed
2. Run `yarn dprint fmt` locally before committing
3. Consider adding auto-fix workflow (see below)
### Playwright Tests Timeout
**Symptoms**: E2E tests fail with timeout errors
**Solutions**:
1. Check `playwright.config.ts` timeout settings
2. Ensure preview server starts before tests run (built into config)
3. Increase timeout in workflow:
```yaml
- name: Run Playwright tests
run: yarn test:e2e
env:
PLAYWRIGHT_TIMEOUT: 60000
```
### Build Fails with Out of Memory
**Symptoms**: Build fails with memory allocation errors
**Solutions**:
1. Increase Node.js memory limit:
```yaml
- name: Build project
run: yarn build
env:
NODE_OPTIONS: --max-old-space-size=4096
```
2. Ensure runner has sufficient RAM (minimum 2GB recommended)
### Permission Denied on Runner
**Symptoms**: Runner can't access repository or secrets
**Solutions**:
1. Verify runner has read access to repository
2. Check secret names match exactly in workflow
3. Ensure runner user has file system permissions
### Yarn Install Fails with Lockfile Conflict
**Symptoms**: `yarn install --frozen-lockfile` fails
**Solutions**:
1. Ensure `yarn.lock` is up-to-date locally
2. Run `yarn install` and commit updated `yarn.lock`
3. Do not use `--frozen-lockfile` if using different platforms (arm64 vs amd64)
### Slow Workflow Execution
**Symptoms**: Workflows take too long to complete
**Solutions**:
1. Verify caching is working (check logs for "Cache restored")
2. Use `--frozen-lockfile` for faster dependency resolution
3. Consider matrix strategy for parallel execution (not currently used)
4. Optimize Playwright tests (reduce test count, increase timeouts only if needed)
## Best Practices
### 1. Keep Dependencies Updated
Regularly update action versions:
```yaml
- uses: actions/checkout@v4 # Update from v3 to v4 when available
- uses: actions/setup-node@v4
```
### 2. Use Frozen Lockfile
Always use `--frozen-lockfile` in CI to ensure reproducible builds:
```bash
yarn install --frozen-lockfile
```
### 3. Monitor Workflow Status
Set up notifications for workflow failures:
- Email notifications in Gitea user settings
- Integrate with Slack/Mattermost for team alerts
- Use status badges in README
### 4. Test Locally Before Pushing
Run the same checks locally:
```bash
yarn lint # oxlint
yarn dprint check # Formatting check
yarn tsc --noEmit # Type check
yarn test:e2e # E2E tests
yarn build # Build
```
### 5. Leverage Git Hooks
The project uses lefthook for pre-commit/pre-push checks. This catches issues before they reach CI:
```bash
# Pre-commit: Format code, lint staged files
# Pre-push: Full type check, format check, full lint
```
## Additional Resources
- [Gitea Actions Documentation](https://docs.gitea.com/usage/actions/overview)
- [Gitea act_runner Documentation](https://docs.gitea.com/usage/actions/act-runner)
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
- [SvelteKit Deployment Guide](https://kit.svelte.dev/docs/adapters)
- [Playwright CI/CD Guide](https://playwright.dev/docs/ci)
## Status Badges
Add status badges to your README.md:
```markdown
![Lint](https://your-gitea-instance.com/username/glyphdiff/actions/badges/workflow/lint.yml/badge.svg)
![Test](https://your-gitea-instance.com/username/glyphdiff/actions/badges/workflow/test.yml/badge.svg)
![Build](https://your-gitea-instance.com/username/glyphdiff/actions/badges/workflow/build.yml/badge.svg)
```
## Next Steps
1. **Customize deployment**: Modify `deploy.yml` with your deployment strategy
2. **Add notifications**: Set up workflow failure notifications
3. **Optimize caching**: Add Playwright cache if needed
4. **Add badges**: Include status badges in README
5. **Schedule tasks**: Add periodic tests or dependency updates (optional)
---
**Last Updated**: December 30, 2025
**Version**: 1.0.0

View File

@@ -1,53 +0,0 @@
name: Build
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '25'
# We handle caching manually below to ensure
# corepack-managed yarn is used correctly.
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Persistent Yarn Cache
uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ github.workspace }}/.yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
# --immutable ensures the lockfile isn't changed (replaces --frozen-lockfile)
run: yarn install --immutable
- name: Build Svelte App
run: yarn build
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: dist/
retention-days: 7

View File

@@ -1,58 +0,0 @@
name: Deploy Pipeline
on:
push:
branches: [main]
workflow_dispatch:
inputs:
environment:
description: 'Target'
required: true
default: 'production'
type: choice
options: [staging, production]
jobs:
pipeline:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '25'
# We handle caching manually below to ensure
# corepack-managed yarn is used correctly.
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Persistent Yarn Cache
uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ github.workspace }}/.yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
# --immutable ensures the lockfile isn't changed (replaces --frozen-lockfile)
run: yarn install --immutable
- name: Validation
run: |
yarn oxlint .
yarn svelte-check
- name: Build for Production
run: yarn build
env:
NODE_ENV: production

View File

@@ -1,58 +0,0 @@
name: Lint
on:
push:
branches:
- main
- develop
- feature/*
pull_request:
branches:
- main
- develop
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint Code
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '25'
# We handle caching manually below to ensure
# corepack-managed yarn is used correctly.
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Persistent Yarn Cache
uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ github.workspace }}/.yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
# --immutable ensures the lockfile isn't changed (replaces --frozen-lockfile)
run: yarn install --immutable
- name: Lint
run: yarn lint

View File

@@ -1,14 +1,13 @@
name: Test
name: Workflow
on:
push:
branches: [main, develop, "feature/*"]
branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_dispatch:
jobs:
test:
name: Svelte Checks
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -18,33 +17,44 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '25'
# We handle caching manually below to ensure
# corepack-managed yarn is used correctly.
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Persistent Yarn Cache
uses: actions/cache@v4
id: yarn-cache
with:
path: ${{ github.workspace }}/.yarn/cache
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
restore-keys: ${{ runner.os }}-yarn-
- name: Install dependencies
# --immutable ensures the lockfile isn't changed (replaces --frozen-lockfile)
run: yarn install --immutable
- name: Build Svelte App
run: yarn build
- name: Lint
run: yarn lint
- name: Type Check
run: yarn check:shadcn-excluded
- name: Lint
run: yarn lint
publish:
needs: build # Only runs if tests/lint pass
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # Only deploy from main branch
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
run: echo "${{ secrets.CI_DEPLOY_TOKEN }}" | docker login git.allmy.work -u ${{ gitea.repository_owner }} --password-stdin
- name: Build and Push Docker Image
run: |
docker build -t git.allmy.work/${{ gitea.repository }}:latest .
docker push git.allmy.work/${{ gitea.repository }}:latest

6
.gitignore vendored
View File

@@ -10,6 +10,9 @@ node_modules
/build
/dist
# Git worktrees (isolated development branches)
.worktrees
# OS
.DS_Store
Thumbs.db
@@ -35,9 +38,12 @@ vite.config.ts.timestamp-*
/docs
AGENTS.md
*.md
!README.md
*storybook.log
storybook-static
# Tests
coverage/
.aider*

View File

@@ -0,0 +1,29 @@
<!--
Component: Decorator
Global Storybook decorator that wraps all stories with necessary providers.
This provides:
- ResponsiveManager context for breakpoint tracking
- TooltipProvider for shadcn 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 {
children: import('svelte').Snippet;
}
let { children }: Props = $props();
// Create and provide responsive context
const responsiveManager = createResponsiveManager();
$effect(() => responsiveManager.init());
setContext<ResponsiveManager>('responsive', responsiveManager);
</script>
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
{@render children()}
</TooltipProvider>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
interface Props {
children: import('svelte').Snippet;
width?: string; // Optional width override
}
let { children, width = '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="relative flex justify-center items-center text-foreground">
{@render children()}
</div>
</div>
</div>

View File

@@ -0,0 +1,79 @@
<!--
Component: ThemeDecorator
Storybook decorator that initializes ThemeManager for theme-related stories.
Ensures theme management works correctly in Storybook's iframe environment.
Includes a floating theme toggle for universal theme switching across all stories.
-->
<script lang="ts">
import { themeManager } from '$features/ChangeAppTheme';
import type { ResponsiveManager } from '$shared/lib';
import { IconButton } from '$shared/ui';
import MoonIcon from '@lucide/svelte/icons/moon';
import SunIcon from '@lucide/svelte/icons/sun';
import { getContext } from 'svelte';
import {
onDestroy,
onMount,
} from 'svelte';
interface Props {
children: import('svelte').Snippet;
}
let { children }: Props = $props();
// Get responsive context (set by Decorator)
const responsive = getContext<ResponsiveManager>('responsive');
// Initialize themeManager on mount
onMount(() => {
themeManager.init();
// Add keyboard shortcut for theme toggle (Cmd/Ctrl+Shift+D)
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'D') {
e.preventDefault();
themeManager.toggle();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
});
// Clean up themeManager when story unmounts
onDestroy(() => {
themeManager.destroy();
});
const theme = $derived(themeManager.value);
const themeLabel = $derived(theme === 'light' ? 'Light' : 'Dark');
</script>
<!-- Floating Theme Toggle -->
<div
class="fixed top-4 right-4 z-50 flex items-center gap-2 px-3 py-2 bg-card border border-border shadow-lg rounded-lg"
title="Toggle theme (Cmd/Ctrl+Shift+D)"
>
<span class="text-xs font-medium text-muted-foreground">Theme: {themeLabel}</span>
<IconButton
onclick={() => themeManager.toggle()}
size={responsive?.isMobile ? 'sm' : 'md'}
variant="ghost"
title="Toggle theme"
>
{#snippet icon()}
{#if theme === 'light'}
<MoonIcon class="size-4" />
{:else}
<SunIcon class="size-4" />
{/if}
{/snippet}
</IconButton>
</div>
<!-- Story Content -->
{@render children()}

View File

@@ -1,4 +1,16 @@
import type { StorybookConfig } from '@storybook/svelte-vite';
import {
dirname,
resolve,
} from 'path';
import { fileURLToPath } from 'url';
import {
loadConfigFromFile,
mergeConfig,
} from 'vite';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const config: StorybookConfig = {
'stories': [
@@ -9,7 +21,8 @@ const config: StorybookConfig = {
{
name: '@storybook/addon-svelte-csf',
options: {
legacyTemplate: true, // Enables the legacy template syntax
// Use modern template syntax for better performance
legacyTemplate: false,
},
},
'@chromatic-com/storybook',
@@ -18,5 +31,17 @@ const config: StorybookConfig = {
'@storybook/addon-docs',
],
'framework': '@storybook/svelte-vite',
async viteFinal(config) {
// This attempts to find your actual vite.config.ts
const { config: userConfig } = await loadConfigFromFile(
{ command: 'serve', mode: 'development' },
resolve(__dirname, '../vite.config.ts'),
) || {};
return mergeConfig(config, {
// Merge only the resolve/alias parts if you want to be safe
resolve: userConfig?.resolve || {},
});
},
};
export default config;

View File

@@ -0,0 +1,13 @@
<link rel="preconnect" href="https://api.fontshare.com" />
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
/>
<style>
body {
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
}
</style>

View File

@@ -1,9 +1,74 @@
import type { Preview } from '@storybook/svelte-vite';
import Decorator from './Decorator.svelte';
import StoryStage from './StoryStage.svelte';
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: {
matchers: {
color: /(background|color)$/i,
@@ -17,7 +82,126 @@ const preview: Preview = {
// 'off' - skip a11y checks entirely
test: 'todo',
},
docs: {
story: {
// This sets the default height for the iframe in Autodocs
iframeHeight: '600px',
},
},
viewport: {
viewports: {
// Mobile devices
mobile1: {
name: 'iPhone 5/SE',
styles: {
width: '320px',
height: '568px',
},
},
mobile2: {
name: 'iPhone 14 Pro Max',
styles: {
width: '414px',
height: '896px',
},
},
// Tablet
tablet: {
name: 'iPad (Portrait)',
styles: {
width: '834px',
height: '1112px',
},
},
desktop: {
name: 'Desktop (Small)',
styles: {
width: '1024px',
height: '1280px',
},
},
// Widget-specific viewports
widgetMedium: {
name: 'Widget Medium',
styles: {
width: '768px',
height: '800px',
},
},
widgetWide: {
name: 'Widget Wide',
styles: {
width: '1024px',
height: '800px',
},
},
widgetExtraWide: {
name: 'Widget Extra Wide',
styles: {
width: '1280px',
height: '800px',
},
},
// Full-width viewports
fullWidth: {
name: 'Full Width',
styles: {
width: '100%',
height: '800px',
},
},
fullScreen: {
name: 'Full Screen',
styles: {
width: '100%',
height: '100%',
},
},
},
},
head: `
<link rel="preconnect" href="https://api.fontshare.com" />
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
/>
<style>
body {
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
}
</style>
`,
},
decorators: [
// Outermost: initialize ThemeManager for all stories
story => ({
Component: ThemeDecorator,
props: {
children: story(),
},
}),
// Wrap with providers (TooltipProvider, ResponsiveManager)
story => ({
Component: Decorator,
props: {
children: story(),
},
}),
// Wrap with StoryStage for presentation styling
story => ({
Component: StoryStage,
props: {
children: story(),
},
}),
],
};
export default preview;

5
Caddyfile Normal file
View File

@@ -0,0 +1,5 @@
:3000 {
root * /usr/share/caddy
file_server
try_files {path} /index.html
}

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Enable Corepack so we can use Yarn v4
RUN corepack enable && corepack prepare yarn@stable --activate
# Force Yarn to use node_modules instead of PnP
ENV YARN_NODE_LINKER=node-modules
COPY package.json yarn.lock ./
RUN yarn install --immutable
COPY . .
RUN yarn build
# Production stage - Caddy
FROM caddy:2-alpine
WORKDIR /usr/share/caddy
# Copy built static files from the builder stage
COPY --from=builder /app/dist .
# Copy our local Caddyfile config
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 3000
# Start caddy using the config file
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]

View File

@@ -1,38 +1,78 @@
# sv
# GlyphDiff
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
A modern font exploration and comparison tool for browsing fonts from Google Fonts and Fontshare with real-time visual comparisons, advanced filtering, and customizable typography.
## Creating a project
## Features
If you're seeing this, you've probably already done this step. Congrats!
- **Multi-Provider Catalog**: Browse fonts from Google Fonts and Fontshare in one place
- **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
- **Type-Safe**: Full TypeScript coverage with strict mode enabled
```sh
# create a new project in the current directory
npx sv create
## Tech Stack
# create a new project in my-app
npx sv create my-app
- **Framework**: Svelte 5 with reactive primitives (runes)
- **Styling**: Tailwind CSS v4
- **Components**: shadcn-svelte (via bits-ui)
- **State Management**: TanStack Query for async data
- **Architecture**: Feature-Sliced Design (FSD)
- **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks)
## Project Structure
```
src/
├── app/ # App shell, layout, providers
├── widgets/ # Composed UI blocks (ComparisonSlider, SampleList, FontSearch)
├── features/ # Business features (filters, search, display)
├── entities/ # Domain models and stores (Font, Breadcrumb)
├── shared/ # Reusable utilities, UI components, helpers
└── routes/ # Page-level components
```
## Developing
## Quick Start
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
# Install dependencies
yarn install
```sh
npm run dev
# Start development server
yarn dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
# Build for production
yarn build
# Preview production build
yarn preview
```
## Building
## Available Scripts
To create a production version of your app:
| Command | Description |
| ------------------- | -------------------------- |
| `yarn dev` | Start development server |
| `yarn build` | Build for production |
| `yarn preview` | Preview production build |
| `yarn check` | Run Svelte type checking |
| `yarn lint` | Run oxlint |
| `yarn format` | Format code with dprint |
| `yarn test:unit` | Run unit tests |
| `yarn test:unit:ui` | Run Vitest UI |
| `yarn storybook` | Start Storybook dev server |
```sh
npm run build
```
## Code Style
You can preview the production build with `npm run preview`.
- **Path Aliases**: Use `$app/`, `$shared/`, `$features/`, `$entities/`, `$widgets/`, `$routes/`
- **Components**: PascalCase (e.g., `ComparisonSlider.svelte`)
- **Formatting**: 100 char line width, 4-space indent, single quotes
- **Type Safety**: Strict TypeScript with JSDoc comments for public APIs
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
## Architecture Notes
This project follows the Feature-Sliced Design (FSD) methodology for clean separation of concerns. The application uses Svelte 5's new runes system (`$state`, `$derived`, `$effect`) for reactive state management.
## License
MIT

View File

@@ -16,7 +16,7 @@
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
],
"typescript": {
"lineWidth": 100,
"lineWidth": 120,
"indentWidth": 4,
"useTabs": false,
"semiColons": "prefer",
@@ -41,7 +41,7 @@
"lineWidth": 100
},
"markup": {
"printWidth": 100,
"printWidth": 120,
"indentWidth": 4,
"useTabs": false,
"quotes": "double",

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

@@ -2,13 +2,14 @@
"name": "glyphdiff",
"private": true,
"version": "0.0.1",
"packageManager": "yarn@4.11.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check": "svelte-check",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"check:shadcn-excluded": "svelte-check --no-tsconfig --ignore \"src/shared/shadcn\"",
"lint": "oxlint",
@@ -22,7 +23,7 @@
"test:component": "vitest run --config vitest.config.component.ts",
"test:component:browser": "vitest run --config vitest.config.browser.ts",
"test:component:browser:watch": "vitest --config vitest.config.browser.ts",
"test": "npm run test:e2e && npm run test:unit",
"test": "yarn run test:unit",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
@@ -65,7 +66,7 @@
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-virtual": "^3.13.17"
"@chenglou/pretext": "^0.0.5",
"@tanstack/svelte-query": "^6.0.14"
}
}

View File

@@ -1,3 +1,7 @@
<!--
Component: App
Application root with query provider and layout
-->
<script lang="ts">
/**
* App Component

View File

@@ -1,15 +1,23 @@
<!--
Component: QueryProvider
Provides a QueryClientProvider for child components.
All components that use useQueryClient() or createQuery() must be
descendants of this provider.
-->
<script lang="ts">
/**
* Query Provider Component
*
* All components that use useQueryClient() or createQuery() must be
* descendants of this provider.
*/
import { queryClient } from '$shared/api/queryClient';
import { QueryClientProvider } from '@tanstack/svelte-query';
import type { Snippet } from 'svelte';
/** Slot content for child components */
let { children } = $props();
interface Props {
/**
* Content snippet
*/
children?: Snippet;
}
let { children }: Props = $props();
</script>
<QueryClientProvider client={queryClient}>

View File

@@ -1,83 +1,157 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@variant dark (&:where(.dark, .dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
/* Base font size */
--font-size: 16px;
/* GLYPHDIFF Swiss Design System */
/* Primary Colors */
--swiss-beige: #f3f0e9;
--swiss-red: #ff3b30;
--swiss-black: #1a1a1a;
--swiss-white: #ffffff;
/* Neutral Grays */
--neutral-50: #fafafa;
--neutral-100: #f5f5f5;
--neutral-200: #e5e5e5;
--neutral-300: #d4d4d4;
--neutral-400: #a3a3a3;
--neutral-500: #737373;
--neutral-600: #525252;
--neutral-700: #404040;
--neutral-800: #262626;
--neutral-900: #171717;
/* Dark Mode Backgrounds */
--dark-bg: #121212;
--dark-card: #1e1e1e;
--dark-border: rgba(255, 255, 255, 0.1);
/* Light Mode Backgrounds */
--light-bg: #f3f0e9;
--light-card: #ffffff;
--light-border: rgba(0, 0, 0, 0.05);
/* Semantic Colors */
--color-brand: var(--swiss-red);
--color-surface: var(--swiss-beige);
--color-paper: var(--swiss-white);
/* Base Tailwind Colors (for compatibility) */
--background: #ffffff;
--foreground: oklch(0.145 0 0);
--card: #ffffff;
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--popover-foreground: oklch(0.145 0 0);
--primary: #030213;
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.95 0.0058 264.53);
--secondary-foreground: #030213;
--muted: #ececf0;
--muted-foreground: #717182;
--accent: #e9ebef;
--accent-foreground: #030213;
--destructive: #d4183d;
--destructive-foreground: #ffffff;
--border: rgba(0, 0, 0, 0.1);
--input: transparent;
--input-background: #f3f3f5;
--switch-background: #cbced4;
--font-weight-medium: 500;
--font-weight-normal: 400;
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: #030213;
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* Spacing Scale (rem-based) */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 0.75rem;
--space-lg: 1rem;
--space-xl: 1.5rem;
--space-2xl: 2rem;
--space-3xl: 3rem;
--space-4xl: 4rem;
/* Typography Scale */
--text-2xs: 0.625rem;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
--text-6xl: 3.75rem;
--text-7xl: 4.5rem;
--text-8xl: 6rem;
/* Comparison Font Sizes */
--comparison-font-mobile: 3rem;
--comparison-font-tablet: 4.5rem;
--comparison-font-desktop: 6rem;
}
.dark {
--background: oklch(0.141 0.005 285.823);
--color-surface: var(--dark-bg);
--color-paper: var(--dark-card);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--font-weight-medium: 500;
--font-weight-normal: 400;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -93,14 +167,21 @@
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-input-background: var(--input-background);
--color-switch-background: var(--switch-background);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: 0rem;
--radius-md: 0rem;
--radius-lg: 0rem;
--radius-xl: 0rem;
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
@@ -109,14 +190,78 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-swiss-beige: var(--swiss-beige);
--color-swiss-red: var(--swiss-red);
--color-swiss-black: var(--swiss-black);
--color-swiss-white: var(--swiss-white);
--color-brand: var(--color-brand);
--color-surface: var(--color-surface);
--color-paper: var(--color-paper);
--color-dark-bg: var(--dark-bg);
--color-dark-card: var(--dark-card);
--font-logo: 'Syne', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
--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;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
font-optical-sizing: auto;
}
html {
font-size: var(--font-size);
}
h1 {
font-size: var(--text-2xl);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h2 {
font-size: var(--text-xl);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h3 {
font-size: var(--text-lg);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h4 {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
label {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
button {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
input {
font-size: var(--text-base);
font-weight: var(--font-weight-normal);
line-height: 1.5;
}
}
@@ -138,3 +283,95 @@
.peer:focus-visible ~ * {
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes nudge {
0%, 100% {
transform: translateY(0) scale(1) rotate(0deg);
}
2% {
transform: translateY(-2px) scale(1.1) rotate(-1deg);
}
4% {
transform: translateY(0) scale(1) rotate(1deg);
}
6% {
transform: translateY(-2px) scale(1.1) rotate(0deg);
}
8% {
transform: translateY(0) scale(1) rotate(0deg);
}
}
.animate-nudge {
animation: nudge 10s ease-in-out infinite;
}
/* ============================================
SCROLLBAR STYLES
============================================ */
/* ---- Modern API: color + width (Chrome 121+, FF 64+) ---- */
@supports (scrollbar-width: auto) {
* {
scrollbar-width: thin;
scrollbar-color: hsl(0 0% 70% / 0.4) var(--color-surface);
}
.dark * {
scrollbar-color: hsl(0 0% 40% / 0.5) var(--color-surface);
}
}
/* ---- Webkit layer: runs ON TOP in Chrome, standalone in old Safari ---- */
/* Handles things scrollbar-width can't: hiding buttons, exact sizing */
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-button {
display: none; /* kills arrows */
}
::-webkit-scrollbar-track {
background: var(--color-surface);
}
::-webkit-scrollbar-thumb {
background: hsl(0 0% 70% / 0.4);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(0 0% 50% / 0.6);
}
::-webkit-scrollbar-thumb:active {
background: hsl(0 0% 40% / 0.8);
}
::-webkit-scrollbar-corner {
background: var(--color-surface);
}
.dark ::-webkit-scrollbar-thumb { background: hsl(0 0% 40% / 0.5); }
.dark ::-webkit-scrollbar-thumb:hover { background: hsl(0 0% 55% / 0.6); }
.dark ::-webkit-scrollbar-thumb:active { background: hsl(0 0% 65% / 0.7); }
}
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
body {
overscroll-behavior-y: none;
}
.scroll-stable {
scrollbar-gutter: stable;
}

View File

@@ -35,3 +35,16 @@ declare module '*.jpg' {
const content: string;
export default content;
}
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly DEV: boolean;
readonly PROD: boolean;
readonly MODE: string;
// Add other env variables you use
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -1,47 +1,103 @@
<!--
Component: Layout
Application shell with providers and page wrapper
-->
<script lang="ts">
/**
* Layout Component
*
* Root layout wrapper that provides the application shell structure. Handles favicon,
* sidebar provider initialization, and renders child routes with consistent structure.
* toolbar provider initialization, and renders child routes with consistent structure.
*
* Layout structure:
* - Header area (currently empty, reserved for future use)
* - Collapsible sidebar with main content area
* - Footer area (currently empty, reserved for future use)
*
* Uses Sidebar.Provider to enable mobile-responsive collapsible sidebar behavior
* throughout the application.
* - Footer area (currently empty, reserved for future use)
*/
import favicon from '$shared/assets/favicon.svg';
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
import { FiltersSidebar } from '$widgets/FiltersSidebar';
import TypographyMenu from '$widgets/TypographySettings/ui/TypographyMenu.svelte';
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 {
type Snippet,
onDestroy,
onMount,
} from 'svelte';
/** Slot content for route pages to render */
let { children } = $props();
interface Props {
/**
* Content snippet
*/
children: Snippet;
}
let { children }: Props = $props();
let fontsReady = $state(true);
const theme = $derived(themeManager.value);
onMount(() => themeManager.init());
onDestroy(() => themeManager.destroy());
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="icon" href={GD} />
<link rel="preconnect" href="https://api.fontshare.com" />
<link
rel="preconnect"
href="https://cdn.fontshare.com"
crossorigin="anonymous"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossorigin="anonymous"
/>
<link
rel="preload"
as="style"
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
media="print"
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
/>
<noscript>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
/>
</noscript>
<title>GlyphDiff | Typography & Typefaces</title>
</svelte:head>
<div id="app-root">
<header></header>
<ResponsiveProvider>
<div
id="app-root"
class={cn(
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
theme === 'dark' ? 'dark' : '',
)}
>
<header>
<BreadcrumbHeader />
</header>
<Sidebar.Provider>
<FiltersSidebar />
<main class="w-dvw">
<TypographyMenu />
{@render children?.()}
</main>
</Sidebar.Provider>
<footer></footer>
</div>
<style>
#app-root {
width: 100%;
height: 100vh;
}
</style>
<!-- <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

@@ -0,0 +1,35 @@
/**
* Breadcrumb entity
*
* Tracks page sections using Intersection Observer with scroll direction
* detection. Sections appear in breadcrumbs when scrolling down and exiting
* the viewport top.
*
* @example
* ```svelte
* <script lang="ts">
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
* import { onMount } from 'svelte';
*
* onMount(() => {
* const section = document.getElementById('section');
* if (section) {
* scrollBreadcrumbsStore.add({
* index: 0,
* title: 'Section',
* element: section
* }, 80);
* }
* });
* </script>
* ```
*/
export {
type NavigationAction,
scrollBreadcrumbsStore,
} from './model';
export {
BreadcrumbHeader,
NavigationWrapper,
} from './ui';

View File

@@ -0,0 +1,2 @@
export * from './store/scrollBreadcrumbsStore.svelte';
export * from './types/types.ts';

View File

@@ -0,0 +1,240 @@
/**
* Scroll-based breadcrumb tracking store
*
* Tracks page sections using Intersection Observer with scroll direction
* detection. Sections appear in breadcrumbs when scrolling DOWN and exiting
* the viewport top. This creates a natural "breadcrumb trail" as users
* scroll through content.
*
* Features:
* - Scroll direction detection (up/down)
* - Intersection Observer for efficient tracking
* - Smooth scrolling to tracked sections
* - Configurable scroll offset for sticky headers
*
* @example
* ```svelte
* <script lang="ts">
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
*
* onMount(() => {
* scrollBreadcrumbsStore.add({
* index: 0,
* title: 'Introduction',
* element: document.getElementById('intro')!
* }, 80); // 80px offset for sticky header
* });
* </script>
*
* <div id="intro">Introduction</div>
* ```
*/
/**
* A breadcrumb item representing a tracked section
*/
export interface BreadcrumbItem {
/** Unique index for ordering */
index: number;
/** Display title for the breadcrumb */
title: string;
/** DOM element to track */
element: HTMLElement;
}
/**
* Scroll-based breadcrumb tracking store
*
* Uses Intersection Observer to detect when sections scroll out of view
* and tracks scroll direction to only show sections the user has scrolled
* past while moving down the page.
*/
class ScrollBreadcrumbsStore {
/** All tracked breadcrumb items */
#items = $state<BreadcrumbItem[]>([]);
/** Set of indices that have scrolled past (exited viewport while scrolling down) */
#scrolledPast = $state<Set<number>>(new Set());
/** Intersection Observer instance */
#observer: IntersectionObserver | null = null;
/** Offset for smooth scrolling (sticky header height) */
#scrollOffset = 0;
/** Current scroll direction */
#isScrollingDown = $state(false);
/** Previous scroll Y position to determine direction */
#prevScrollY = 0;
/** Throttled scroll handler */
#handleScroll: (() => void) | null = null;
/** Listener count for cleanup */
#listenerCount = 0;
/**
* Updates scroll direction based on current position
*/
#updateScrollDirection(): void {
const currentScrollY = window.scrollY;
this.#isScrollingDown = currentScrollY > this.#prevScrollY;
this.#prevScrollY = currentScrollY;
}
/**
* Initializes the Intersection Observer
*
* Tracks when elements enter/exit viewport with zero threshold
* (fires as soon as any part of element crosses viewport edge).
*/
#initObserver(): void {
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 (!entry.isIntersecting && this.#isScrollingDown) {
// Element exited viewport while scrolling DOWN - add to breadcrumbs
this.#scrolledPast = new Set(this.#scrolledPast).add(item.index);
} else if (entry.isIntersecting && !this.#isScrollingDown) {
// Element entered viewport while scrolling UP - remove from breadcrumbs
const newSet = new Set(this.#scrolledPast);
newSet.delete(item.index);
this.#scrolledPast = newSet;
}
}
},
{
threshold: 0,
},
);
}
/**
* Attaches scroll listener for direction detection
*/
#attachScrollListener(): void {
if (this.#listenerCount === 0) {
this.#handleScroll = () => this.#updateScrollDirection();
window.addEventListener('scroll', this.#handleScroll, { passive: true });
}
this.#listenerCount++;
}
/**
* Detaches scroll listener when no items remain
*/
#detachScrollListener(): void {
this.#listenerCount = Math.max(0, this.#listenerCount - 1);
if (this.#listenerCount === 0 && this.#handleScroll) {
window.removeEventListener('scroll', this.#handleScroll);
this.#handleScroll = null;
}
}
/**
* Disconnects observer and removes scroll listener
*/
#disconnect(): void {
if (this.#observer) {
this.#observer.disconnect();
this.#observer = null;
}
this.#detachScrollListener();
}
/** 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) */
get scrolledPastItems(): BreadcrumbItem[] {
return this.items.filter(item => this.#scrolledPast.has(item.index));
}
/** 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;
}
/**
* Check if a specific index has been scrolled past
* @param index - Item index to check
*/
isScrolledPast(index: number): boolean {
return this.#scrolledPast.has(index);
}
/**
* Add a breadcrumb item to track
* @param item - Breadcrumb item with index, title, and element
* @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;
this.#scrollOffset = offset;
this.#items.push(item);
this.#attachScrollListener();
this.#initObserver();
// Initialize scroll direction
this.#prevScrollY = window.scrollY;
this.#observer?.observe(item.element);
}
/**
* Remove a breadcrumb item from tracking
* @param index - Index of item to remove
*/
remove(index: number): void {
const item = this.#items.find(i => i.index === index);
if (!item) return;
this.#observer?.unobserve(item.element);
this.#items = this.#items.filter(i => i.index !== index);
const newSet = new Set(this.#scrolledPast);
newSet.delete(index);
this.#scrolledPast = newSet;
if (this.#items.length === 0) {
this.#disconnect();
}
}
/**
* Smooth scroll to a tracked breadcrumb item
* @param index - Index of item to scroll to
* @param container - Scroll container (window by default)
*/
scrollTo(index: number, container: HTMLElement | Window = window): void {
const item = this.#items.find(i => i.index === index);
if (!item) return;
const rect = item.element.getBoundingClientRect();
const scrollTop = container === window ? window.scrollY : (container as HTMLElement).scrollTop;
const target = rect.top + scrollTop - this.#scrollOffset;
if (container === window) {
window.scrollTo({ top: target, behavior: 'smooth' });
} else {
(container as HTMLElement).scrollTo({
top: target - (container as HTMLElement).getBoundingClientRect().top
+ (container as HTMLElement).scrollTop - window.scrollY,
behavior: 'smooth',
});
}
}
}
/**
* Creates a new scroll breadcrumbs store instance
*/
export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
return new ScrollBreadcrumbsStore();
}
/**
* Singleton scroll breadcrumbs store instance
*/
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();

View File

@@ -0,0 +1,559 @@
/** @vitest-environment jsdom */
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import {
type BreadcrumbItem,
createScrollBreadcrumbsStore,
} from './scrollBreadcrumbsStore.svelte';
// Mock IntersectionObserver - class variable to track instances
let mockObserverInstances: MockIntersectionObserver[] = [];
class MockIntersectionObserver implements IntersectionObserver {
root = null;
rootMargin = '';
thresholds: number[] = [];
readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = [];
readonly observedElements = new Set<Element>();
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.callbacks.push(callback);
if (options?.rootMargin) this.rootMargin = options.rootMargin;
if (options?.threshold) {
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold];
}
mockObserverInstances.push(this);
}
observe(target: Element): void {
this.observedElements.add(target);
}
unobserve(target: Element): void {
this.observedElements.delete(target);
}
disconnect(): void {
this.observedElements.clear();
}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
// Helper method for tests to trigger intersection changes
triggerIntersection(target: Element, isIntersecting: boolean): void {
const entry: Partial<IntersectionObserverEntry> = {
target,
isIntersecting,
intersectionRatio: isIntersecting ? 1 : 0,
boundingClientRect: {} as DOMRectReadOnly,
intersectionRect: {} as DOMRectReadOnly,
rootBounds: null,
time: Date.now(),
};
this.callbacks.forEach(cb => cb([entry as IntersectionObserverEntry], this));
}
}
describe('ScrollBreadcrumbsStore', () => {
let scrollListeners: Array<() => void> = [];
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
let scrollToSpy: ReturnType<typeof vi.spyOn>;
// Helper to create mock elements
const createMockElement = (): HTMLElement => {
const el = document.createElement('div');
Object.defineProperty(el, 'getBoundingClientRect', {
value: vi.fn(() => ({
top: 100,
left: 0,
bottom: 200,
right: 100,
width: 100,
height: 100,
x: 0,
y: 100,
toJSON: () => ({}),
})),
});
return el;
};
// Helper to create breadcrumb item
const createItem = (index: number, title: string, element?: HTMLElement): BreadcrumbItem => ({
index,
title,
element: element ?? createMockElement(),
});
beforeEach(() => {
mockObserverInstances = [];
scrollListeners = [];
// Set up IntersectionObserver mock before creating store
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
// Mock window.scrollTo
scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
// Track scroll event listeners
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
(event: string, listener: EventListenerOrEventListenerObject, options?: any) => {
if (event === 'scroll') {
scrollListeners.push(listener as () => void);
}
return undefined;
},
);
removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation(
(event: string, listener: EventListenerOrEventListenerObject) => {
if (event === 'scroll') {
const index = scrollListeners.indexOf(listener as () => void);
if (index > -1) scrollListeners.splice(index, 1);
}
return undefined;
},
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Adding items', () => {
it('should add an item and track it', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
const item = createItem(0, 'Section 1', element);
store.add(item);
expect(store.items).toHaveLength(1);
expect(store.items[0]).toEqual(item);
});
it('should ignore duplicate indices', () => {
const store = createScrollBreadcrumbsStore();
const element1 = createMockElement();
const element2 = createMockElement();
store.add(createItem(0, 'First', element1));
store.add(createItem(0, 'Second', element2));
expect(store.items).toHaveLength(1);
expect(store.items[0].title).toBe('First');
});
it('should add multiple items with different indices', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
store.add(createItem(2, 'Third'));
expect(store.items).toHaveLength(3);
expect(store.items.map(i => i.index)).toEqual([0, 1, 2]);
});
it('should attach scroll listener when first item is added', () => {
const store = createScrollBreadcrumbsStore();
expect(scrollListeners).toHaveLength(0);
store.add(createItem(0, 'First'));
expect(scrollListeners).toHaveLength(1);
});
it('should initialize observer with element', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'Test', element));
expect(mockObserverInstances).toHaveLength(1);
expect(mockObserverInstances[0].observedElements.has(element)).toBe(true);
});
});
describe('Removing items', () => {
it('should remove an item by index', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
store.add(createItem(2, 'Third'));
store.remove(1);
expect(store.items).toHaveLength(2);
expect(store.items.map(i => i.index)).toEqual([0, 2]);
});
it('should do nothing when removing non-existent index', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
store.remove(999);
expect(store.items).toHaveLength(2);
});
it('should unobserve element when removed', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
expect(mockObserverInstances[0].observedElements.has(element)).toBe(true);
store.remove(0);
expect(mockObserverInstances[0].observedElements.has(element)).toBe(false);
});
it('should disconnect observer when no items remain', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
expect(addEventListenerSpy).toHaveBeenCalled();
const initialCallCount = addEventListenerSpy.mock.calls.length;
store.remove(0);
// addEventListener was called for the first item, still 1 call
expect(addEventListenerSpy.mock.calls.length).toBe(initialCallCount);
store.remove(1);
// The listener count should be 0 now - disconnect was called
// We verify the observer was disconnected
expect(mockObserverInstances[0].observedElements.size).toBe(0);
});
it('should reattach listener when adding after cleanup', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.remove(0);
expect(scrollListeners).toHaveLength(0);
store.add(createItem(1, 'Second'));
expect(scrollListeners).toHaveLength(1);
});
});
describe('Intersection Observer behavior', () => {
it('should add to scrolledPast when element exits viewport while scrolling down', () => {
// Set initial scrollY before creating store/adding items
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
// Simulate scrolling down (scrollY increases)
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
// Trigger intersection: element exits viewport while scrolling down
mockObserverInstances[0].triggerIntersection(element, false);
expect(store.isScrolledPast(0)).toBe(true);
expect(store.scrolledPastItems).toHaveLength(1);
});
it('should not add to scrolledPast when not scrolling down', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
// scrollY stays at 0 (not scrolling down)
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
scrollListeners.forEach(l => l());
// Element exits viewport
mockObserverInstances[0].triggerIntersection(element, false);
expect(store.isScrolledPast(0)).toBe(false);
});
it('should remove from scrolledPast when element enters viewport while scrolling up', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
// First, scroll down and exit viewport
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element, false);
expect(store.isScrolledPast(0)).toBe(true);
// Now scroll up (decrease scrollY) and element enters viewport
Object.defineProperty(window, 'scrollY', { value: 50, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element, true);
expect(store.isScrolledPast(0)).toBe(false);
});
});
describe('scrollTo method', () => {
it('should scroll to item by index with window as container', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
store.scrollTo(0, window);
expect(scrollToSpy).toHaveBeenCalledWith(
expect.objectContaining({
behavior: 'smooth',
}),
);
});
it('should do nothing when index does not exist', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.scrollTo(999);
expect(scrollToSpy).not.toHaveBeenCalled();
});
it('should use scroll offset when calculating target position', () => {
const store = createScrollBreadcrumbsStore();
// Reset the mock to clear previous calls
scrollToSpy.mockClear();
// Create fresh mock element with specific getBoundingClientRect
const element = document.createElement('div');
const getBoundingClientRectMock = vi.fn(() => ({
top: 200,
left: 0,
bottom: 300,
right: 100,
width: 100,
height: 100,
x: 0,
y: 200,
toJSON: () => ({}),
}));
Object.defineProperty(element, 'getBoundingClientRect', {
value: getBoundingClientRectMock,
writable: true,
configurable: true,
});
// Add item with 80px offset
store.add(createItem(0, 'Third', element), 80);
store.scrollTo(0);
// The offset should be subtracted from the element position
// 200 - 80 = 120 (but in jsdom, getBoundingClientRect might have different behavior)
// Let's just verify smooth behavior is used
expect(scrollToSpy).toHaveBeenCalledWith(
expect.objectContaining({
behavior: 'smooth',
}),
);
// Verify that the scroll position is less than the element top (offset was applied)
const scrollToCall = scrollToSpy.mock.calls[0][0] as ScrollToOptions;
expect((scrollToCall as any).top).toBeLessThan(200);
});
it('should handle HTMLElement container', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'Test', element));
const container: HTMLElement = {
scrollTop: 50,
scrollTo: vi.fn(),
getBoundingClientRect: () => ({
top: 0,
bottom: 500,
left: 0,
right: 400,
width: 400,
height: 500,
x: 0,
y: 0,
toJSON: () => ({}),
}),
} as any;
store.scrollTo(0, container);
expect(container.scrollTo).toHaveBeenCalledWith(
expect.objectContaining({
behavior: 'smooth',
}),
);
});
});
describe('Getters', () => {
it('should return items sorted by index', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(1, 'Second'));
store.add(createItem(0, 'First'));
store.add(createItem(2, 'Third'));
expect(store.items.map(i => i.index)).toEqual([0, 1, 2]);
expect(store.items.map(i => i.title)).toEqual(['First', 'Second', 'Third']);
});
it('should return empty scrolledPastItems initially', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
expect(store.scrolledPastItems).toHaveLength(0);
});
it('should return items that have been scrolled past', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
store.add(createItem(1, 'Second'));
// Simulate scrolling down and element exiting viewport
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element, false);
expect(store.scrolledPastItems).toHaveLength(1);
expect(store.scrolledPastItems[0].index).toBe(0);
});
});
describe('activeIndex getter', () => {
it('should return null when no items are scrolled past', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
expect(store.activeIndex).toBeNull();
});
it('should return the last scrolled item index', () => {
// Set initial scroll position
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
const store = createScrollBreadcrumbsStore();
const element0 = createMockElement();
const element1 = createMockElement();
store.add(createItem(0, 'First', element0));
store.add(createItem(1, 'Second', element1));
// Scroll down, first item exits
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element0, false);
expect(store.activeIndex).toBe(0);
// Second item exits
mockObserverInstances[0].triggerIntersection(element1, false);
expect(store.activeIndex).toBe(1);
});
it('should update active index when scrolling back up', () => {
const store = createScrollBreadcrumbsStore();
const element0 = createMockElement();
const element1 = createMockElement();
store.add(createItem(0, 'First', element0));
store.add(createItem(1, 'Second', element1));
// Scroll past both items
Object.defineProperty(window, 'scrollY', { value: 200, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element0, false);
mockObserverInstances[0].triggerIntersection(element1, false);
expect(store.activeIndex).toBe(1);
// Scroll back up, item 1 enters viewport
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element1, true);
expect(store.activeIndex).toBe(0);
});
});
describe('isScrolledPast', () => {
it('should return false for items not scrolled past', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
store.add(createItem(1, 'Second'));
expect(store.isScrolledPast(0)).toBe(false);
expect(store.isScrolledPast(1)).toBe(false);
});
it('should return true for scrolled items', () => {
// Set initial scroll position
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
store.add(createItem(1, 'Second'));
// Scroll down, first item exits viewport
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element, false);
expect(store.isScrolledPast(0)).toBe(true);
expect(store.isScrolledPast(1)).toBe(false);
});
it('should return false for non-existent indices', () => {
const store = createScrollBreadcrumbsStore();
store.add(createItem(0, 'First'));
expect(store.isScrolledPast(999)).toBe(false);
});
});
describe('Scroll direction tracking', () => {
it('should track scroll direction changes', () => {
const store = createScrollBreadcrumbsStore();
const element = createMockElement();
store.add(createItem(0, 'First', element));
// Initial scroll position
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
scrollListeners.forEach(l => l());
// Scroll down
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element, false);
// Should be in scrolledPast since we scrolled down
expect(store.isScrolledPast(0)).toBe(true);
// Scroll back up
Object.defineProperty(window, 'scrollY', { value: 50, writable: true, configurable: true });
scrollListeners.forEach(l => l());
mockObserverInstances[0].triggerIntersection(element, true);
// Should be removed since we scrolled up
expect(store.isScrolledPast(0)).toBe(false);
});
});
});

View File

@@ -0,0 +1,7 @@
/**
* Navigation action type for breadcrumb components
*
* A Svelte action that can be attached to navigation elements
* for scroll tracking or other behaviors.
*/
export type NavigationAction = (node: HTMLElement) => void;

View File

@@ -0,0 +1,85 @@
<!--
Component: BreadcrumbHeader
Fixed header for breadcrumbs navigation for sections in the page
-->
<script lang="ts">
import type { ResponsiveManager } from '$shared/lib';
import {
Button,
Label,
Logo,
} from '$shared/ui';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import {
type BreadcrumbItem,
scrollBreadcrumbsStore,
} from '../../model';
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
const responsive = getContext<ResponsiveManager>('responsive');
function handleClick(item: BreadcrumbItem) {
scrollBreadcrumbsStore.scrollTo(item.index);
}
function createButtonText(item: BreadcrumbItem) {
const index = String(item.index + 1).padStart(2, '0');
if (responsive.isMobileOrTablet) {
return index;
}
return `${index} // ${item.title}`;
}
</script>
{#if breadcrumbs.length > 0}
<div
transition:slide={{ duration: 200 }}
class="
fixed top-0 left-0 right-0
h-14
md:h-16 px-4 md:px-6 lg:px-8
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
"
>
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
<Logo />
<nav class="flex items-center overflow-x-auto scrollbar-hide">
{#each breadcrumbs as item, _ (item.index)}
{@const active = scrollBreadcrumbsStore.activeIndex === item.index}
{@const text = createButtonText(item)}
<div class="ml-1 md:ml-4" transition:slide={{ duration: 200, axis: 'x', easing: cubicOut }}>
<Button
class="uppercase"
variant="tertiary"
size="xs"
{active}
onclick={() => handleClick(item)}
>
<Label class="text-inherit">
{text}
</Label>
</Button>
</div>
{/each}
</nav>
</div>
</div>
{/if}
<style>
/* Hide scrollbar but keep functionality */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -0,0 +1,44 @@
<!--
Component: NavigationWrapper
Wrapper for breadcrumb registration with scroll tracking
-->
<script lang="ts">
import { type Snippet } from 'svelte';
import {
type NavigationAction,
scrollBreadcrumbsStore,
} from '../../model';
interface Props {
/**
* Navigation index
*/
index: number;
/**
* Navigation title
*/
title: string;
/**
* Scroll offset
* @default 96
*/
offset?: number;
/**
* Content snippet
*/
content: Snippet<[action: NavigationAction]>;
}
const { index, title, offset = 96, content }: Props = $props();
function registerBreadcrumb(node: HTMLElement) {
scrollBreadcrumbsStore.add({ index, title, element: node }, offset);
return {
destroy() {
scrollBreadcrumbsStore.remove(index);
},
};
}
</script>
{@render content(registerBreadcrumb)}

View File

@@ -0,0 +1,2 @@
export { default as BreadcrumbHeader } from './BreadcrumbHeader/BreadcrumbHeader.svelte';
export { default as NavigationWrapper } from './NavigationWrapper/NavigationWrapper.svelte';

View File

@@ -1,161 +0,0 @@
/**
* Fontshare API client
*
* Handles API requests to Fontshare API for fetching font metadata.
* Provides error handling, pagination support, and type-safe responses.
*
* Pagination: The Fontshare API DOES support pagination via `page` and `limit` parameters.
* However, the current implementation uses `fetchAllFontshareFonts()` to fetch all fonts upfront.
* For future optimization, consider implementing incremental pagination for large datasets.
*
* @see https://fontshare.com
*/
import { api } from '$shared/api/api';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type {
FontshareApiModel,
FontshareFont,
} from '../../model/types/fontshare';
/**
* Fontshare API parameters
*/
export interface FontshareParams extends QueryParams {
/**
* Filter by categories (e.g., ["Sans", "Serif", "Display"])
*/
categories?: string[];
/**
* Filter by tags (e.g., ["Magazines", "Branding", "Logos"])
*/
tags?: string[];
/**
* Page number for pagination (1-indexed)
*/
page?: number;
/**
* Number of items per page
*/
limit?: number;
/**
* Search query to filter fonts
*/
q?: string;
}
/**
* Fontshare API response wrapper
* Re-exported from model/types/fontshare for backward compatibility
*/
export type FontshareResponse = FontshareApiModel;
/**
* Fetch fonts from Fontshare API
*
* @param params - Query parameters for filtering fonts
* @returns Promise resolving to Fontshare API response
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all Sans category fonts
* const response = await fetchFontshareFonts({
* categories: ['Sans'],
* limit: 50
* });
*
* // Fetch fonts with specific tags
* const response = await fetchFontshareFonts({
* tags: ['Branding', 'Logos']
* });
*
* // Search fonts
* const response = await fetchFontshareFonts({
* search: 'Satoshi'
* });
* ```
*/
export async function fetchFontshareFonts(
params: FontshareParams = {},
): Promise<FontshareResponse> {
const queryString = buildQueryString(params);
const url = `https://api.fontshare.com/v2/fonts${queryString}`;
try {
const response = await api.get<FontshareResponse>(url);
return response.data;
} catch (error) {
// Re-throw ApiError with context
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to fetch Fontshare fonts: ${String(error)}`);
}
}
/**
* Fetch font by slug
* Convenience function for fetching a single font
*
* @param slug - Font slug (e.g., "satoshi", "general-sans")
* @returns Promise resolving to Fontshare font item
*
* @example
* ```ts
* const satoshi = await fetchFontshareFontBySlug('satoshi');
* ```
*/
export async function fetchFontshareFontBySlug(
slug: string,
): Promise<FontshareFont | undefined> {
const response = await fetchFontshareFonts();
return response.fonts.find(font => font.slug === slug);
}
/**
* Fetch all fonts from Fontshare
* Convenience function for fetching all available fonts
* Uses pagination to get all items
*
* @returns Promise resolving to all Fontshare fonts
*
* @example
* ```ts
* const allFonts = await fetchAllFontshareFonts();
* console.log(`Found ${allFonts.fonts.length} fonts`);
* ```
*/
export async function fetchAllFontshareFonts(
params: FontshareParams = {},
): Promise<FontshareResponse> {
const allFonts: FontshareFont[] = [];
let page = 1;
const limit = 100; // Max items per page
while (true) {
const response = await fetchFontshareFonts({
...params,
page,
limit,
});
allFonts.push(...response.fonts);
// Check if we've fetched all items
if (response.fonts.length < limit) {
break;
}
page++;
}
// Return first response with all items combined
const firstResponse = await fetchFontshareFonts({ ...params, page: 1, limit });
return {
...firstResponse,
fonts: allFonts,
};
}

View File

@@ -1,127 +0,0 @@
/**
* Google Fonts API client
*
* Handles API requests to Google Fonts API for fetching font metadata.
* Provides error handling, retry logic, and type-safe responses.
*
* Pagination: The Google Fonts API does NOT support pagination parameters.
* All fonts matching the query are returned in a single response.
* Use category, subset, or sort filters to reduce the result set if needed.
*
* @see https://developers.google.com/fonts/docs/developer_api
*/
import { api } from '$shared/api/api';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type {
FontItem,
GoogleFontsApiModel,
} from '../../model/types/google';
/**
* Google Fonts API parameters
*/
export interface GoogleFontsParams extends QueryParams {
/**
* Google Fonts API key (required for Google Fonts API v1)
*/
key?: string;
/**
* Font family name (to fetch specific font)
*/
family?: string;
/**
* Font category filter (e.g., "sans-serif", "serif", "display")
*/
category?: string;
/**
* Character subset filter (e.g., "latin", "latin-ext", "cyrillic")
*/
subset?: string;
/**
* Sort order for results
*/
sort?: 'alpha' | 'date' | 'popularity' | 'style' | 'trending';
/**
* Cap the number of fonts returned
*/
capability?: 'VF' | 'WOFF2';
}
/**
* Google Fonts API response wrapper
* Re-exported from model/types/google for backward compatibility
*/
export type GoogleFontsResponse = GoogleFontsApiModel;
/**
* Simplified font item from Google Fonts API
* Re-exported from model/types/google for backward compatibility
*/
export type GoogleFontItem = FontItem;
/**
* Google Fonts API base URL
* Note: Google Fonts API v1 requires an API key. For development/testing without a key,
* fonts may not load properly.
*/
const GOOGLE_FONTS_API_URL = 'https://www.googleapis.com/webfonts/v1/webfonts' as const;
/**
* Fetch fonts from Google Fonts API
*
* @param params - Query parameters for filtering fonts
* @returns Promise resolving to Google Fonts API response
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all sans-serif fonts sorted by popularity
* const response = await fetchGoogleFonts({
* category: 'sans-serif',
* sort: 'popularity'
* });
*
* // Fetch specific font family
* const robotoResponse = await fetchGoogleFonts({
* family: 'Roboto'
* });
* ```
*/
export async function fetchGoogleFonts(
params: GoogleFontsParams = {},
): Promise<GoogleFontsResponse> {
const queryString = buildQueryString(params);
const url = `${GOOGLE_FONTS_API_URL}${queryString}`;
try {
const response = await api.get<GoogleFontsResponse>(url);
return response.data;
} catch (error) {
// Re-throw ApiError with context
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to fetch Google Fonts: ${String(error)}`);
}
}
/**
* Fetch font by family name
* Convenience function for fetching a single font
*
* @param family - Font family name (e.g., "Roboto")
* @returns Promise resolving to Google Font item
*
* @example
* ```ts
* const roboto = await fetchGoogleFontFamily('Roboto');
* ```
*/
export async function fetchGoogleFontFamily(
family: string,
): Promise<GoogleFontItem | undefined> {
const response = await fetchGoogleFonts({ family });
return response.items.find(item => item.family === family);
}

View File

@@ -4,22 +4,13 @@
* Exports API clients and normalization utilities
*/
// Proxy API (primary)
export {
fetchGoogleFontFamily,
fetchGoogleFonts,
} from './google/googleFonts';
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
} from './proxy/proxyFonts';
export type {
GoogleFontItem,
GoogleFontsParams,
GoogleFontsResponse,
} from './google/googleFonts';
export {
fetchAllFontshareFonts,
fetchFontshareFontBySlug,
fetchFontshareFonts,
} from './fontshare/fontshare';
export type {
FontshareParams,
FontshareResponse,
} from './fontshare/fontshare';
ProxyFontsParams,
ProxyFontsResponse,
} from './proxy/proxyFonts';

View File

@@ -0,0 +1,171 @@
/**
* Tests for proxy API client
*/
import {
beforeEach,
describe,
expect,
test,
vi,
} from 'vitest';
import type { UnifiedFont } from '../../model/types';
import type { ProxyFontsResponse } from './proxyFonts';
vi.mock('$shared/api/api', () => ({
api: {
get: vi.fn(),
},
}));
import { api } from '$shared/api/api';
import {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
} from './proxyFonts';
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
return {
id: 'roboto',
family: 'Roboto',
provider: 'google',
category: 'sans-serif',
variants: [],
subsets: [],
...overrides,
} as UnifiedFont;
}
function mockApiGet<T>(data: T) {
vi.mocked(api.get).mockResolvedValueOnce({ data, status: 200 });
}
describe('proxyFonts', () => {
beforeEach(() => {
vi.mocked(api.get).mockReset();
});
describe('fetchProxyFonts', () => {
test('should fetch fonts with no params', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont()],
total: 1,
limit: 50,
offset: 0,
};
mockApiGet(mockResponse);
const result = await fetchProxyFonts();
expect(api.get).toHaveBeenCalledWith(PROXY_API_URL);
expect(result).toEqual(mockResponse);
});
test('should build URL with query params', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont()],
total: 1,
limit: 20,
offset: 0,
};
mockApiGet(mockResponse);
await fetchProxyFonts({ provider: 'google', category: 'sans-serif', limit: 20, offset: 0 });
const calledUrl = vi.mocked(api.get).mock.calls[0][0];
expect(calledUrl).toContain('provider=google');
expect(calledUrl).toContain('category=sans-serif');
expect(calledUrl).toContain('limit=20');
expect(calledUrl).toContain('offset=0');
});
test('should throw on invalid response (missing fonts array)', async () => {
mockApiGet({ total: 0 });
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
});
test('should throw on null response data', async () => {
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
});
});
describe('fetchProxyFontById', () => {
test('should return font matching the ID', async () => {
const targetFont = createMockFont({ id: 'satoshi', name: 'Satoshi' });
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont(), targetFont],
total: 2,
limit: 1000,
offset: 0,
};
mockApiGet(mockResponse);
const result = await fetchProxyFontById('satoshi');
expect(result).toEqual(targetFont);
});
test('should return undefined when font not found', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont()],
total: 1,
limit: 1000,
offset: 0,
};
mockApiGet(mockResponse);
const result = await fetchProxyFontById('nonexistent');
expect(result).toBeUndefined();
});
test('should search with the ID as query param', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [],
total: 0,
limit: 1000,
offset: 0,
};
mockApiGet(mockResponse);
await fetchProxyFontById('Roboto');
const calledUrl = vi.mocked(api.get).mock.calls[0][0];
expect(calledUrl).toContain('limit=1000');
expect(calledUrl).toContain('q=Roboto');
});
});
describe('fetchFontsByIds', () => {
test('should return empty array for empty input', async () => {
const result = await fetchFontsByIds([]);
expect(result).toEqual([]);
expect(api.get).not.toHaveBeenCalled();
});
test('should call batch endpoint with comma-separated IDs', async () => {
const fonts = [createMockFont({ id: 'roboto' }), createMockFont({ id: 'satoshi' })];
mockApiGet(fonts);
const result = await fetchFontsByIds(['roboto', 'satoshi']);
expect(api.get).toHaveBeenCalledWith(`${PROXY_API_URL}/batch?ids=roboto,satoshi`);
expect(result).toEqual(fonts);
});
test('should return empty array when response data is nullish', async () => {
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
const result = await fetchFontsByIds(['roboto']);
expect(result).toEqual([]);
});
});
});

View File

@@ -0,0 +1,189 @@
/**
* Proxy API client
*
* Handles API requests to GlyphDiff proxy API for fetching font metadata.
* Provides error handling, pagination support, and type-safe responses.
*
* Proxy API normalizes font data from Google Fonts and Fontshare into a single
* unified format, eliminating the need for client-side normalization.
*
* @see https://api.glyphdiff.com/api/v1/fonts
*/
import { api } from '$shared/api/api';
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';
/**
* Proxy API base URL
*/
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
/**
* Proxy API parameters
*
* Maps directly to the proxy API query parameters
*
* UPDATED: Now supports array values for filters
*/
export interface ProxyFontsParams extends QueryParams {
/**
* Font provider filter
*
* NEW: Supports array of providers (e.g., ["google", "fontshare"])
* Backward compatible: Single value still works
*/
providers?: string[] | string;
/**
* Font category filter
*
* NEW: Supports array of categories (e.g., ["serif", "sans-serif"])
* Backward compatible: Single value still works
*/
categories?: string[] | string;
/**
* Character subset filter
*
* NEW: Supports array of subsets (e.g., ["latin", "cyrillic"])
* Backward compatible: Single value still works
*/
subsets?: string[] | string;
/**
* Search query (e.g., "roboto", "satoshi")
*/
q?: string;
/**
* Sort order for results
* "name" - Alphabetical by font name
* "popularity" - Most popular first
* "lastModified" - Recently updated first
*/
sort?: 'name' | 'popularity' | 'lastModified';
/**
* Number of items to return (pagination)
*/
limit?: number;
/**
* Number of items to skip (pagination)
* Use for pagination: offset = (page - 1) * limit
*/
offset?: number;
}
/**
* Proxy API response
*
* Includes pagination metadata alongside font data
*/
export interface ProxyFontsResponse {
/** Array of unified font objects */
fonts: UnifiedFont[];
/** Total number of fonts matching the query */
total: number;
/** Limit used for this request */
limit: number;
/** Offset used for this request */
offset: number;
}
/**
* Fetch fonts from proxy API
*
* @param params - Query parameters for filtering and pagination
* @returns Promise resolving to proxy API response
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all sans-serif fonts from Google
* const response = await fetchProxyFonts({
* provider: 'google',
* category: 'sans-serif',
* limit: 50,
* offset: 0
* });
*
* // Search fonts across all providers
* const searchResponse = await fetchProxyFonts({
* q: 'roboto',
* limit: 20
* });
*
* // Fetch fonts with pagination
* const page1 = await fetchProxyFonts({ limit: 50, offset: 0 });
* const page2 = await fetchProxyFonts({ limit: 50, offset: 50 });
* ```
*/
export async function fetchProxyFonts(
params: ProxyFontsParams = {},
): Promise<ProxyFontsResponse> {
const queryString = buildQueryString(params);
const url = `${PROXY_API_URL}${queryString}`;
const response = await api.get<ProxyFontsResponse>(url);
if (!response.data || !Array.isArray(response.data.fonts)) {
throw new Error('Proxy API returned invalid response');
}
return response.data;
}
/**
* Fetch font by ID
*
* Convenience function for fetching a single font by ID
* Note: This fetches a page and filters client-side, which is not ideal
* For production, consider adding a dedicated endpoint to the proxy API
*
* @param id - Font ID (family name for Google, slug for Fontshare)
* @returns Promise resolving to font or undefined
*
* @example
* ```ts
* const roboto = await fetchProxyFontById('Roboto');
* const satoshi = await fetchProxyFontById('satoshi');
* ```
*/
export async function fetchProxyFontById(
id: string,
): Promise<UnifiedFont | undefined> {
const response = await fetchProxyFonts({ limit: 1000, q: id });
if (!response || !response.fonts) {
console.error('[fetchProxyFontById] No fonts in response', { response });
return undefined;
}
return response.fonts.find(font => font.id === id);
}
/**
* Fetch multiple fonts by their IDs
*
* @param ids - Array of font IDs to fetch
* @returns Promise resolving to an array of fonts
*/
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
if (ids.length === 0) return [];
const queryString = ids.join(',');
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
const response = await api.get<UnifiedFont[]>(url);
return response.data ?? [];
}

View File

@@ -1,75 +1,3 @@
export {
fetchAllFontshareFonts,
fetchFontshareFontBySlug,
fetchFontshareFonts,
} from './api/fontshare/fontshare';
export type {
FontshareParams,
FontshareResponse,
} from './api/fontshare/fontshare';
export {
fetchGoogleFontFamily,
fetchGoogleFonts,
} from './api/google/googleFonts';
export type {
GoogleFontItem,
GoogleFontsParams,
GoogleFontsResponse,
} from './api/google/googleFonts';
export {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} 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,
FontshareStore,
FontshareStyle,
FontshareStyleProperties,
FontshareTag,
FontshareWeight,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
// Google Fonts API types
GoogleFontsApiModel,
// Normalization types
UnifiedFont,
UnifiedFontVariant,
} from './model';
export {
createFontshareStore,
fetchFontshareFontsQuery,
fontshareStore,
} from './model';
// Stores
export {
createGoogleFontsStore,
GoogleFontsStore,
} from './model/services/fetchGoogleFonts.svelte';
// UI elements
export { FontList } from './ui';
export * from './api';
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

@@ -0,0 +1,592 @@
import {
describe,
expect,
it,
} from 'vitest';
import type { UnifiedFont } from '../../model/types';
import { getFontUrl } from './getFontUrl';
/**
* Helper function to create a minimal UnifiedFont mock for testing
*/
function createMockFont(
overrides: Partial<UnifiedFont> = {},
): UnifiedFont {
const baseFont: UnifiedFont = {
id: 'test-font',
name: 'Test Font',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: [],
styles: {},
metadata: {
cachedAt: Date.now(),
},
features: {
isVariable: false,
tags: [],
},
};
return { ...baseFont, ...overrides };
}
describe('getFontUrl', () => {
describe('basic logic', () => {
it('returns URL for exact weight match in variants', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
'700': 'https://example.com/font-700.woff2',
},
},
});
const result = getFontUrl(font, 400);
expect(result).toBe('https://example.com/font-400.woff2');
});
it('returns URL for weight 700', () => {
const font = createMockFont({
styles: {
variants: {
'700': 'https://example.com/font-700.woff2',
},
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://example.com/font-700.woff2');
});
it('returns URL for weight 100 (lightest)', () => {
const font = createMockFont({
styles: {
variants: {
'100': 'https://example.com/font-100.woff2',
},
},
});
const result = getFontUrl(font, 100);
expect(result).toBe('https://example.com/font-100.woff2');
});
it('returns URL for weight 900 (boldest)', () => {
const font = createMockFont({
styles: {
variants: {
'900': 'https://example.com/font-900.woff2',
},
},
});
const result = getFontUrl(font, 900);
expect(result).toBe('https://example.com/font-900.woff2');
});
it('returns URL for variable font (backend maps weight to VF URL)', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-variable.woff2',
'700': 'https://example.com/font-variable.woff2',
},
},
});
const result400 = getFontUrl(font, 400);
const result700 = getFontUrl(font, 700);
expect(result400).toBe('https://example.com/font-variable.woff2');
expect(result700).toBe('https://example.com/font-variable.woff2');
});
});
describe('fallback logic', () => {
it('falls back to regular when exact weight not found', () => {
const font = createMockFont({
styles: {
regular: 'https://example.com/font-regular.woff2',
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://example.com/font-regular.woff2');
});
it('falls back to variant 400 when exact weight and regular not found', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://example.com/font-400.woff2');
});
it('falls back to variant regular when exact weight, regular, and 400 not found', () => {
const font = createMockFont({
styles: {
variants: {
'700': 'https://example.com/font-700.woff2',
'regular': 'https://example.com/font-regular.woff2',
},
},
});
const result = getFontUrl(font, 400);
expect(result).toBe('https://example.com/font-regular.woff2');
});
it('prefers regular over variants.400 for fallback', () => {
const font = createMockFont({
styles: {
regular: 'https://example.com/font-regular.woff2',
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://example.com/font-regular.woff2');
});
it('returns undefined when no fallback options available', () => {
const font = createMockFont({
styles: {
variants: {
'700': 'https://example.com/font-700.woff2',
},
},
});
const result = getFontUrl(font, 400);
expect(result).toBeUndefined();
});
it('returns undefined for font with empty styles', () => {
const font = createMockFont({
styles: {},
});
const result = getFontUrl(font, 400);
expect(result).toBeUndefined();
});
it('throws error for font with undefined styles (invalid font data)', () => {
const font = createMockFont({
styles: undefined as any,
});
expect(() => getFontUrl(font, 400)).toThrow();
});
});
describe('edge cases', () => {
it('handles font with only regular URL (legacy format)', () => {
const font = createMockFont({
styles: {
regular: 'https://example.com/font-regular.woff2',
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://example.com/font-regular.woff2');
});
it('handles font with only variants object', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
'700': 'https://example.com/font-700.woff2',
},
},
});
const result400 = getFontUrl(font, 400);
const result700 = getFontUrl(font, 700);
expect(result400).toBe('https://example.com/font-400.woff2');
expect(result700).toBe('https://example.com/font-700.woff2');
});
it('handles font with variants but no requested weight', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://example.com/font-400.woff2');
});
it('handles Google Fonts style with legacy URLs', () => {
const font = createMockFont({
styles: {
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
bold: 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
},
});
const result = getFontUrl(font, 700);
expect(result).toBe('https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2');
});
it('handles Fontshare fonts with multiple weights', () => {
const font = createMockFont({
styles: {
variants: {
'100': 'https://cdn.fontshare.com/wf/font-100.woff2',
'200': 'https://cdn.fontshare.com/wf/font-200.woff2',
'300': 'https://cdn.fontshare.com/wf/font-300.woff2',
'400': 'https://cdn.fontshare.com/wf/font-400.woff2',
'500': 'https://cdn.fontshare.com/wf/font-500.woff2',
'600': 'https://cdn.fontshare.com/wf/font-600.woff2',
'700': 'https://cdn.fontshare.com/wf/font-700.woff2',
'800': 'https://cdn.fontshare.com/wf/font-800.woff2',
'900': 'https://cdn.fontshare.com/wf/font-900.woff2',
},
},
});
// Test all valid weights
for (const weight of [100, 200, 300, 400, 500, 600, 700, 800, 900]) {
const result = getFontUrl(font, weight);
expect(result).toBe(`https://cdn.fontshare.com/wf/font-${weight}.woff2`);
}
});
it('handles font with partial weight coverage', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-regular.woff2',
'700': 'https://example.com/font-bold.woff2',
},
},
});
const result400 = getFontUrl(font, 400);
const result700 = getFontUrl(font, 700);
const result500 = getFontUrl(font, 500);
expect(result400).toBe('https://example.com/font-regular.woff2');
expect(result700).toBe('https://example.com/font-bold.woff2');
expect(result500).toBe('https://example.com/font-regular.woff2'); // Fallback
});
it('handles font with variants.regular as fallback', () => {
const font = createMockFont({
styles: {
variants: {
'700': 'https://example.com/font-bold.woff2',
'regular': 'https://example.com/font-regular.woff2',
},
},
});
const result = getFontUrl(font, 400);
expect(result).toBe('https://example.com/font-regular.woff2');
});
it('handles empty variants object', () => {
const font = createMockFont({
styles: {
variants: {},
},
});
const result = getFontUrl(font, 400);
expect(result).toBeUndefined();
});
it('returns undefined when variant URL is null and no fallback available', () => {
const font = createMockFont({
styles: {
variants: {
'400': null as any,
'700': 'https://example.com/font-bold.woff2',
},
},
});
const result = getFontUrl(font, 400);
// null is falsy, so it falls back to regular, 400, and then regular variant
// All are undefined, so returns undefined
expect(result).toBeUndefined();
});
});
describe('boundary tests', () => {
it('handles lowest valid weight (100)', () => {
const font = createMockFont({
styles: {
variants: {
'100': 'https://example.com/font-100.woff2',
},
},
});
const result = getFontUrl(font, 100);
expect(result).toBe('https://example.com/font-100.woff2');
});
it('handles highest valid weight (900)', () => {
const font = createMockFont({
styles: {
variants: {
'900': 'https://example.com/font-900.woff2',
},
},
});
const result = getFontUrl(font, 900);
expect(result).toBe('https://example.com/font-900.woff2');
});
it('handles middle weight (500)', () => {
const font = createMockFont({
styles: {
variants: {
'500': 'https://example.com/font-500.woff2',
},
},
});
const result = getFontUrl(font, 500);
expect(result).toBe('https://example.com/font-500.woff2');
});
});
describe('invalid weights', () => {
it('throws error for weight below 100', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, 99)).toThrow('Invalid weight: 99');
});
it('throws error for weight above 900', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, 901)).toThrow('Invalid weight: 901');
});
it('throws error for weight 0', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, 0)).toThrow('Invalid weight: 0');
});
it('throws error for negative weight', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, -100)).toThrow('Invalid weight: -100');
});
it('throws error for non-numeric weight', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
// @ts-ignore - Testing invalid input type
expect(() => getFontUrl(font, '400' as any)).toThrow('Invalid weight: 400');
});
it('throws error for decimal weight', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, 450.5)).toThrow('Invalid weight: 450.5');
});
it('throws error for weight with step of 50 (not supported)', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, 450)).toThrow('Invalid weight: 450');
});
it('throws error for weight with step of 10 (not supported)', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, 410)).toThrow('Invalid weight: 410');
});
it('throws error for NaN weight', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, NaN)).toThrow('Invalid weight: NaN');
});
it('throws error for Infinity weight', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
expect(() => getFontUrl(font, Infinity)).toThrow('Invalid weight: Infinity');
});
it('throws descriptive error message', () => {
const font = createMockFont({
styles: {
variants: {
'400': 'https://example.com/font-400.woff2',
},
},
});
try {
getFontUrl(font, 999);
expect.fail('Expected function to throw');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('Invalid weight: 999');
}
});
});
describe('provider-specific tests', () => {
it('handles Google Fonts with variable fonts', () => {
const font = createMockFont({
provider: 'google',
styles: {
variants: {
'400': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
},
},
});
const result400 = getFontUrl(font, 400);
const result700 = getFontUrl(font, 700);
// Variable fonts return the same URL for all weights
expect(result400).toBe(result700);
});
it('handles Fontshare fonts with static weights', () => {
const font = createMockFont({
provider: 'fontshare',
styles: {
variants: {
'400': 'https://cdn.fontshare.com/wf/satoshi-regular.woff2',
'700': 'https://cdn.fontshare.com/wf/satoshi-bold.woff2',
},
},
});
const result400 = getFontUrl(font, 400);
const result700 = getFontUrl(font, 700);
expect(result400).toBe('https://cdn.fontshare.com/wf/satoshi-regular.woff2');
expect(result700).toBe('https://cdn.fontshare.com/wf/satoshi-bold.woff2');
expect(result400).not.toBe(result700);
});
});
describe('all valid weights test', () => {
it('handles all valid weight values', () => {
const validWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
validWeights.forEach(weight => {
const font = createMockFont({
styles: {
variants: {
[weight.toString()]: `https://example.com/font-${weight}.woff2`,
},
},
});
const result = getFontUrl(font, weight);
expect(result).toBe(`https://example.com/font-${weight}.woff2`);
});
});
});
});

View File

@@ -0,0 +1,46 @@
import type {
FontWeight,
UnifiedFont,
} from '../../model';
/** Valid font weight values (100-900 in increments of 100) */
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
/**
* Gets the URL for a font file at a specific weight
*
* Constructs the appropriate URL for loading a font file based on
* the font object and requested weight. Handles variable fonts and
* provides fallbacks for static fonts.
*
* @param font - Unified font object containing style URLs
* @param weight - Font weight (100-900)
* @returns URL string for the font file, or undefined if not found
* @throws Error if weight is not a valid value (100-900)
*
* @example
* ```ts
* const url = getFontUrl(roboto, 700); // Returns URL for Roboto Bold
*
* // Variable fonts: backend maps weight to VF URL
* const vfUrl = getFontUrl(inter, 450); // Returns variable font URL
*
* // Fallback for missing weights
* const fallback = getFontUrl(font, 900); // Falls back to regular/400 if 900 missing
* ```
*/
export function getFontUrl(font: UnifiedFont, weight: number): string | undefined {
if (!SIZES.includes(weight)) {
throw new Error(`Invalid weight: ${weight}`);
}
const weightKey = weight.toString() as FontWeight;
// Try exact match (backend maps weight to VF URL for variable fonts)
if (font.styles.variants?.[weightKey]) {
return font.styles.variants[weightKey];
}
// Fallbacks for static fonts when exact weight is missing
return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular'];
}

View File

@@ -1,6 +1,53 @@
export { getFontUrl } from './getFontUrl/getFontUrl';
// Mock data helpers for Storybook and testing
export {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './normalize/normalize';
createCategoriesFilter,
createErrorState,
createGenericFilter,
createLoadingState,
createMockComparisonStore,
// Filter mocks
createMockFilter,
createMockFontApiResponse,
createMockFontStoreState,
// Store mocks
createMockQueryState,
createMockReactiveState,
createMockStore,
createProvidersFilter,
createSubsetsFilter,
createSuccessState,
generateMixedCategoryFonts,
generateMockFonts,
generatePaginatedFonts,
generateSequentialFilter,
GENERIC_FILTERS,
getAllMockFonts,
getFontsByCategory,
getFontsByProvider,
MOCK_FILTERS,
MOCK_FILTERS_ALL_SELECTED,
MOCK_FILTERS_EMPTY,
MOCK_FILTERS_SELECTED,
MOCK_FONT_STORE_STATES,
MOCK_STORES,
type MockFilterOptions,
type MockFilters,
type MockFontStoreState,
// Font mocks
// Types
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

@@ -0,0 +1,311 @@
/**
* 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,
FontSubset,
} from '$entities/Font/model/types';
import type { Property } from '$shared/lib';
import { createFilter } from '$shared/lib';
// TYPE DEFINITIONS
/**
* Options for creating a mock filter
*/
export interface MockFilterOptions {
/** Filter properties */
properties: Property<string>[];
}
/**
* Preset mock filters for font filtering
*/
export interface MockFilters {
/** Provider filter (Google, Fontshare) */
providers: ReturnType<typeof createFilter<'google' | 'fontshare'>>;
/** Category filter (sans-serif, serif, display, etc.) */
categories: ReturnType<typeof createFilter<FontCategory>>;
/** Subset filter (latin, latin-ext, cyrillic, etc.) */
subsets: ReturnType<typeof createFilter<FontSubset>>;
}
// FONT CATEGORIES
/**
* Unified categories (combines both providers)
*/
export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
{ 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' },
{ id: 'slab', name: 'Slab', value: 'slab' },
{ id: 'script', name: 'Script', value: 'script' },
];
// FONT SUBSETS
/**
* Common font subsets
*/
export const FONT_SUBSETS: Property<FontSubset>[] = [
{ id: 'latin', name: 'Latin', value: 'latin' },
{ id: 'latin-ext', name: 'Latin Extended', value: 'latin-ext' },
{ id: 'cyrillic', name: 'Cyrillic', value: 'cyrillic' },
{ id: 'greek', name: 'Greek', value: 'greek' },
{ id: 'arabic', name: 'Arabic', value: 'arabic' },
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
];
// FONT PROVIDERS
/**
* Font providers
*/
export const FONT_PROVIDERS: Property<FontProvider>[] = [
{ id: 'google', name: 'Google Fonts', value: 'google' },
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
];
// FILTER FACTORIES
/**
* Create a mock filter from properties
*/
export function createMockFilter<TValue extends string>(
options: MockFilterOptions & { properties: Property<TValue>[] },
) {
return createFilter<TValue>(options);
}
/**
* Create a mock filter for categories
*/
export function createCategoriesFilter(options?: { selected?: FontCategory[] }) {
const properties = UNIFIED_CATEGORIES.map(cat => ({
...cat,
selected: options?.selected?.includes(cat.value) ?? false,
}));
return createFilter<FontCategory>({ properties });
}
/**
* Create a mock filter for subsets
*/
export function createSubsetsFilter(options?: { selected?: FontSubset[] }) {
const properties = FONT_SUBSETS.map(subset => ({
...subset,
selected: options?.selected?.includes(subset.value) ?? false,
}));
return createFilter<FontSubset>({ properties });
}
/**
* Create a mock filter for providers
*/
export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
const properties = FONT_PROVIDERS.map(provider => ({
...provider,
selected: options?.selected?.includes(provider.value) ?? false,
}));
return createFilter<FontProvider>({ properties });
}
// PRESET FILTERS
/**
* Preset mock filters - use these directly in stories
*/
export const MOCK_FILTERS: MockFilters = {
providers: createFilter({
properties: FONT_PROVIDERS,
}),
categories: createFilter({
properties: UNIFIED_CATEGORIES,
}),
subsets: createFilter({
properties: FONT_SUBSETS,
}),
};
/**
* Preset filters with some items selected
*/
export const MOCK_FILTERS_SELECTED: MockFilters = {
providers: createFilter({
properties: [
{ ...FONT_PROVIDERS[0], selected: true },
{ ...FONT_PROVIDERS[1] },
],
}),
categories: createFilter({
properties: [
{ ...UNIFIED_CATEGORIES[0], selected: true },
{ ...UNIFIED_CATEGORIES[1], selected: true },
{ ...UNIFIED_CATEGORIES[2] },
{ ...UNIFIED_CATEGORIES[3] },
{ ...UNIFIED_CATEGORIES[4] },
],
}),
subsets: createFilter({
properties: [
{ ...FONT_SUBSETS[0], selected: true },
{ ...FONT_SUBSETS[1] },
{ ...FONT_SUBSETS[2] },
{ ...FONT_SUBSETS[3] },
{ ...FONT_SUBSETS[4] },
],
}),
};
/**
* Empty filters (all properties, none selected)
*/
export const MOCK_FILTERS_EMPTY: MockFilters = {
providers: createFilter({
properties: FONT_PROVIDERS.map(p => ({ ...p, selected: false })),
}),
categories: createFilter({
properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: false })),
}),
subsets: createFilter({
properties: FONT_SUBSETS.map(s => ({ ...s, selected: false })),
}),
};
/**
* All selected filters
*/
export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
providers: createFilter({
properties: FONT_PROVIDERS.map(p => ({ ...p, selected: true })),
}),
categories: createFilter({
properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: true })),
}),
subsets: createFilter({
properties: FONT_SUBSETS.map(s => ({ ...s, selected: true })),
}),
};
// GENERIC FILTER MOCKS
/**
* Create a mock filter with generic string properties
* Useful for testing generic filter components
*/
export function createGenericFilter(
items: Array<{ id: string; name: string; selected?: boolean }>,
options?: { selected?: string[] },
) {
const properties = items.map(item => ({
id: item.id,
name: item.name,
value: item.id,
selected: options?.selected?.includes(item.id) ?? item.selected ?? false,
}));
return createFilter({ properties });
}
/**
* Preset generic filters for testing
*/
export const GENERIC_FILTERS = {
/** Small filter with 3 items */
small: createFilter({
properties: [
{ id: 'option-1', name: 'Option 1', value: 'option-1' },
{ id: 'option-2', name: 'Option 2', value: 'option-2' },
{ id: 'option-3', name: 'Option 3', value: 'option-3' },
],
}),
/** Medium filter with 6 items */
medium: createFilter({
properties: [
{ id: 'alpha', name: 'Alpha', value: 'alpha' },
{ id: 'beta', name: 'Beta', value: 'beta' },
{ id: 'gamma', name: 'Gamma', value: 'gamma' },
{ id: 'delta', name: 'Delta', value: 'delta' },
{ id: 'epsilon', name: 'Epsilon', value: 'epsilon' },
{ id: 'zeta', name: 'Zeta', value: 'zeta' },
],
}),
/** Large filter with 12 items */
large: createFilter({
properties: [
{ id: 'jan', name: 'January', value: 'jan' },
{ id: 'feb', name: 'February', value: 'feb' },
{ id: 'mar', name: 'March', value: 'mar' },
{ id: 'apr', name: 'April', value: 'apr' },
{ id: 'may', name: 'May', value: 'may' },
{ id: 'jun', name: 'June', value: 'jun' },
{ id: 'jul', name: 'July', value: 'jul' },
{ id: 'aug', name: 'August', value: 'aug' },
{ id: 'sep', name: 'September', value: 'sep' },
{ id: 'oct', name: 'October', value: 'oct' },
{ id: 'nov', name: 'November', value: 'nov' },
{ id: 'dec', name: 'December', value: 'dec' },
],
}),
/** Filter with some pre-selected items */
partial: createFilter({
properties: [
{ id: 'red', name: 'Red', value: 'red', selected: true },
{ id: 'blue', name: 'Blue', value: 'blue', selected: false },
{ id: 'green', name: 'Green', value: 'green', selected: true },
{ id: 'yellow', name: 'Yellow', value: 'yellow', selected: false },
],
}),
/** Filter with all items selected */
allSelected: createFilter({
properties: [
{ id: 'cat', name: 'Cat', value: 'cat', selected: true },
{ id: 'dog', name: 'Dog', value: 'dog', selected: true },
{ id: 'bird', name: 'Bird', value: 'bird', selected: true },
],
}),
/** Empty filter (no items) */
empty: createFilter({
properties: [],
}),
};
/**
* Generate a filter with sequential items
*/
export function generateSequentialFilter(count: number, prefix = 'Item ') {
const properties = Array.from({ length: count }, (_, i) => ({
id: `item-${i + 1}`,
name: `${prefix}${i + 1}`,
value: `item-${i + 1}`,
}));
return createFilter({ properties });
}

View File

@@ -0,0 +1,274 @@
/**
* ============================================================================
* MOCK FONT DATA
* ============================================================================
*
* Factory functions and preset mock data for fonts.
* Used in Storybook stories, tests, and development.
*
* ## Usage
*
* ```ts
* import {
* mockGoogleFont,
* mockFontshareFont,
* mockUnifiedFont,
* GOOGLE_FONTS,
* FONTHARE_FONTS,
* UNIFIED_FONTS,
* } from '$entities/Font/lib/mocks';
*
* // Create a mock Google Font
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
*
* // Create a mock Fontshare font
* const satoshi = mockFontshareFont({ name: 'Satoshi', slug: 'satoshi' });
*
* // Create a mock UnifiedFont
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
*
* // Use preset fonts
* import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
* ```
*/
import type {
FontCategory,
FontProvider,
FontSubset,
FontVariant,
} from '$entities/Font/model/types';
import type {
FontFeatures,
FontMetadata,
FontStyleUrls,
UnifiedFont,
} from '$entities/Font/model/types';
// UNIFIED FONT MOCKS
/**
* Options for creating a mock UnifiedFont
*/
export interface MockUnifiedFontOptions {
/** Unique identifier (default: derived from name) */
id?: string;
/** Font display name (default: 'Mock Font') */
name?: string;
/** Font provider (default: 'google') */
provider?: FontProvider;
/** Font category (default: 'sans-serif') */
category?: FontCategory;
/** Font subsets (default: ['latin']) */
subsets?: FontSubset[];
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
variants?: FontVariant[];
/** Style URLs (if not provided, mock URLs are generated) */
styles?: FontStyleUrls;
/** Metadata overrides */
metadata?: Partial<FontMetadata>;
/** Features overrides */
features?: Partial<FontFeatures>;
}
/**
* Default mock UnifiedFont
*/
export function mockUnifiedFont(options: MockUnifiedFontOptions = {}): UnifiedFont {
const {
id,
name = 'Mock Font',
provider = 'google',
category = 'sans-serif',
subsets = ['latin'],
variants = ['regular', '700', 'italic', '700italic'],
styles,
metadata,
features,
} = options;
const fontId = id ?? name.toLowerCase().replace(/\s+/g, '');
const baseUrl = provider === 'google'
? `https://fonts.gstatic.com/s/${fontId}/v30`
: `//cdn.fontshare.com/wf/${fontId}`;
return {
id: fontId,
name,
provider,
category,
subsets,
variants: variants as FontVariant[],
styles: styles ?? {
regular: `${baseUrl}/regular.woff2`,
bold: `${baseUrl}/bold.woff2`,
italic: `${baseUrl}/italic.woff2`,
boldItalic: `${baseUrl}/bolditalic.woff2`,
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
lastModified: new Date().toISOString().split('T')[0],
popularity: 1,
...metadata,
},
features: {
isVariable: false,
...features,
},
};
}
/**
* Preset UnifiedFont mocks
*/
export const UNIFIED_FONTS: Record<string, UnifiedFont> = {
roboto: mockUnifiedFont({
id: 'roboto',
name: 'Roboto',
provider: 'google',
category: 'sans-serif',
subsets: ['latin', 'latin-ext'],
variants: ['100', '300', '400', '500', '700', '900'],
metadata: { popularity: 1 },
}),
openSans: mockUnifiedFont({
id: 'open-sans',
name: 'Open Sans',
provider: 'google',
category: 'sans-serif',
subsets: ['latin', 'latin-ext'],
variants: ['300', '400', '500', '600', '700', '800'],
metadata: { popularity: 2 },
}),
lato: mockUnifiedFont({
id: 'lato',
name: 'Lato',
provider: 'google',
category: 'sans-serif',
subsets: ['latin', 'latin-ext'],
variants: ['100', '300', '400', '700', '900'],
metadata: { popularity: 3 },
}),
playfairDisplay: mockUnifiedFont({
id: 'playfair-display',
name: 'Playfair Display',
provider: 'google',
category: 'serif',
subsets: ['latin'],
variants: ['400', '700', '900'],
metadata: { popularity: 10 },
}),
montserrat: mockUnifiedFont({
id: 'montserrat',
name: 'Montserrat',
provider: 'google',
category: 'sans-serif',
subsets: ['latin', 'latin-ext'],
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
metadata: { popularity: 4 },
}),
satoshi: mockUnifiedFont({
id: 'satoshi',
name: 'Satoshi',
provider: 'fontshare',
category: 'sans-serif',
subsets: ['latin'],
variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[],
features: { isVariable: true, axes: [{ name: 'wght', property: 'wght', default: 400, min: 300, max: 700 }] },
metadata: { popularity: 15000 },
}),
generalSans: mockUnifiedFont({
id: 'general-sans',
name: 'General Sans',
provider: 'fontshare',
category: 'sans-serif',
subsets: ['latin'],
variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[],
features: { isVariable: true },
metadata: { popularity: 12000 },
}),
clashDisplay: mockUnifiedFont({
id: 'clash-display',
name: 'Clash Display',
provider: 'fontshare',
category: 'display',
subsets: ['latin'],
variants: ['regular', '500', '600', 'bold'] as FontVariant[],
features: { tags: ['Headlines', 'Posters', 'Branding'] },
metadata: { popularity: 8000 },
}),
oswald: mockUnifiedFont({
id: 'oswald',
name: 'Oswald',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: ['200', '300', '400', '500', '600', '700'],
metadata: { popularity: 6 },
}),
raleway: mockUnifiedFont({
id: 'raleway',
name: 'Raleway',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
metadata: { popularity: 7 },
}),
};
/**
* Get an array of all preset UnifiedFonts
*/
export function getAllMockFonts(): UnifiedFont[] {
return Object.values(UNIFIED_FONTS);
}
/**
* Get fonts by provider
*/
export function getFontsByProvider(provider: FontProvider): UnifiedFont[] {
return getAllMockFonts().filter(font => font.provider === provider);
}
/**
* Get fonts by category
*/
export function getFontsByCategory(category: FontCategory): UnifiedFont[] {
return getAllMockFonts().filter(font => font.category === category);
}
/**
* Generate an array of mock fonts with sequential naming
*/
export function generateMockFonts(count: number, options?: Omit<MockUnifiedFontOptions, 'id' | 'name'>): UnifiedFont[] {
return Array.from({ length: count }, (_, i) =>
mockUnifiedFont({
...options,
id: `mock-font-${i + 1}`,
name: `Mock Font ${i + 1}`,
}));
}
/**
* Generate an array of mock fonts with different categories
*/
export function generateMixedCategoryFonts(countPerCategory: number = 2): UnifiedFont[] {
const categories: FontCategory[] = ['sans-serif', 'serif', 'display', 'handwriting', 'monospace'];
const fonts: UnifiedFont[] = [];
categories.forEach(category => {
for (let i = 0; i < countPerCategory; i++) {
fonts.push(
mockUnifiedFont({
id: `${category}-${i + 1}`,
name: `${category.replace('-', ' ')} ${i + 1}`,
category,
}),
);
}
});
return fonts;
}

View File

@@ -0,0 +1,76 @@
/**
* ============================================================================
* MOCK DATA HELPERS - MAIN EXPORT
* ============================================================================
*
* Comprehensive mock data for Storybook stories, tests, and development.
*
* ## Quick Start
*
* ```ts
* import {
* mockUnifiedFont,
* UNIFIED_FONTS,
* MOCK_FILTERS,
* createMockFontStoreState,
* } from '$entities/Font/lib/mocks';
*
* // Use in stories
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
* const presets = UNIFIED_FONTS;
* const filter = MOCK_FILTERS.categories;
* ```
*
* @module
*/
// Font mocks
export {
generateMixedCategoryFonts,
generateMockFonts,
getAllMockFonts,
getFontsByCategory,
getFontsByProvider,
mockUnifiedFont,
type MockUnifiedFontOptions,
UNIFIED_FONTS,
} from './fonts.mock';
// Filter mocks
export {
createCategoriesFilter,
createGenericFilter,
createMockFilter,
createProvidersFilter,
createSubsetsFilter,
FONT_PROVIDERS,
FONT_SUBSETS,
generateSequentialFilter,
GENERIC_FILTERS,
MOCK_FILTERS,
MOCK_FILTERS_ALL_SELECTED,
MOCK_FILTERS_EMPTY,
MOCK_FILTERS_SELECTED,
type MockFilterOptions,
type MockFilters,
UNIFIED_CATEGORIES,
} from './filters.mock';
// Store mocks
export {
createErrorState,
createLoadingState,
createMockComparisonStore,
createMockFontApiResponse,
createMockFontStoreState,
createMockQueryState,
createMockReactiveState,
createMockStore,
createSuccessState,
generatePaginatedFonts,
MOCK_FONT_STORE_STATES,
MOCK_STORES,
type MockFontStoreState,
type MockQueryObserverResult,
type MockQueryState,
} from './stores.mock';

View File

@@ -0,0 +1,689 @@
/**
* ============================================================================
* 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.
*
* ## Usage
*
* ```ts
* import {
* createMockQueryState,
* MOCK_STORES,
* } from '$entities/Font/lib/mocks';
*
* // Create a mock query state
* const loadingState = createMockQueryState({ status: 'pending' });
* const errorState = createMockQueryState({ status: 'error', error: 'Failed to load' });
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
*
* // Use preset stores
* const mockFontStore = createMockFontStore();
* ```
*/
import type { UnifiedFont } from '$entities/Font/model/types';
import type {
QueryKey,
QueryObserverResult,
QueryStatus,
} from '@tanstack/svelte-query';
import {
UNIFIED_FONTS,
generateMockFonts,
} from './fonts.mock';
// TANSTACK QUERY MOCK TYPES
/**
* Mock TanStack Query state
*/
export interface MockQueryState<TData = unknown, TError = Error> {
status: QueryStatus;
data?: TData;
error?: TError;
isLoading?: boolean;
isFetching?: boolean;
isSuccess?: boolean;
isError?: boolean;
isPending?: boolean;
dataUpdatedAt?: number;
errorUpdatedAt?: number;
failureCount?: number;
failureReason?: TError;
errorUpdateCount?: number;
isRefetching?: boolean;
isRefetchError?: boolean;
isPaused?: boolean;
}
/**
* Mock TanStack Query observer result
*/
export interface MockQueryObserverResult<TData = unknown, TError = Error> {
status?: QueryStatus;
data?: TData;
error?: TError;
isLoading?: boolean;
isFetching?: boolean;
isSuccess?: boolean;
isError?: boolean;
isPending?: boolean;
dataUpdatedAt?: number;
errorUpdatedAt?: number;
failureCount?: number;
failureReason?: TError;
errorUpdateCount?: number;
isRefetching?: boolean;
isRefetchError?: boolean;
isPaused?: boolean;
}
// TANSTACK QUERY MOCK FACTORIES
/**
* Create a mock query state for TanStack Query
*/
export function createMockQueryState<TData = unknown, TError = Error>(
options: MockQueryState<TData, TError>,
): MockQueryObserverResult<TData, TError> {
const {
status,
data,
error,
} = options;
return {
status: status ?? 'success',
data,
error,
isLoading: status === 'pending' ? true : false,
isFetching: status === 'pending' ? true : false,
isSuccess: status === 'success',
isError: status === 'error',
isPending: status === 'pending',
dataUpdatedAt: status === 'success' ? Date.now() : undefined,
errorUpdatedAt: status === 'error' ? Date.now() : undefined,
failureCount: status === 'error' ? 1 : 0,
failureReason: status === 'error' ? error : undefined,
errorUpdateCount: status === 'error' ? 1 : 0,
isRefetching: false,
isRefetchError: false,
isPaused: false,
};
}
/**
* Create a loading query state
*/
export function createLoadingState<TData = unknown>(): MockQueryObserverResult<TData> {
return createMockQueryState<TData>({ status: 'pending', data: undefined, error: undefined });
}
/**
* Create an error query state
*/
export function createErrorState<TError = Error>(
error: TError,
): MockQueryObserverResult<unknown, TError> {
return createMockQueryState<unknown, TError>({ status: 'error', data: undefined, error });
}
/**
* Create a success query state
*/
export function createSuccessState<TData>(data: TData): MockQueryObserverResult<TData> {
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
}
// FONT STORE MOCKS
/**
* Mock UnifiedFontStore state
*/
export interface MockFontStoreState {
/** All cached fonts */
fonts: Record<string, UnifiedFont>;
/** Current page */
page: number;
/** Total pages available */
totalPages: number;
/** Items per page */
limit: number;
/** Total font count */
total: number;
/** Loading state */
isLoading: boolean;
/** Error state */
error: Error | null;
/** Search query */
searchQuery: string;
/** Selected provider */
provider: 'google' | 'fontshare' | 'all';
/** Selected category */
category: string | null;
/** Selected subset */
subset: string | null;
}
/**
* Create a mock font store state
*/
export function createMockFontStoreState(
options: Partial<MockFontStoreState> = {},
): MockFontStoreState {
const {
page = 1,
limit = 24,
isLoading = false,
error = null,
searchQuery = '',
provider = 'all',
category = null,
subset = null,
} = options;
// Generate mock fonts if not provided
const mockFonts = options.fonts ?? Object.fromEntries(
Object.values(UNIFIED_FONTS).map(font => [font.id, font]),
);
const fontArray = Object.values(mockFonts);
const total = options.total ?? fontArray.length;
const totalPages = options.totalPages ?? Math.ceil(total / limit);
return {
fonts: mockFonts,
page,
totalPages,
limit,
total,
isLoading,
error,
searchQuery,
provider,
category,
subset,
};
}
/**
* Preset font store states
*/
export const MOCK_FONT_STORE_STATES = {
/** Initial loading state */
loading: createMockFontStoreState({
isLoading: true,
fonts: {},
total: 0,
page: 1,
}),
/** Empty state (no fonts found) */
empty: createMockFontStoreState({
fonts: {},
total: 0,
page: 1,
isLoading: false,
}),
/** First page with fonts */
firstPage: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]),
),
total: 50,
page: 1,
limit: 10,
totalPages: 5,
isLoading: false,
}),
/** Second page with fonts */
secondPage: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]),
),
total: 50,
page: 2,
limit: 10,
totalPages: 5,
isLoading: false,
}),
/** Last page with fonts */
lastPage: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]),
),
total: 25,
page: 3,
limit: 10,
totalPages: 3,
isLoading: false,
}),
/** Error state */
error: createMockFontStoreState({
fonts: {},
error: new Error('Failed to load fonts'),
total: 0,
page: 1,
isLoading: false,
}),
/** With search query */
withSearch: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]),
),
total: 3,
page: 1,
isLoading: false,
searchQuery: 'Roboto',
}),
/** Filtered by category */
filteredByCategory: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS)
.filter(f => f.category === 'serif')
.slice(0, 5)
.map(font => [font.id, font]),
),
total: 5,
page: 1,
isLoading: false,
category: 'serif',
}),
/** Filtered by provider */
filteredByProvider: createMockFontStoreState({
fonts: Object.fromEntries(
Object.values(UNIFIED_FONTS)
.filter(f => f.provider === 'google')
.slice(0, 5)
.map(font => [font.id, font]),
),
total: 5,
page: 1,
isLoading: false,
provider: 'google',
}),
/** Large dataset */
largeDataset: createMockFontStoreState({
fonts: Object.fromEntries(
generateMockFonts(50).map(font => [font.id, font]),
),
total: 500,
page: 1,
limit: 50,
totalPages: 10,
isLoading: false,
}),
};
// 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: {
data?: T;
isLoading?: boolean;
isError?: boolean;
error?: Error;
isFetching?: boolean;
}) {
const {
data,
isLoading = false,
isError = false,
error,
isFetching = false,
} = config;
return {
get data() {
return data;
},
get isLoading() {
return isLoading;
},
get isError() {
return isError;
},
get error() {
return error;
},
get isFetching() {
return isFetching;
},
get isSuccess() {
return !isLoading && !isError && data !== undefined;
},
get status() {
if (isLoading) return 'pending';
if (isError) return 'error';
return 'success';
},
};
}
/**
* Preset mock stores
*/
export const MOCK_STORES = {
/** Font store in loading state */
loadingFontStore: createMockStore<UnifiedFont[]>({
isLoading: true,
data: undefined,
}),
/** Font store with fonts loaded */
successFontStore: createMockStore<UnifiedFont[]>({
data: Object.values(UNIFIED_FONTS),
isLoading: false,
isError: false,
}),
/** Font store with error */
errorFontStore: createMockStore<UnifiedFont[]>({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Failed to load fonts'),
}),
/** Font store with empty results */
emptyFontStore: createMockStore<UnifiedFont[]>({
data: [],
isLoading: false,
isError: false,
}),
/**
* Create a mock UnifiedFontStore-like object
* Note: This is a simplified mock for Storybook use
*/
unifiedFontStore: (state: Partial<MockFontStoreState> = {}) => {
const mockState = createMockFontStoreState(state);
return {
// State properties
get fonts() {
return mockState.fonts;
},
get page() {
return mockState.page;
},
get totalPages() {
return mockState.totalPages;
},
get limit() {
return mockState.limit;
},
get total() {
return mockState.total;
},
get isLoading() {
return mockState.isLoading;
},
get error() {
return mockState.error;
},
get searchQuery() {
return mockState.searchQuery;
},
get provider() {
return mockState.provider;
},
get category() {
return mockState.category;
},
get subset() {
return mockState.subset;
},
// Methods (no-op for Storybook)
nextPage: () => {},
prevPage: () => {},
goToPage: (_page: number) => {},
setLimit: (_limit: number) => {},
setProvider: (_provider: typeof mockState.provider) => {},
setCategory: (_category: string | null) => {},
setSubset: (_subset: string | null) => {},
setSearch: (_query: string) => {},
resetFilters: () => {},
};
},
/**
* Create a mock FontStore object
* Matches FontStore's public API for Storybook use
*/
fontStore: (config: {
fonts?: UnifiedFont[];
total?: number;
limit?: number;
offset?: number;
isLoading?: boolean;
isFetching?: boolean;
isError?: boolean;
error?: Error | null;
hasMore?: boolean;
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
get params() {
return state.params;
},
get fonts() {
return mockFonts;
},
get isLoading() {
return isLoading;
},
get isFetching() {
return isFetching;
},
get isError() {
return isError;
},
get error() {
return error;
},
get isEmpty() {
return !isLoading && !isFetching && mockFonts.length === 0;
},
get pagination() {
return {
total: mockTotal,
limit,
offset,
hasMore,
page,
totalPages,
};
},
// Category getters
get sansSerifFonts() {
return mockFonts.filter(f => f.category === 'sans-serif');
},
get serifFonts() {
return mockFonts.filter(f => f.category === 'serif');
},
get displayFonts() {
return mockFonts.filter(f => f.category === 'display');
},
get handwritingFonts() {
return mockFonts.filter(f => f.category === 'handwriting');
},
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
/**
* Create a reactive state object using Svelte 5 runes pattern
* Useful for stories that need reactive state
*
* Note: This uses plain JavaScript objects since Svelte runes
* only work in .svelte files. For Storybook, this provides
* a similar API for testing.
*/
export function createMockReactiveState<T>(initialValue: T) {
let value = initialValue;
return {
get value() {
return value;
},
set value(newValue: T) {
value = newValue;
},
update(fn: (current: T) => T) {
value = fn(value);
},
};
}
/**
* Mock comparison store for ComparisonSlider component
*/
export function createMockComparisonStore(config: {
fontA?: UnifiedFont;
fontB?: UnifiedFont;
text?: string;
} = {}) {
const { fontA, fontB, text = 'The quick brown fox jumps over the lazy dog.' } = config;
return {
get fontA() {
return fontA ?? UNIFIED_FONTS.roboto;
},
get fontB() {
return fontB ?? UNIFIED_FONTS.openSans;
},
get text() {
return text;
},
// Methods (no-op for Storybook)
setFontA: (_font: UnifiedFont | undefined) => {},
setFontB: (_font: UnifiedFont | undefined) => {},
setText: (_text: string) => {},
swapFonts: () => {},
};
}
// MOCK DATA GENERATORS
/**
* Generate paginated font data
*/
export function generatePaginatedFonts(
totalCount: number,
page: number,
limit: number,
): {
fonts: UnifiedFont[];
page: number;
totalPages: number;
total: number;
hasNextPage: boolean;
hasPrevPage: boolean;
} {
const totalPages = Math.ceil(totalCount / limit);
const startIndex = (page - 1) * limit;
const endIndex = Math.min(startIndex + limit, totalCount);
return {
fonts: generateMockFonts(endIndex - startIndex).map((font, i) => ({
...font,
id: `font-${startIndex + i + 1}`,
name: `Font ${startIndex + i + 1}`,
})),
page,
totalPages,
total: totalCount,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
};
}
/**
* Create mock API response for fonts
*/
export function createMockFontApiResponse(config: {
fonts?: UnifiedFont[];
total?: number;
page?: number;
limit?: number;
} = {}) {
const fonts = config.fonts ?? Object.values(UNIFIED_FONTS);
const total = config.total ?? fonts.length;
const page = config.page ?? 1;
const limit = config.limit ?? fonts.length;
return {
data: fonts,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
hasNextPage: page < Math.ceil(total / limit),
hasPrevPage: page > 1,
},
};
}

View File

@@ -1,584 +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,274 +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,
} 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;
});
// 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,112 @@
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,43 +1,7 @@
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 { fetchFontshareFontsQuery } from './services';
export {
createFontshareStore,
type FontshareStore,
fontshareStore,
appliedFontsManager,
createFontStore,
FontStore,
fontStore,
} from './store';
export * from './types';

View File

@@ -1,31 +0,0 @@
import {
type FontshareParams,
fetchFontshareFonts,
} from '../../api';
import { normalizeFontshareFonts } from '../../lib';
import type { UnifiedFont } from '../types';
/**
* Query function for fetching fonts from Fontshare.
*
* @param params - The parameters for fetching fonts from Fontshare (E.g. search query, page number, etc.).
* @returns A promise that resolves with an array of UnifiedFont objects representing the fonts found in Fontshare.
*/
export async function fetchFontshareFontsQuery(params: FontshareParams): Promise<UnifiedFont[]> {
try {
const response = await fetchFontshareFonts(params);
return normalizeFontshareFonts(response.fonts);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('Failed to fetch')) {
throw new Error(
'Unable to connect to Fontshare. Please check your internet connection.',
);
}
if (error.message.includes('404')) {
throw new Error('Font not found in Fontshare catalog.');
}
}
throw new Error('Failed to load fonts from Fontshare.');
}
}

View File

@@ -1,274 +0,0 @@
/**
* Service for fetching Google Fonts with Svelte 5 runes + TanStack Query
*/
import {
type CreateQueryResult,
createQuery,
useQueryClient,
} from '@tanstack/svelte-query';
import {
type GoogleFontsParams,
fetchGoogleFonts,
} from '../../api';
import { normalizeGoogleFonts } from '../../lib';
import type {
FontCategory,
FontSubset,
} from '../types';
import type { UnifiedFont } from '../types/normalize';
/**
* Query key factory
*/
function getGoogleFontsQueryKey(params: GoogleFontsParams) {
return ['googleFonts', params] as const;
}
/**
* Query function
*/
export async function fetchGoogleFontsQuery(params: GoogleFontsParams): Promise<UnifiedFont[]> {
try {
const response = await fetchGoogleFonts({
category: params.category,
subset: params.subset,
sort: params.sort,
});
return normalizeGoogleFonts(response.items);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('Failed to fetch')) {
throw new Error(
'Unable to connect to Google Fonts. Please check your internet connection.',
);
}
if (error.message.includes('404')) {
throw new Error('Font not found in Google Fonts catalog.');
}
}
throw new Error('Failed to load fonts from Google Fonts.');
}
}
/**
* Google Fonts store wrapping TanStack Query with runes
*/
export class GoogleFontsStore {
params = $state<GoogleFontsParams>({});
private query: CreateQueryResult<UnifiedFont[], Error>;
private queryClient = useQueryClient();
constructor(initialParams: GoogleFontsParams = {}) {
this.params = initialParams;
// Create the query - automatically reactive
this.query = createQuery(() => ({
queryKey: getGoogleFontsQueryKey(this.params),
queryFn: () => fetchGoogleFontsQuery(this.params),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
}));
}
// Proxy TanStack Query's reactive state
get fonts() {
return this.query.data ?? [];
}
get isLoading() {
return this.query.isLoading;
}
get isFetching() {
return this.query.isFetching;
}
get isRefetching() {
return this.query.isRefetching;
}
get error() {
return this.query.error;
}
get isError() {
return this.query.isError;
}
get isSuccess() {
return this.query.isSuccess;
}
get status() {
return this.query.status;
}
// Derived helpers
get hasData() {
return this.fonts.length > 0;
}
get isEmpty() {
return !this.isLoading && this.fonts.length === 0;
}
get fontCount() {
return this.fonts.length;
}
// Filtered fonts by category (if you need additional client-side filtering)
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');
}
/**
* Update parameters - TanStack Query will automatically refetch
*/
setParams(newParams: Partial<GoogleFontsParams>) {
this.params = { ...this.params, ...newParams };
}
setCategory(category: FontCategory | undefined) {
this.setParams({ category });
}
setSubset(subset: FontSubset | undefined) {
this.setParams({ subset });
}
setSort(sort: 'popularity' | 'alpha' | 'date' | undefined) {
this.setParams({ sort });
}
setSearch(search: string) {
this.setParams({ search });
}
clearSearch() {
this.setParams({ search: undefined });
}
clearFilters() {
this.params = {};
}
/**
* Manually refetch
*/
async refetch() {
await this.query.refetch();
}
/**
* Invalidate cache and refetch
*/
invalidate() {
this.queryClient.invalidateQueries({
queryKey: getGoogleFontsQueryKey(this.params),
});
}
/**
* Invalidate all Google Fonts queries
*/
invalidateAll() {
this.queryClient.invalidateQueries({
queryKey: ['googleFonts'],
});
}
/**
* Prefetch with different params (for hover states, pagination, etc.)
*/
async prefetch(params: GoogleFontsParams) {
await this.queryClient.prefetchQuery({
queryKey: getGoogleFontsQueryKey(params),
queryFn: () => fetchGoogleFontsQuery(params),
staleTime: 5 * 60 * 1000,
});
}
/**
* Prefetch next category (useful for tab switching)
*/
async prefetchCategory(category: FontCategory) {
await this.prefetch({ ...this.params, category });
}
/**
* Cancel ongoing queries
*/
cancel() {
this.queryClient.cancelQueries({
queryKey: getGoogleFontsQueryKey(this.params),
});
}
/**
* Clear cache for current params
*/
clearCache() {
this.queryClient.removeQueries({
queryKey: getGoogleFontsQueryKey(this.params),
});
}
/**
* Get cached data without triggering fetch
*/
getCachedData() {
return this.queryClient.getQueryData<UnifiedFont[]>(
getGoogleFontsQueryKey(this.params),
);
}
/**
* Check if data exists in cache
*/
hasCache(params?: GoogleFontsParams) {
const key = params ? getGoogleFontsQueryKey(params) : getGoogleFontsQueryKey(this.params);
return this.queryClient.getQueryData(key) !== undefined;
}
/**
* Set data manually (optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
this.queryClient.setQueryData(
getGoogleFontsQueryKey(this.params),
updater,
);
}
/**
* Get query state for debugging
*/
getQueryState() {
return this.queryClient.getQueryState(
getGoogleFontsQueryKey(this.params),
);
}
}
/**
* Factory function to create Google Fonts store
*/
export function createGoogleFontsStore(params: GoogleFontsParams = {}) {
return new GoogleFontsStore(params);
}

View File

@@ -1,2 +0,0 @@
export { fetchFontshareFontsQuery } from './fetchFontshareFonts.svelte';
export { fetchGoogleFontsQuery } from './fetchGoogleFonts.svelte';

View File

@@ -0,0 +1,332 @@
/** @vitest-environment jsdom */
import { AppliedFontsManager } from './appliedFontsStore.svelte';
import { FontFetchError } from './errors';
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
// ── Fake collaborators ────────────────────────────────────────────────────────
class FakeBufferCache {
async get(_url: string): Promise<ArrayBuffer> {
return new ArrayBuffer(8);
}
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 {}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
id,
name: id,
url: `https://example.com/${id}.woff2`,
weight: 400,
...overrides,
});
// ── Suite ─────────────────────────────────────────────────────────────────────
describe('AppliedFontsManager', () => {
let manager: AppliedFontsManager;
let eviction: FontEvictionPolicy;
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.useFakeTimers();
eviction = new FontEvictionPolicy({ ttl: 60000 });
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) {
this.name = name;
this.buffer = buffer;
this.load = vi.fn().mockResolvedValue(this);
});
vi.stubGlobal('FontFace', MockFontFace);
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
});
// ── touch() ───────────────────────────────────────────────────────────────
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(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
});
it('skips fonts that are already loaded', async () => {
manager.touch([makeConfig('lato')]);
await vi.advanceTimersByTimeAsync(50);
manager.touch([makeConfig('lato')]);
await vi.advanceTimersByTimeAsync(50);
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
});
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);
});
});
// ── queue processing ──────────────────────────────────────────────────────
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;
});
});
// ── Phase 1: fetch ────────────────────────────────────────────────────────
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();
});
});
// ── Phase 2: parse ────────────────────────────────────────────────────────
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');
consoleSpy.mockRestore();
});
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([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
// ── #purgeUnused ──────────────────────────────────────────────────────────
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('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);
await vi.advanceTimersByTimeAsync(40000);
manager.touch([config]); // refresh at t≈40s
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();
});
});
// ── destroy() ─────────────────────────────────────────────────────────────
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

@@ -0,0 +1,379 @@
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';
interface AppliedFontsManagerDeps {
cache?: FontBufferCache;
eviction?: FontEvictionPolicy;
queue?: FontLoadQueue;
}
/**
* Manages web font loading with caching, adaptive concurrency, and automatic cleanup.
*
* **Two-Phase Loading Strategy:**
* 1. *Concurrent Fetching*: Font files fetched in parallel (network I/O is non-blocking)
* 2. *Sequential Parsing*: Buffers parsed into FontFace objects one at a time with periodic yields
*
* **Yielding Strategy:**
* - Chromium: Yields only when user input is pending (via `scheduler.yield()` + `isInputPending()`)
* - Others: Time-based fallback, yields every 8ms
*
* **Network Adaptation:**
* - 2G: 1 concurrent request, 3G: 2, 4G+: 4 (via Network Information API)
* - Respects `saveData` mode to defer non-critical weights
*
* **Cache Integration:** Cache API with best-effort fallback (handles private browsing, quota limits)
*
* **Cleanup:** LRU-style eviction after 5 minutes of inactivity; cleanup runs every 60 seconds
*
* **Font Identity:** Variable fonts use `{id}@vf`, static fonts use `{id}@{weight}`
*
* **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>();
// 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;
// Interval handle for periodic cleanup (runs every PURGE_INTERVAL)
#intervalId: ReturnType<typeof setInterval> | null = null;
// AbortController for canceling in-flight fetches on destroy
#abortController = new AbortController();
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
#pendingType: 'idle' | 'timeout' | null = null;
readonly #PURGE_INTERVAL = 60000;
// Reactive status map for Svelte components to track font states
statuses = new SvelteMap<string, FontLoadStatus>();
// Starts periodic cleanup timer (browser-only).
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);
}
}
/**
* 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: FontLoadRequestConfig[]) {
if (this.#abortController.signal.aborted) {
return;
}
try {
const now = Date.now();
let hasNewItems = false;
for (const config of configs) {
const key = generateFontKey(config);
// Update last-used timestamp for LRU eviction policy
this.#eviction.touch(key, now);
const status = this.statuses.get(key);
// 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(),
{ timeout: 150 },
) as unknown as ReturnType<typeof setTimeout>;
this.#pendingType = 'idle';
} else {
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
this.#pendingType = 'timeout';
}
}
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
#shouldDeferNonCritical(): boolean {
return (navigator as any).connection?.saveData === true;
}
/**
* Processes queued fonts in two phases:
* 1. Concurrent fetching (network I/O, non-blocking)
* 2. Sequential parsing with periodic yields (CPU-intensive, can block UI)
*
* 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;
// 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));
}
// Determine optimal concurrent fetches based on network speed (1-4)
const concurrency = getEffectiveConcurrency();
const buffers = new Map<string, ArrayBuffer>();
// ==================== PHASE 1: Concurrent Fetching ====================
// Fetch multiple font files in parallel since network I/O is non-blocking
for (let i = 0; i < entries.length; i += concurrency) {
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers);
}
// ==================== PHASE 2: Sequential Parsing ====================
// Parse buffers one at a time with periodic yields to avoid blocking UI
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
let lastYield = performance.now();
const YIELD_INTERVAL = 8;
for (const [key, config] of entries) {
const buffer = buffers.get(key);
// 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;
if (shouldYield) {
await yieldToMainThread();
lastYield = performance.now();
}
}
}
/**
* 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 #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 {
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);
}
}
/**
* 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 {
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);
}
}
}
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */
#purgeUnused() {
const now = Date.now();
// Iterate through all tracked font keys
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);
// 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.statuses.delete(key);
this.#eviction.remove(key);
}
}
/** Returns current loading status for a font, or undefined if never requested. */
getFontStatus(id: string, weight: number, isVariable = false) {
try {
return this.statuses.get(generateFontKey({ id, weight, isVariable }));
} catch (error) {
console.error(error);
}
}
/** 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;
}
try {
await document.fonts.ready;
} catch { /* document unloaded */ }
}
/** 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);
} else {
clearTimeout(this.#timeoutId);
}
this.#timeoutId = null;
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.#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. */
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,66 @@
/** @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,97 @@
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,76 @@
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,57 @@
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,93 @@
/** @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,156 +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';
/** */
export abstract class BaseFontStore<TParams extends Record<string, any>> {
// params = $state<TParams>({} as TParams);
cleanup: () => void;
#bindings = $state<(() => Partial<TParams>)[]>([]);
#internalParams = $state<TParams>({} as TParams);
params = $derived.by(() => {
let merged = { ...this.#internalParams };
// Loop through every "Cable" plugged into the store
for (const getter of this.#bindings) {
merged = { ...merged, ...getter() };
}
return merged as TParams;
});
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
protected observer: QueryObserver<UnifiedFont[], Error>;
protected qc = queryClient;
constructor(initialParams: TParams) {
this.#internalParams = initialParams;
this.observer = new QueryObserver(this.qc, this.getOptions());
// Sync TanStack -> Svelte State
this.observer.subscribe(r => {
this.result = r;
});
// Sync Svelte State -> TanStack Options
this.cleanup = $effect.root(() => {
$effect(() => {
this.observer.setOptions(this.getOptions());
});
});
}
/**
* Mandatory: Child must define how to fetch data and what the key is.
*/
protected abstract getQueryKey(params: TParams): QueryKey;
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
private getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
return {
queryKey: this.getQueryKey(params),
queryFn: () => this.fetchFn(params),
staleTime: 5 * 60 * 1000,
};
}
// --- Common Getters ---
get fonts() {
return this.result.data ?? [];
}
get isLoading() {
return this.result.isLoading;
}
get isFetching() {
return this.result.isFetching;
}
get isError() {
return this.result.isError;
}
get isEmpty() {
return !this.isLoading && this.fonts.length === 0;
}
// --- Common Actions ---
addBinding(getter: () => Partial<TParams>) {
this.#bindings.push(getter);
return () => {
this.#bindings = this.#bindings.filter(b => b !== getter);
};
}
setParams(newParams: Partial<TParams>) {
this.#internalParams = { ...this.params, ...newParams };
}
/**
* Invalidate cache and refetch
*/
invalidate() {
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
}
destroy() {
this.cleanup();
}
/**
* Manually refetch
*/
async refetch() {
await this.observer.refetch();
}
/**
* Prefetch with different params (for hover states, pagination, etc.)
*/
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,583 @@
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,283 @@
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;
});
}
// -- Public state --
get params(): FontStoreParams {
return this.#params;
}
get fonts(): UnifiedFont[] {
return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
}
get isLoading(): boolean {
return this.#result.isLoading;
}
get isFetching(): boolean {
return this.#result.isFetching;
}
get isError(): boolean {
return this.#result.isError;
}
get error(): Error | null {
return this.#result.error ?? null;
}
// isEmpty is false during loading/fetching so the UI never flashes "no results"
// while a fetch is in progress. The !isFetching guard is specifically for the filter-change
// transition: fonts clear synchronously → isFetching becomes true → isEmpty stays false.
get isEmpty(): boolean {
return !this.isLoading && !this.isFetching && this.fonts.length === 0;
}
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),
};
}
// -- Lifecycle --
destroy() {
this.#unsubscribe();
this.#observer.destroy();
}
// -- Param management --
setParams(updates: Partial<FontStoreParams>) {
this.#params = { ...this.#params, ...updates };
this.#observer.setOptions(this.buildOptions());
}
invalidate() {
this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) });
}
// -- Async operations --
async refetch() {
await this.#observer.refetch();
}
async prefetch(params: FontStoreParams) {
await this.#qc.prefetchInfiniteQuery(this.buildOptions(params));
}
cancel() {
this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) });
}
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);
}
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 }],
};
},
);
}
// -- Filter shortcuts --
setProviders(v: ProxyFontsParams['providers']) {
this.setParams({ providers: v });
}
setCategories(v: ProxyFontsParams['categories']) {
this.setParams({ categories: v });
}
setSubsets(v: ProxyFontsParams['subsets']) {
this.setParams({ subsets: v });
}
setSearch(v: string) {
this.setParams({ q: v || undefined });
}
setSort(v: ProxyFontsParams['sort']) {
this.setParams({ sort: v });
}
// -- Pagination navigation --
async nextPage(): Promise<void> {
await this.#observer.fetchNextPage();
}
prevPage(): void {} // no-op: infinite scroll accumulates forward only; method kept for API compatibility
goToPage(_page: number): void {} // no-op
setLimit(limit: number) {
this.setParams({ limit });
}
// -- Category views --
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');
}
// -- Private helpers (TypeScript-private so tests can spy via `as any`) --
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,32 +0,0 @@
import type { FontshareParams } from '../../api';
import { fetchFontshareFontsQuery } from '../services';
import type { UnifiedFont } from '../types';
import { BaseFontStore } from './baseFontStore.svelte';
/**
* Fontshare store wrapping TanStack Query with runes
*/
export class FontshareStore extends BaseFontStore<FontshareParams> {
constructor(initialParams: FontshareParams = {}) {
super(initialParams);
}
protected getQueryKey(params: FontshareParams) {
return ['fontshare', params] as const;
}
protected async fetchFn(params: FontshareParams): Promise<UnifiedFont[]> {
return fetchFontshareFontsQuery(params);
}
// Provider-specific methods (shortcuts)
setSearch(search: string) {
this.setParams({ q: search } as any);
}
}
export function createFontshareStore(params: FontshareParams = {}) {
return new FontshareStore(params);
}
export const fontshareStore = new FontshareStore();

View File

@@ -1,27 +0,0 @@
import type { GoogleFontsParams } from '../../api';
import { fetchGoogleFontsQuery } from '../services';
import type { UnifiedFont } from '../types';
import { BaseFontStore } from './baseFontStore.svelte';
/**
* Google Fonts store wrapping TanStack Query with runes
*/
export class GoogleFontsStore extends BaseFontStore<GoogleFontsParams> {
constructor(initialParams: GoogleFontsParams = {}) {
super(initialParams);
}
protected getQueryKey(params: GoogleFontsParams) {
return ['googleFonts', params] as const;
}
protected async fetchFn(params: GoogleFontsParams): Promise<UnifiedFont[]> {
return fetchGoogleFontsQuery(params);
}
}
export function createFontshareStore(params: GoogleFontsParams = {}) {
return new GoogleFontsStore(params);
}
export const googleFontsStore = new GoogleFontsStore();

View File

@@ -1,19 +1,9 @@
/**
* ============================================================================
* UNIFIED FONT STORE EXPORTS
* ============================================================================
*
* Single export point for the unified font store infrastructure.
*/
// export {
// createUnifiedFontStore,
// UNIFIED_FONT_STORE_KEY,
// type UnifiedFontStore,
// } from './unifiedFontStore.svelte';
// Applied fonts manager
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
// Single FontStore
export {
createFontshareStore,
type FontshareStore,
fontshareStore,
} from './fontshareStore.svelte';
createFontStore,
FontStore,
fontStore,
} from './fontStore/fontStore.svelte';

View File

@@ -1,43 +0,0 @@
/**
* ============================================================================
* UNIFIED FONT STORE TYPES
* ============================================================================
*
* Type definitions for the unified font store infrastructure.
* Provides types for filters, sorting, and fetch parameters.
*/
import type {
FontshareParams,
GoogleFontsParams,
} from '$entities/Font/api';
import type {
FontCategory,
FontProvider,
FontSubset,
} from '$entities/Font/model/types/common';
/**
* Sort configuration
*/
export interface FontSort {
field: 'name' | 'popularity' | 'category' | 'date';
direction: 'asc' | 'desc';
}
/**
* Fetch params for unified API
*/
export interface FetchFontsParams {
providers?: FontProvider[];
categories?: FontCategory[];
subsets?: FontSubset[];
search?: string;
sort?: FontSort;
forceRefetch?: boolean;
}
/**
* Provider-specific params union
*/
export type ProviderParams = GoogleFontsParams | FontshareParams;

View File

@@ -1,29 +0,0 @@
import {
type Filter,
type FilterModel,
createFilter,
} from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity';
import type { FontProvider } from '../types';
import type { CheckboxFilter } from '../types/common';
import type { BaseFontStore } from './baseFontStore.svelte';
import { createFontshareStore } from './fontshareStore.svelte';
import type { ProviderParams } from './types';
export class UnitedFontStore {
private sources: Partial<Record<FontProvider, BaseFontStore<ProviderParams>>>;
filters: SvelteMap<CheckboxFilter, Filter>;
queryValue = $state('');
constructor(initialConfig: Partial<Record<FontProvider, ProviderParams>> = {}) {
this.sources = {
fontshare: createFontshareStore(initialConfig?.fontshare),
};
this.filters = new SvelteMap();
}
get fonts() {
return Object.values(this.sources).map(store => store.fonts).flat();
}
}

View File

@@ -1,34 +0,0 @@
/**
* ============================================================================
* DOMAIN TYPES
* ============================================================================
*/
import type { FontCategory as FontshareFontCategory } from './fontshare';
import type { FontCategory as GoogleFontCategory } from './google';
/**
* Font category
*/
export type FontCategory = GoogleFontCategory | FontshareFontCategory;
/**
* Font provider
*/
export type FontProvider = 'google' | 'fontshare';
/**
* Font subset
*/
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
/**
* Filter state
*/
export interface FontFilters {
providers: FontProvider[];
categories: FontCategory[];
subsets: FontSubset[];
}
export type CheckboxFilter = 'providers' | 'categories' | 'subsets';
export type FilterType = CheckboxFilter | 'searchQuery';

View File

@@ -0,0 +1,166 @@
/**
* 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 {
/** 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';
/**
* Standardized font variant alias
*/
export type UnifiedFontVariant = FontVariant;
/**
* Font style URLs
*/
export interface FontStyleUrls {
/** Regular weight URL */
regular?: string;
/** Italic URL */
italic?: string;
/** Bold weight URL */
bold?: string;
/** Bold italic URL */
boldItalic?: string;
/** Additional variant mapping */
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,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,121 +0,0 @@
/**
* ============================================================================
* GOOGLE FONTS API TYPES
* ============================================================================
*/
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;
/**
* Standard font weights that can appear in Google Fonts API
*/
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
/**
* Italic variant format: e.g., "100italic", "400italic", "700italic"
*/
export type FontWeightItalic = `${FontWeight}italic`;
/**
* All possible font variants in Google Fonts API
* - 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';
/**
* 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

@@ -7,48 +7,23 @@
* 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,
} from './common';
// Google Fonts API types
export type {
FontFiles,
FontItem,
FontVariant,
FontWeight,
FontWeightItalic,
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 +31,5 @@ export type {
FontCollectionSort,
FontCollectionState,
} from './store';
export * from './store/appliedFonts';

View File

@@ -1,89 +0,0 @@
/**
* ============================================================================
* NORMALIZATION TYPES
* ============================================================================
*/
import type {
FontCategory,
FontProvider,
FontSubset,
} from './common';
/**
* Font variant types (standardized)
*/
export type UnifiedFontVariant = string;
/**
* Font style URLs
*/
export interface FontStyleUrls {
/** Regular weight URL */
regular?: string;
/** Italic URL */
italic?: string;
/** Bold weight URL */
bold?: string;
/** Bold italic URL */
boldItalic?: 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;
}
/**
* 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;
/** 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

@@ -8,8 +8,8 @@ import type {
FontCategory,
FontProvider,
FontSubset,
} from './common';
import type { UnifiedFont } from './normalize';
UnifiedFont,
} from './font';
/**
* Font collection state

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,78 @@
<!--
Component: FontApplicator
Loads fonts from fontshare with link tag
- Loads font only if it's not already applied
- Reacts to font load status to show/hide content
- Adds smooth transition when font appears
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion';
import {
type UnifiedFont,
appliedFontsManager,
} from '../../model';
interface Props {
/**
* Font to apply
*/
font: UnifiedFont;
/**
* Font weight
* @default 400
*/
weight?: number;
/**
* CSS classes
*/
className?: string;
/**
* Content snippet
*/
children?: Snippet;
}
let {
font,
weight = 400,
className,
children,
}: Props = $props();
const status = $derived(
appliedFontsManager.getFontStatus(
font.id,
weight,
font.features?.isVariable,
),
);
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
const shouldReveal = $derived(status === 'loaded' || status === 'error');
const transitionClasses = $derived(
prefersReducedMotion.current
? 'transition-none' // Disable CSS transitions if motion is reduced
: 'transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
);
</script>
<div
style:font-family={shouldReveal
? `'${font.name}'`
: 'system-ui, -apple-system, sans-serif'}
class={cn(
transitionClasses,
// If reduced motion is on, we skip the transform/blur entirely
!shouldReveal
&& !prefersReducedMotion.current
&& 'opacity-50 scale-[0.95] blur-sm',
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
shouldReveal && 'opacity-100 scale-100 blur-0',
className,
)}
>
{@render children?.()}
</div>

View File

@@ -1,46 +0,0 @@
<script lang="ts">
import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte';
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
import {
Content as ItemContent,
Root as ItemRoot,
Title as ItemTitle,
} from '$shared/shadcn/ui/item';
import { VirtualList } from '$shared/ui';
/**
* FontList
*
* Displays a virtualized list of fonts with loading, empty, and error states.
* Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
*/
interface FontListProps {
/** Font items to display (defaults to filtered fonts from store) */
fonts?: UnifiedFont[];
/** Show loading state */
loading?: boolean;
/** Show empty state when no results */
showEmpty?: boolean;
/** Custom error message to display */
errorMessage?: string;
}
let {
fonts,
loading,
showEmpty = true,
errorMessage,
}: FontListProps = $props();
// const fontshareStore = getFontshareContext();
</script>
{#each fontshareStore.fonts as font (font.id)}
<ItemRoot>
<ItemContent>
<ItemTitle>{font.name}</ItemTitle>
<span class="text-xs text-muted-foreground">
{font.category}{font.provider}
</span>
</ItemContent>
</ItemRoot>
{/each}

View File

@@ -0,0 +1,130 @@
<!--
Component: FontVirtualList
- Renders a virtualized list of fonts
- Handles font registration with the manager
-->
<script lang="ts">
import {
Skeleton,
VirtualList,
} from '$shared/ui';
import type {
ComponentProps,
Snippet,
} from 'svelte';
import { fade } from 'svelte/transition';
import { getFontUrl } from '../../lib';
import {
type FontLoadRequestConfig,
type UnifiedFont,
appliedFontsManager,
} from '../../model';
import { fontStore } from '../../model/store';
interface Props extends
Omit<
ComponentProps<typeof VirtualList<UnifiedFont>>,
'items' | 'total' | 'isLoading' | 'onVisibleItemsChange' | 'onNearBottom'
>
{
/**
* Visible items callback
*/
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
/**
* Font weight
*/
weight: number;
/**
* Skeleton snippet
*/
skeleton?: Snippet;
}
let {
children,
onVisibleItemsChange,
weight,
skeleton,
...rest
}: Props = $props();
const isLoading = $derived(
fontStore.isFetching || fontStore.isLoading,
);
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
const configs: FontLoadRequestConfig[] = [];
visibleItems.forEach(item => {
const url = getFontUrl(item, weight);
if (url) {
configs.push({
id: item.id,
name: item.name,
weight,
url,
isVariable: item.features?.isVariable,
});
}
});
// Auto-register fonts with the manager
appliedFontsManager.touch(configs);
// Forward the call to any external listener
// onVisibleItemsChange?.(visibleItems);
}
/**
* Load more fonts by moving to the next page
*/
function loadMore() {
if (
!fontStore.pagination.hasMore
|| fontStore.isFetching
) {
return;
}
fontStore.nextPage();
}
/**
* Handle scroll near bottom - auto-load next page
*
* Triggered by VirtualList when the user scrolls within 5 items of the end
* of the loaded items. Only fetches if there are more pages available.
*/
function handleNearBottom(_lastVisibleIndex: number) {
const { hasMore } = fontStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items
if (hasMore && !fontStore.isFetching) {
loadMore();
}
}
</script>
<div class="relative w-full h-full">
{#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()}
</div>
{:else}
<!-- VirtualList persists during pagination - no destruction/recreation -->
<VirtualList
items={fontStore.fonts}
total={fontStore.pagination.total}
isLoading={isLoading}
onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom}
{...rest}
>
{#snippet children(scope)}
{@render children(scope)}
{/snippet}
</VirtualList>
{/if}
</div>

View File

@@ -1,3 +1,7 @@
import FontList from './FontList/FontList.svelte';
import FontApplicator from './FontApplicator/FontApplicator.svelte';
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
export { FontList };
export {
FontApplicator,
FontVirtualList,
};

View File

@@ -0,0 +1,2 @@
export * from './model';
export * from './ui';

View File

@@ -0,0 +1 @@
export { themeManager } from './store/ThemeManager/ThemeManager.svelte';

View File

@@ -0,0 +1,188 @@
/**
* Theme management with system preference detection
*
* Manages light/dark theme state with localStorage persistence
* and automatic system preference detection. Themes are applied
* via CSS class on the document element.
*
* Features:
* - Persists user preference to localStorage
* - Detects OS-level theme preference
* - Responds to OS theme changes when in "system" mode
* - Toggle between light/dark themes
* - Reset to follow system preference
*
* @example
* ```svelte
* <script lang="ts">
* import { themeManager } from '$features/ChangeAppTheme';
*
* // Initialize once on app mount
* onMount(() => themeManager.init());
* onDestroy(() => themeManager.destroy());
* </script>
*
* <button on:click={() => themeManager.toggle()}>
* {themeManager.isDark ? 'Switch to Light' : 'Switch to Dark'}
* </button>
* ```
*/
import { createPersistentStore } from '$shared/lib';
type Theme = 'light' | 'dark';
type ThemeSource = 'system' | 'user';
/**
* Theme manager singleton
*
* Call init() on app mount and destroy() on app unmount.
* Use isDark property to conditionally apply styles.
*/
class ThemeManager {
// Private reactive state
/** Current theme value ('light' or 'dark') */
#theme = $state<Theme>('light');
/** Whether theme is controlled by user or follows system */
#source = $state<ThemeSource>('system');
/** MediaQueryList for detecting system theme changes */
#mediaQuery: MediaQueryList | null = null;
/** Persistent storage for user's theme preference */
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
/** Bound handler for system theme change events */
#systemChangeHandler = this.#onSystemChange.bind(this);
constructor() {
// Derive initial values from stored preference or OS
const stored = this.#store.value;
if (stored === 'dark' || stored === 'light') {
this.#theme = stored;
this.#source = 'user';
} else {
this.#theme = this.#getSystemTheme();
this.#source = 'system';
}
}
/** Current theme value */
get value(): Theme {
return this.#theme;
}
/** Source of current theme ('system' or 'user') */
get source(): ThemeSource {
return this.#source;
}
/** Whether dark theme is active */
get isDark(): boolean {
return this.#theme === 'dark';
}
/** Whether theme is controlled by user (not following system) */
get isUserControlled(): boolean {
return this.#source === 'user';
}
/**
* Initialize theme manager
*
* Applies current theme to DOM and sets up system preference listener.
* Call once in root component onMount.
*/
init(): void {
this.#applyToDom(this.#theme);
this.#mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.#mediaQuery.addEventListener('change', this.#systemChangeHandler);
}
/**
* Clean up theme manager
*
* Removes system preference listener.
* Call in root component onDestroy.
*/
destroy(): void {
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
this.#mediaQuery = null;
}
/**
* Set theme explicitly
*
* Switches to user control and applies specified theme.
*
* @param theme - Theme to apply ('light' or 'dark')
*/
setTheme(theme: Theme): void {
this.#source = 'user';
this.#theme = theme;
this.#store.value = theme;
this.#applyToDom(theme);
}
/**
* Toggle between light and dark themes
*/
toggle(): void {
this.setTheme(this.value === 'dark' ? 'light' : 'dark');
}
/**
* Reset to follow system preference
*
* Clears user preference and switches to system theme.
*/
resetToSystem(): void {
this.#store.clear();
this.#theme = this.#getSystemTheme();
this.#source = 'system';
this.#applyToDom(this.#theme);
}
// Private helpers
/**
* Detect system theme preference
* @returns 'dark' if system prefers dark mode, 'light' otherwise
*/
#getSystemTheme(): Theme {
if (typeof window === 'undefined') {
return 'light';
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/**
* Apply theme to DOM
* @param theme - Theme to apply
*/
#applyToDom(theme: Theme): void {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
/**
* Handle system theme change
* Only updates if currently following system preference
*/
#onSystemChange(e: MediaQueryListEvent): void {
if (this.#source === 'system') {
this.#theme = e.matches ? 'dark' : 'light';
this.#applyToDom(this.#theme);
}
}
}
/**
* Singleton theme manager instance
*
* Use throughout the app for consistent theme state.
*/
export const themeManager = new ThemeManager();
/**
* ThemeManager class exported for testing purposes
* Use the singleton `themeManager` in application code.
*/
export { ThemeManager };

View File

@@ -0,0 +1,726 @@
/** @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;
media: string;
constructor(type: string, eventInitDict: { matches: boolean; media: string }) {
super(type);
this.matches = eventInitDict.matches;
this.media = eventInitDict.media;
}
}
// ============================================================
// NOW IT'S SAFE TO IMPORT
// ============================================================
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { ThemeManager } from './ThemeManager.svelte';
/**
* Test Suite for ThemeManager
*
* Tests theme management functionality including:
* - Initial state from localStorage or system preference
* - Theme setting and persistence
* - Toggle functionality
* - System preference detection and following
* - DOM manipulation for theme application
* - MediaQueryList listener management
*/
// Storage key used by ThemeManager
const STORAGE_KEY = 'glyphdiff:theme';
// Helper type for MediaQueryList event handler
type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void;
// Helper to flush Svelte effects (they run in microtasks)
async function flushEffects() {
await Promise.resolve();
}
describe('ThemeManager', () => {
let classListMock: DOMTokenList;
let darkClassAdded = false;
let mediaQueryListeners: Map<string, Set<MediaQueryListCallback>> = new Map();
let matchMediaSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
// Reset tracking variables
darkClassAdded = false;
mediaQueryListeners.clear();
// Clear localStorage before each test
localStorage.clear();
// Mock documentElement.classList
classListMock = {
contains: (className: string) => className === 'dark' ? darkClassAdded : false,
add: vi.fn((..._classNames: string[]) => {
darkClassAdded = true;
}),
remove: vi.fn((..._classNames: string[]) => {
darkClassAdded = false;
}),
toggle: vi.fn((className: string, force?: boolean) => {
if (className === 'dark') {
if (force !== undefined) {
darkClassAdded = force;
} else {
darkClassAdded = !darkClassAdded;
}
return darkClassAdded;
}
return false;
}),
supports: vi.fn(() => true),
entries: vi.fn(() => []),
forEach: vi.fn(),
keys: vi.fn(() => []),
values: vi.fn(() => []),
length: 0,
item: vi.fn(() => null),
replace: vi.fn(() => false),
} as unknown as DOMTokenList;
// Mock document.documentElement
if (typeof document !== 'undefined' && document.documentElement) {
Object.defineProperty(document.documentElement, 'classList', {
configurable: true,
get: () => classListMock,
});
}
// Mock window.matchMedia with spy to track listeners
matchMediaSpy = vi.fn((query: string) => {
// Default to light theme (matches = false)
const mediaQueryList = {
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
if (!mediaQueryListeners.has(query)) {
mediaQueryListeners.set(query, new Set());
}
mediaQueryListeners.get(query)!.add(listener);
}),
removeEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
if (mediaQueryListeners.has(query)) {
mediaQueryListeners.get(query)!.delete(listener);
}
}),
dispatchEvent: vi.fn(),
};
return mediaQueryList;
});
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: matchMediaSpy,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
/**
* Helper to trigger a MediaQueryList change event
*/
function triggerSystemThemeChange(isDark: boolean) {
const query = '(prefers-color-scheme: dark)';
const listeners = mediaQueryListeners.get(query);
if (listeners) {
const event = new MockMediaQueryListEvent('change', {
matches: isDark,
media: query,
});
listeners.forEach(listener => listener.call({ matches: isDark, media: query } as MediaQueryList, event));
}
}
describe('Constructor - Initial State', () => {
it('should initialize with light theme when localStorage is empty and system prefers light', () => {
const manager = new ThemeManager();
expect(manager.value).toBe('light');
expect(manager.isDark).toBe(false);
expect(manager.source).toBe('system');
});
it('should initialize with system dark theme when localStorage is empty', () => {
// Mock system prefers dark theme
matchMediaSpy.mockImplementation((query: string) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
const manager = new ThemeManager();
expect(manager.value).toBe('dark');
expect(manager.isDark).toBe(true);
expect(manager.source).toBe('system');
});
it('should initialize with stored light theme from localStorage', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
const manager = new ThemeManager();
expect(manager.value).toBe('light');
expect(manager.isDark).toBe(false);
expect(manager.source).toBe('user');
});
it('should initialize with stored dark theme from localStorage', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
expect(manager.value).toBe('dark');
expect(manager.isDark).toBe(true);
expect(manager.source).toBe('user');
});
it('should ignore invalid values in localStorage and use system theme', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('invalid'));
const manager = new ThemeManager();
expect(manager.value).toBe('light');
expect(manager.source).toBe('system');
});
it('should handle null in localStorage as system theme', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(null));
const manager = new ThemeManager();
expect(manager.value).toBe('light');
expect(manager.source).toBe('system');
});
it('should be in user-controlled mode when localStorage has a valid theme', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
expect(manager.isUserControlled).toBe(true);
});
it('should not be in user-controlled mode when following system', () => {
const manager = new ThemeManager();
expect(manager.isUserControlled).toBe(false);
expect(manager.source).toBe('system');
});
});
describe('init() - Initialization', () => {
it('should apply initial theme to DOM on init', () => {
const manager = new ThemeManager();
manager.init();
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
});
it('should apply dark theme to DOM when initialized with dark theme', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.init();
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
});
it('should set up MediaQueryList listener on init', () => {
const manager = new ThemeManager();
manager.init();
expect(matchMediaSpy).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
});
it('should not fail if called multiple times', () => {
const manager = new ThemeManager();
expect(() => {
manager.init();
manager.init();
}).not.toThrow();
});
});
describe('destroy() - Cleanup', () => {
it('should remove MediaQueryList listener on destroy', () => {
const manager = new ThemeManager();
manager.init();
manager.destroy();
const listeners = mediaQueryListeners.get('(prefers-color-scheme: dark)');
expect(listeners?.size ?? 0).toBe(0);
});
it('should not fail if destroy is called before init', () => {
const manager = new ThemeManager();
expect(() => {
manager.destroy();
}).not.toThrow();
});
it('should not fail if destroy is called multiple times', () => {
const manager = new ThemeManager();
manager.init();
expect(() => {
manager.destroy();
manager.destroy();
}).not.toThrow();
});
});
describe('setTheme() - Set Explicit Theme', () => {
it('should set theme to light and update source to user', () => {
const manager = new ThemeManager();
manager.setTheme('light');
expect(manager.value).toBe('light');
expect(manager.isDark).toBe(false);
expect(manager.source).toBe('user');
});
it('should set theme to dark and update source to user', () => {
const manager = new ThemeManager();
manager.setTheme('dark');
expect(manager.value).toBe('dark');
expect(manager.isDark).toBe(true);
expect(manager.source).toBe('user');
});
it('should save theme to localStorage when set', async () => {
const manager = new ThemeManager();
manager.setTheme('dark');
await flushEffects();
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
});
it('should apply theme to DOM when set', () => {
const manager = new ThemeManager();
manager.setTheme('dark');
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
});
it('should overwrite existing localStorage value', async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
const manager = new ThemeManager();
manager.setTheme('dark');
await flushEffects();
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
});
it('should handle switching from light to dark', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
const manager = new ThemeManager();
manager.init();
manager.setTheme('dark');
expect(manager.value).toBe('dark');
expect(manager.source).toBe('user');
});
it('should handle switching from dark to light', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.init();
manager.setTheme('light');
expect(manager.value).toBe('light');
expect(manager.source).toBe('user');
});
});
describe('toggle() - Toggle Between Themes', () => {
it('should toggle from light to dark', () => {
const manager = new ThemeManager();
manager.toggle();
expect(manager.value).toBe('dark');
expect(manager.isDark).toBe(true);
expect(manager.source).toBe('user');
});
it('should toggle from dark to light', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.toggle();
expect(manager.value).toBe('light');
expect(manager.isDark).toBe(false);
expect(manager.source).toBe('user');
});
it('should save toggled theme to localStorage', async () => {
const manager = new ThemeManager();
manager.toggle();
await flushEffects();
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
});
it('should apply toggled theme to DOM', () => {
const manager = new ThemeManager();
manager.toggle();
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
});
it('should handle multiple rapid toggles', () => {
const manager = new ThemeManager();
manager.toggle();
expect(manager.value).toBe('dark');
manager.toggle();
expect(manager.value).toBe('light');
manager.toggle();
expect(manager.value).toBe('dark');
});
});
describe('resetToSystem() - Reset to System Preference', () => {
it('should clear localStorage when resetting to system', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.resetToSystem();
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('should set source to system after reset', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.resetToSystem();
expect(manager.source).toBe('system');
expect(manager.isUserControlled).toBe(false);
});
it('should detect and apply light system theme', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.resetToSystem();
expect(manager.value).toBe('light');
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
});
it('should detect and apply dark system theme', () => {
// Override matchMedia to return dark preference
matchMediaSpy.mockImplementation((query: string) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
const manager = new ThemeManager();
manager.resetToSystem();
expect(manager.value).toBe('dark');
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
});
it('should apply system theme to DOM on reset', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.resetToSystem();
expect(classListMock.toggle).toHaveBeenCalled();
});
});
describe('System Theme Change Handling', () => {
it('should update theme when system changes to dark while following system', () => {
const manager = new ThemeManager();
manager.init();
triggerSystemThemeChange(true);
expect(manager.value).toBe('dark');
expect(manager.isDark).toBe(true);
});
it('should update theme when system changes to light while following system', () => {
// Start with dark system theme
// Keep the listener tracking while overriding matches behavior
matchMediaSpy.mockImplementation((query: string) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
if (!mediaQueryListeners.has(query)) {
mediaQueryListeners.set(query, new Set());
}
mediaQueryListeners.get(query)!.add(listener);
}),
removeEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
if (mediaQueryListeners.has(query)) {
mediaQueryListeners.get(query)!.delete(listener);
}
}),
dispatchEvent: vi.fn(),
}));
const manager = new ThemeManager();
manager.init();
expect(manager.value).toBe('dark');
// Now change to light
triggerSystemThemeChange(false);
expect(manager.value).toBe('light');
expect(manager.isDark).toBe(false);
});
it('should update DOM when system theme changes while following system', () => {
const manager = new ThemeManager();
manager.init();
triggerSystemThemeChange(true);
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
});
it('should NOT update theme when system changes if user has set theme', () => {
const manager = new ThemeManager();
manager.setTheme('light'); // User explicitly sets light
manager.init();
// Simulate system changing to dark
triggerSystemThemeChange(true);
// Theme should remain light because user set it
expect(manager.value).toBe('light');
expect(manager.source).toBe('user');
});
it('should respond to system changes after resetToSystem', () => {
// Start with user-controlled dark theme
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.init();
expect(manager.value).toBe('dark');
expect(manager.source).toBe('user');
// Reset to system (which is light)
manager.resetToSystem();
expect(manager.value).toBe('light');
expect(manager.source).toBe('system');
// Now system changes to dark
triggerSystemThemeChange(true);
// Should update because we're back to following system
expect(manager.value).toBe('dark');
expect(manager.source).toBe('system');
});
it('should stop responding to system changes after setTheme is called', () => {
const manager = new ThemeManager();
manager.init();
// System changes to dark
triggerSystemThemeChange(true);
expect(manager.value).toBe('dark');
expect(manager.source).toBe('system');
// User explicitly sets light
manager.setTheme('light');
expect(manager.value).toBe('light');
expect(manager.source).toBe('user');
// System changes again
triggerSystemThemeChange(false);
// Should stay light because user set it
expect(manager.value).toBe('light');
});
it('should not trigger updates after destroy is called', () => {
const manager = new ThemeManager();
manager.init();
manager.destroy();
// This should not cause any updates since listener was removed
expect(() => {
triggerSystemThemeChange(true);
}).not.toThrow();
});
});
describe('DOM Interaction', () => {
it('should add dark class when applying dark theme', () => {
const manager = new ThemeManager();
manager.init();
manager.setTheme('dark');
// Check toggle was called with force=true for dark
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
});
it('should remove dark class when applying light theme', () => {
// Start with dark
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
manager.init();
// Switch to light
manager.setTheme('light');
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
});
it('should not add dark class when system prefers light', () => {
const manager = new ThemeManager();
manager.init();
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
});
});
describe('Getter Properties', () => {
it('value getter should return current theme', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
const manager = new ThemeManager();
expect(manager.value).toBe('dark');
});
it('source getter should return "user" when theme is user-controlled', () => {
const manager = new ThemeManager();
manager.setTheme('dark');
expect(manager.source).toBe('user');
});
it('source getter should return "system" when following system', () => {
const manager = new ThemeManager();
expect(manager.source).toBe('system');
});
it('isDark getter should return true for dark theme', () => {
const manager = new ThemeManager();
manager.setTheme('dark');
expect(manager.isDark).toBe(true);
});
it('isDark getter should return false for light theme', () => {
const manager = new ThemeManager();
expect(manager.isDark).toBe(false);
});
it('isUserControlled getter should return true when source is user', () => {
const manager = new ThemeManager();
manager.setTheme('light');
expect(manager.isUserControlled).toBe(true);
});
it('isUserControlled getter should return false when source is system', () => {
const manager = new ThemeManager();
expect(manager.isUserControlled).toBe(false);
});
});
describe('Edge Cases', () => {
it('should handle rapid setTheme calls', async () => {
const manager = new ThemeManager();
manager.setTheme('dark');
manager.setTheme('light');
manager.setTheme('dark');
manager.setTheme('light');
await flushEffects();
expect(manager.value).toBe('light');
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('light'));
});
it('should handle toggle immediately followed by setTheme', async () => {
const manager = new ThemeManager();
manager.toggle();
manager.setTheme('light');
await flushEffects();
expect(manager.value).toBe('light');
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('light'));
});
it('should handle setTheme immediately followed by resetToSystem', () => {
const manager = new ThemeManager();
manager.setTheme('dark');
manager.resetToSystem();
expect(manager.value).toBe('light');
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('should handle resetToSystem when already following system', () => {
const manager = new ThemeManager();
manager.resetToSystem();
expect(manager.value).toBe('light');
expect(manager.source).toBe('system');
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
});
describe('Type Safety', () => {
it('should accept "light" as valid theme', () => {
const manager = new ThemeManager();
expect(() => manager.setTheme('light')).not.toThrow();
});
it('should accept "dark" as valid theme', () => {
const manager = new ThemeManager();
expect(() => manager.setTheme('dark')).not.toThrow();
});
});
});

View File

@@ -0,0 +1,44 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import ThemeSwitch from './ThemeSwitch.svelte';
const { Story } = defineMeta({
title: 'Features/ThemeSwitch',
component: ThemeSwitch,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Theme toggle button that switches between light and dark modes. Uses ThemeManager to persist user preference and sync with system preference. Displays sun/moon icon based on current theme.',
},
story: { inline: false },
},
},
argTypes: {
// ThemeSwitch has no explicit props - it uses themeManager internally
},
});
</script>
<script lang="ts">
import { themeManager } from '$features/ChangeAppTheme';
// Current theme state for display
const currentTheme = $derived(themeManager.value);
const themeSource = $derived(themeManager.source);
</script>
<Story name="Default">
<div class="flex items-center justify-center p-8 gap-4">
<ThemeSwitch />
<div class="text-sm text-muted-foreground">
Theme: <span class="font-semibold">{currentTheme}</span>
{#if themeSource === 'user'}
<span class="text-xs ml-2">(user preference)</span>
{:else}
<span class="text-xs ml-2">(system preference)</span>
{/if}
</div>
</div>
</Story>

View File

@@ -0,0 +1,26 @@
<!--
Component: ThemeSwitch
Toggles the theme between light and dark mode.
-->
<script lang="ts">
import type { ResponsiveManager } from '$shared/lib';
import { IconButton } from '$shared/ui';
import MoonIcon from '@lucide/svelte/icons/moon';
import SunIcon from '@lucide/svelte/icons/sun';
import { getContext } from 'svelte';
import { themeManager } from '../../model';
const responsive = getContext<ResponsiveManager>('responsive');
const theme = $derived(themeManager.value);
</script>
<IconButton onclick={() => themeManager.toggle()} size={responsive.isMobile ? 'sm' : 'md'} title="Toggle theme">
{#snippet icon()}
{#if theme === 'light'}
<MoonIcon class={responsive.isMobile ? 'size-4' : 'size-5'} />
{:else}
<SunIcon class={responsive.isMobile ? 'size-4' : 'size-5'} />
{/if}
{/snippet}
</IconButton>

View File

@@ -0,0 +1 @@
export { default as ThemeSwitch } from './ThemeSwitch/ThemeSwitch.svelte';

View File

@@ -0,0 +1 @@
export { FontSampler } from './ui';

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