Compare commits

...

692 Commits

Author SHA1 Message Date
Ilia Mashkov
dde187e0b2 chore: move ControlId type to the entities/Font layer 2026-04-16 11:19:17 +03:00
Ilia Mashkov
5a7c61ade7 feat(FontVirtualList): re-touch on weight change and pin visible fonts 2026-04-16 11:05:09 +03:00
Ilia Mashkov
d2bce85f9c feat(ComparisonStore): pin fontA/fontB to prevent eviction while on-screen 2026-04-16 10:55:41 +03:00
Ilia Mashkov
e509463911 chore: remove unused 2026-04-16 09:07:46 +03:00
Ilia Mashkov
db08f523f6 chore: move typography constants to the entity/Font layer 2026-04-16 09:05:34 +03:00
Ilia Mashkov
c5fa159c14 fix(FontList): remove weight prop, use default weight for FontList 2026-04-16 08:51:18 +03:00
Ilia Mashkov
8645c7dcc8 feat: use typographySettingsStore everywhere for the typography settings 2026-04-16 08:44:49 +03:00
Ilia Mashkov
fbeb84270b feat(Layout): remove breadcrumbs 2026-04-16 08:40:16 +03:00
Ilia Mashkov
c1ac9b5bc4 chore(SetupFont): rename controlManager to typographySettingsStore for better semantic 2026-04-16 08:22:08 +03:00
46d0d887b1 Merge pull request 'feature/unified-tanstack-query' (#36) from feature/unified-tanstack-query into main
All checks were successful
Workflow / build (push) Successful in 45s
Workflow / publish (push) Successful in 47s
Reviewed-on: #36
2026-04-16 04:53:28 +00:00
Ilia Mashkov
0a489a8adc fix(BaseQueryStore): use QueryObserverOptions instead of QueryOptions
All checks were successful
Workflow / build (pull_request) Successful in 58s
Workflow / publish (pull_request) Has been skipped
QueryOptions has queryKey as optional; QueryObserverOptions requires it,
matching what QueryObserver.constructor and setOptions actually expect.
2026-04-15 22:37:30 +03:00
Ilia Mashkov
cd349aec92 fix: imports 2026-04-15 22:32:45 +03:00
Ilia Mashkov
adaa6d7648 feat: refactor ComparisonStore to use BatchFontStore
Replace hand-rolled async fetching (fetchFontsByIds + isRestoring flag)
with BatchFontStore backed by TanStack Query. Three reactive effects
handle batch sync, CSS font loading, and default-font fallback.
isLoading now derives from batchStore.isLoading + fontsReady.
2026-04-15 22:25:34 +03:00
Ilia Mashkov
10f4781a67 test: enrich coverage for queryKeys, BaseQueryStore, and BatchFontStore
- queryKeys: add mutation-safety test for batch(), key hierarchy tests
  (list/batch/detail keys rooted in their parent base keys), and
  unique-key test for different detail IDs
- BaseQueryStore: add initial-state test (data undefined, isError false
  before any fetch resolves)
- BatchFontStore: add FontResponseError type assertion on malformed
  response, null error assertion on success, and setIds([]) disables
  query and returns empty fonts without triggering a fetch
2026-04-15 15:59:01 +03:00
Ilia Mashkov
f4a568832a feat: implement reactive BatchFontStore 2026-04-15 12:29:16 +03:00
Ilia Mashkov
4e9670118a feat: add seedFontCache utility 2026-04-15 12:21:04 +03:00
Ilia Mashkov
8e88d1b7cf feat: add BaseQueryStore for reactive query observers 2026-04-15 12:19:25 +03:00
Ilia Mashkov
1cbc262af7 feat: add stable query key factory 2026-04-15 12:06:32 +03:00
f072c5b270 Merge pull request 'fix/initial-fonts-loading' (#35) from fix/initial-fonts-loading into main
All checks were successful
Workflow / build (push) Successful in 46s
Workflow / publish (push) Successful in 45s
Reviewed-on: #35
2026-04-15 08:37:40 +00:00
Ilia Mashkov
bfa99cde20 fix(comparisonStore): add missing batch request and effect for initial font loading
All checks were successful
Workflow / build (pull_request) Successful in 3m8s
Workflow / publish (pull_request) Has been skipped
2026-04-15 11:35:37 +03:00
Ilia Mashkov
75b62265be fix: add missing export 2026-04-15 09:13:22 +03:00
5b81be6614 Merge pull request 'feature/pretext' (#34) from feature/pretext into main
Some checks failed
Workflow / build (push) Failing after 36s
Workflow / publish (push) Has been skipped
Reviewed-on: #34
2026-04-14 07:12:41 +00:00
Ilia Mashkov
a74abbb0b3 feat: wire createFontRowSizeResolver into SampleList for pretext-backed row heights
Some checks failed
Workflow / build (pull_request) Failing after 49s
Workflow / publish (pull_request) Has been skipped
2026-04-13 13:23:03 +03:00
Ilia Mashkov
20accb9c93 feat: implement createFontRowSizeResolver with canvas-measured heights and reactive status check 2026-04-13 08:54:19 +03:00
Ilia Mashkov
46b9db1db3 feat: export ItemSizeResolver type and document reactive estimateSize contract 2026-04-12 19:43:44 +03:00
Ilia Mashkov
4b017a83bb fix: add missing JSDoc, return types, and as-any comments to layout engines 2026-04-12 09:51:36 +03:00
Ilia Mashkov
49822f8af7 feat: install pretext library 2026-04-12 09:08:01 +03:00
Ilia Mashkov
338ca9b4fd feat: export TextLayoutEngine and CharacterComparisonEngine from shared helpers index
Remove deleted createCharacterComparison exports and benchmark.
2026-04-11 16:44:49 +03:00
Ilia Mashkov
99f662e2d5 fix: iterate pre-computed chars array in Line.svelte to fix unicode grapheme splitting bug 2026-04-11 16:26:41 +03:00
Ilia Mashkov
5977e0a0dc fix: correct advances null-check in CharacterComparisonEngine and remove unused TextLayoutEngine dep 2026-04-11 16:14:28 +03:00
Ilia Mashkov
2b0d8470e5 test: fix CharacterComparisonEngine tests — correct env directive, canvas mock, and full spec coverage 2026-04-11 16:14:24 +03:00
Ilia Mashkov
351ee9fd52 docs: add inline documentation to TextLayoutEngine 2026-04-11 16:10:01 +03:00
Ilia Mashkov
a526a51af8 test: fix TextLayoutEngine tests — correct jsdom directive placement and canvas mock setup
fix: correct grapheme-width fallback in TextLayoutEngine for null breakableFitAdvances
2026-04-11 15:48:52 +03:00
Ilia Mashkov
fcde78abad test: add canvas mock helper for pretext-based engine tests 2026-04-11 15:48:47 +03:00
26737f2f11 Merge pull request 'chore/purge-unused' (#33) from chore/purge-unused into main
All checks were successful
Workflow / build (push) Successful in 45s
Workflow / publish (push) Successful in 23s
Reviewed-on: #33
2026-04-10 14:31:27 +00:00
Ilia Mashkov
d9fa2bc501 refactor: consolidate font domain and model types into font.ts
All checks were successful
Workflow / build (pull_request) Successful in 59s
Workflow / publish (pull_request) Has been skipped
2026-04-10 17:29:15 +03:00
Ilia Mashkov
5f38996665 chore: purge legacy font provider types and normalization logic 2026-04-10 16:05:57 +03:00
d70fc9f918 Merge pull request 'feat/font-store-merge' (#32) from feat/font-store-merge into main
All checks were successful
Workflow / build (push) Successful in 43s
Workflow / publish (push) Successful in 21s
Reviewed-on: #32
2026-04-10 05:13:39 +00:00
Ilia Mashkov
14dbd374ec refactor: replace unifiedFontStore with fontStore in comparisonStore tests
All checks were successful
Workflow / build (pull_request) Successful in 1m1s
Workflow / publish (pull_request) Has been skipped
2026-04-10 08:06:51 +03:00
Ilia Mashkov
dc6e15492a test: mock fontStore and update FontStore type signatures 2026-04-09 19:40:31 +03:00
Ilia Mashkov
45eac0c396 refactor: delete BaseFontStore and UnifiedFontStore — FontStore is the single implementation 2026-04-08 10:07:36 +03:00
Ilia Mashkov
ed7d31bf5c refactor: migrate all callers from unifiedFontStore to fontStore 2026-04-08 10:00:30 +03:00
Ilia Mashkov
468d2e7f8c feat(FontStore): export through entity barrel files 2026-04-08 09:55:40 +03:00
Ilia Mashkov
2a761b9d47 feat(FontStore): implement lifecycle, param management, async methods, shortcuts, pagination, category getters, singleton — all tests green 2026-04-08 09:54:27 +03:00
Ilia Mashkov
a9e4633b64 feat(FontStore): implement fetchPage with error wrapping 2026-04-08 09:50:16 +03:00
Ilia Mashkov
778988977f feat(FontStore): implement state getters, pagination, buildQueryKey, buildOptions 2026-04-08 09:47:25 +03:00
Ilia Mashkov
9a9ff95bf3 test(FontStore): write full TDD spec and empty shell (InfiniteQueryObserver) 2026-04-08 09:43:29 +03:00
Ilia Mashkov
7517678e87 chore: add .worktrees to .gitignore for isolated development 2026-04-08 09:37:47 +03:00
4281d94d66 Merge pull request 'refactor/code-splitting' (#31) from refactor/code-splitting into main
All checks were successful
Workflow / build (push) Successful in 44s
Workflow / publish (push) Successful in 42s
Reviewed-on: #31
2026-04-08 06:34:19 +00:00
Ilia Mashkov
752e38adf9 test: full test coverage of baseFontStore and unifiedFontStore
All checks were successful
Workflow / build (pull_request) Successful in 55s
Workflow / publish (pull_request) Has been skipped
2026-04-08 09:33:04 +03:00
Ilia Mashkov
9c538069e4 test(UnifiedFontStore): add isEmpty and destroy tests 2026-04-06 12:26:08 +03:00
Ilia Mashkov
71fed58af9 test(UnifiedFontStore): add category getter tests 2026-04-06 12:24:23 +03:00
Ilia Mashkov
fee3355a65 test(UnifiedFontStore): add filter change reset tests 2026-04-06 12:19:49 +03:00
Ilia Mashkov
2ff7f1a13d test(UnifiedFontStore): add filter setter tests 2026-04-06 11:35:56 +03:00
Ilia Mashkov
6bf1b1ea87 test(UnifiedFontStore): add pagination navigation tests 2026-04-06 11:34:53 +03:00
Ilia Mashkov
3ef012eb43 test(UnifiedFontStore): add pagination state tests 2026-04-06 11:34:03 +03:00
Ilia Mashkov
5df60b236c test(UnifiedFontStore): cover fetchFn typed error paths and error getter 2026-04-05 15:20:15 +03:00
Ilia Mashkov
df3c694909 feat(UnifiedFontStore): throw FontNetworkError and FontResponseError in fetchFn 2026-04-05 14:07:26 +03:00
Ilia Mashkov
a1a1fcf39d feat(BaseFontStore): expose error getter 2026-04-05 11:03:00 +03:00
Ilia Mashkov
b40e651be4 refactor(Font/model): move baseFontStore and unifiedFontStore to subdirectories, rename errors/index to errors/errors 2026-04-05 11:02:42 +03:00
Ilia Mashkov
9427f4e50f feat(Font): re-export FontNetworkError and FontResponseError from entity barrel 2026-04-05 09:33:58 +03:00
Ilia Mashkov
ed9791c176 feat(Font/lib): add FontNetworkError and FontResponseError 2026-04-05 09:04:47 +03:00
Ilia Mashkov
c6dabafd93 chore(appliedFontsStore): move FontBufferCache, FontEvicionPolicy and FontLoadQueue to appliedFontsStore/utils 2026-04-05 08:25:05 +03:00
Ilia Mashkov
e88cca9289 test(FontBufferCache): change mock fetcher type 2026-04-04 16:43:54 +03:00
Ilia Mashkov
d4cf6764b4 test(appliedFontsStore): rewrite tests with describe grouping and full coverage 2026-04-04 10:38:20 +03:00
Ilia Mashkov
5a065ae5a1 refactor: extract #fetchChunk, replace Promise.allSettled with self-describing results 2026-04-04 09:58:41 +03:00
Ilia Mashkov
20110168f2 refactor: extract #processFont and #scheduleProcessing from touch and #processQueue 2026-04-04 09:52:45 +03:00
Ilia Mashkov
f88729cc77 fix: guard AbortError from retry counting; eviction policy removes stale keys 2026-04-04 09:40:21 +03:00
Ilia Mashkov
d21de1bf78 chore(appliedFontsStore): use created collaborators classes 2026-04-03 16:09:10 +03:00
Ilia Mashkov
bc4ab58644 fix(buildQueryString): change the way the searchParams built 2026-04-03 16:08:15 +03:00
Ilia Mashkov
37e0c29788 refactor: loadFont throws FontParseError instead of re-throwing raw error 2026-04-03 15:42:08 +03:00
Ilia Mashkov
46ce0f7aab feat: extract FontBufferCache with injectable fetcher 2026-04-03 15:24:14 +03:00
Ilia Mashkov
128f341399 feat: extract FontEvictionPolicy with TTL and pin/unpin 2026-04-03 15:06:01 +03:00
Ilia Mashkov
64b97794a6 feat: extract FontLoadQueue with retry tracking 2026-04-03 15:01:36 +03:00
Ilia Mashkov
d6eb02bb28 feat: add FontFetchError and FontParseError typed errors 2026-04-03 14:44:06 +03:00
Ilia Mashkov
a711e4e12a chore(appliedFontsStore): move generateFontKey into separate function and cover it with tests 2026-04-03 12:50:50 +03:00
Ilia Mashkov
05e4c082ed feat(appliedFontsStore): move font loading logic into loadFont function and cover it with tests 2026-04-03 12:29:48 +03:00
Ilia Mashkov
b602b5022b chore(appliedFontsStore): move the FontLoadRequestConfig type and other types from appliedFontsStore into types directory 2026-04-03 12:25:38 +03:00
Ilia Mashkov
5249d88df7 feat(appliedFontsStore): create separate yieldToMainThread function with proper tests 2026-04-03 11:05:29 +03:00
Ilia Mashkov
e553cf1f10 feat(appliedFontsStore): create separate getEffectiveConcurrency function with proper tests 2026-04-03 11:03:48 +03:00
Ilia Mashkov
0fdded79d7 test: change globals to true to use vitest tools without importing them 2026-04-03 11:02:17 +03:00
8dbfde882f Merge pull request 'fix(appliedFontsStore): solve ttl based fonts purge by adding cache for on-screen fonts' (#30) from fix/ttl-based-purge into main
All checks were successful
Workflow / build (push) Successful in 44s
Workflow / publish (push) Successful in 43s
Reviewed-on: #30
2026-04-03 06:38:00 +00:00
Ilia Mashkov
a6c8b50cea fix(appliedFontsStore): solve ttl based fonts purge by adding cache for on-screen fonts
All checks were successful
Workflow / build (pull_request) Successful in 3m45s
Workflow / publish (pull_request) Has been skipped
2026-04-03 09:35:16 +03:00
Ilia Mashkov
11c4750d0e chore: update gitignore
All checks were successful
Workflow / build (push) Successful in 51s
Workflow / publish (push) Successful in 26s
2026-03-04 16:53:53 +03:00
03917cf947 Merge pull request 'chore: change hex colors to tailwind bariables' (#29) from chore/color-variables into main
Some checks failed
Workflow / publish (push) Has been cancelled
Workflow / build (push) Has been cancelled
Reviewed-on: #29
2026-03-04 13:52:53 +00:00
Ilia Mashkov
9b90080c57 chore: change hex colors to tailwind bariables
All checks were successful
Workflow / build (pull_request) Successful in 3m29s
Workflow / publish (pull_request) Has been skipped
2026-03-04 16:51:49 +03:00
9c6ff3859a Merge pull request 'feature/project-redesign' (#28) from feature/project-redesign into main
All checks were successful
Workflow / build (push) Successful in 51s
Workflow / publish (push) Successful in 49s
Reviewed-on: #28
2026-03-02 19:46:38 +00:00
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
3302e4a012 Merge pull request 'feature/fetch-fonts' (#14) from feature/fetch-fonts into main
Some checks failed
Build / build (push) Failing after 38s
Deploy Pipeline / pipeline (push) Failing after 39s
Lint / Lint Code (push) Failing after 30s
Test / Svelte Checks (push) Failing after 36s
Reviewed-on: #14
2026-01-14 11:01:43 +00:00
Ilia Mashkov
f730dbc782 fix(workflow): change scripts
Some checks failed
Lint / Lint Code (push) Failing after 30s
Test / Svelte Checks (push) Failing after 35s
Build / build (pull_request) Failing after 1m28s
Lint / Lint Code (pull_request) Failing after 39s
Test / Svelte Checks (pull_request) Failing after 44s
2026-01-14 12:58:52 +03:00
Ilia Mashkov
8b704f1f82 fix(workflow): change the yarn install flags
Some checks failed
Test / Svelte Checks (push) Failing after 33s
Lint / Lint Code (push) Failing after 29s
2026-01-14 12:40:56 +03:00
Ilia Mashkov
36ed19e195 fix(workflow): yarn cache path 2026-01-14 12:39:30 +03:00
Ilia Mashkov
b209e051e5 fix(workflow): yarn cache path
Some checks failed
Lint / Lint Code (push) Failing after 11s
Test / Svelte Checks (push) Failing after 11s
2026-01-14 12:34:10 +03:00
Ilia Mashkov
f49e116408 fix(workflow): change node version
Some checks failed
Lint / Lint Code (push) Failing after 18s
Test / Svelte Checks (push) Failing after 11s
2026-01-14 12:25:14 +03:00
Ilia Mashkov
8d1d1cd60f chore: import/export changes due to code move
Some checks failed
Test / Svelte Checks (push) Failing after 5s
Lint / Lint Code (push) Failing after 1m48s
2026-01-13 20:11:58 +03:00
Ilia Mashkov
fb5c15ec32 fix: minor changes 2026-01-13 20:11:18 +03:00
Ilia Mashkov
955cc66916 feat: new version of unifiedFontStore 2026-01-13 20:10:44 +03:00
Ilia Mashkov
a9cdd15787 feat(GetFonts): separated types for filters 2026-01-13 20:10:20 +03:00
Ilia Mashkov
76172aaa6b fix: minor changes 2026-01-13 20:09:30 +03:00
Ilia Mashkov
7146328982 feat(mapManagerToParams): create mapper to transform filter values to query param values 2026-01-13 20:08:46 +03:00
Ilia Mashkov
52ecb9e304 fix: remove searchQuery from FilterModel 2026-01-13 20:07:42 +03:00
Ilia Mashkov
30cb9ada1a fix(Font): refresh types 2026-01-13 20:06:58 +03:00
Ilia Mashkov
4eeb43fa34 chore: delete unused code 2026-01-13 20:05:33 +03:00
Ilia Mashkov
ad6ba4f0a0 feat: add query provider to App.svelte 2026-01-13 20:04:39 +03:00
Ilia Mashkov
170c8546d3 chore: import/export changes due to code move 2026-01-13 20:04:02 +03:00
Ilia Mashkov
2f15148cdb feat(VirtualList): add overscan support 2026-01-13 20:02:50 +03:00
Ilia Mashkov
a29b80efbb feature: Create BaseFontStore class with Tanstack query logic and FontshareStore, GoogleFontsStore based on it 2026-01-13 20:02:20 +03:00
Ilia Mashkov
91451f7886 chore: import/export fixes due to code move 2026-01-13 20:00:36 +03:00
Ilia Mashkov
99d4b4e29a chore: rename FetchFonts to GetFonts 2026-01-13 19:59:07 +03:00
Ilia Mashkov
d9d45bf9fb chore: move Filters and Controls to GetFont feature 2026-01-13 19:57:22 +03:00
Ilia Mashkov
4810c2b228 chore: delete unused code 2026-01-13 19:56:20 +03:00
Ilia Mashkov
4c9b9f631f fix: minor type changes for fonts 2026-01-13 19:54:56 +03:00
Ilia Mashkov
5fcb381b11 chore(normalize): move font api responce normalization functions to lib 2026-01-13 19:53:26 +03:00
Ilia Mashkov
e098da2dbb feat(filterManager): add debouced state support and move manager 2026-01-13 19:52:36 +03:00
Ilia Mashkov
1a76e9387a feat(createDebouncedState): create helper for managing state with debounce 2026-01-13 19:51:41 +03:00
Ilia Mashkov
0f1eb489ed feat: add query provider for Tanstack 2026-01-13 19:49:51 +03:00
Ilia Mashkov
6e8376b8fc fix(arch): move unifiedFontStore context creation to Layout.svelte
- Moved unifiedFontStore creation from Page.svelte to Layout.svelte
- Layout now creates store instance and provides it via setContext()
- Page.svelte now receives store via getContext() instead of creating it
- Fixes context accessibility issue where FiltersSidebar and FontSearch
  (siblings of Page) could not access the store
- All child components now share the same store instance at Layout level

This resolves the architectural issue where context only flows downward,
not sideways. All components (FiltersSidebar, FontSearch, Page) are now
children of Layout and can access the unifiedFontStore context.
2026-01-12 08:51:36 +03:00
Ilia Mashkov
d81af0a77b feat: implement P0/P1 performance and code quality optimizations
P0 Performance Optimizations:
- Add debounced search (300ms) to reduce re-renders during typing
- Implement single-pass filter function for O(n) complexity
- Add TanStack Query cancellation before new requests

P1 Code Quality Optimizations:
- Add runtime type guards for filter validation
- Implement two derived values (filteredFonts + sortedFilteredFonts)
- Remove all 'as any[]' casts from filter bridge
- Add fast-path for default sorting (skip unnecessary operations)

New Utilities:
- debounce utility with 4 tests (all pass)
- filterUtils with 15 tests (all pass)
- typeGuards with 20 tests (all pass)
- Total: 39 new tests

Modified Files:
- unifiedFontStore.svelte.ts: Add debouncing, use filter/sort utilities
- filterBridge.svelte.ts: Type-safe validation with type guards
- unifiedFontStore.test.ts: Fix pre-existing bugs (missing async, duplicate imports)

Code Quality:
- 0 linting warnings/errors (oxlint)
- FSD compliant architecture (entity lib layer)
- Backward compatible store API
2026-01-11 14:49:21 +03:00
Ilia Mashkov
77de829b04 fix: types 2026-01-09 16:48:26 +03:00
Ilia Mashkov
7630802363 fix: minor changes in types 2026-01-09 16:20:25 +03:00
Ilia Mashkov
43175fd52a feat(FontSearch): create FontSearch component with SearchBar and FontList with list virtualization 2026-01-09 16:20:00 +03:00
Ilia Mashkov
9598d8c3e4 feat(SearchBar): create SearchBar component with input and popover that contains search results 2026-01-09 16:19:22 +03:00
Ilia Mashkov
c863bea2dc feat: create FontList component with use of VirtualList 2026-01-09 16:18:16 +03:00
Ilia Mashkov
ea1f46f780 feat(fontCollection): create font collection state manager 2026-01-09 16:17:49 +03:00
Ilia Mashkov
bdb67157fd fix: rename file 2026-01-09 16:17:09 +03:00
Ilia Mashkov
e198e967ab fix: minor changes in shadcn components import 2026-01-09 16:16:32 +03:00
Ilia Mashkov
e1af950442 chore: create index files for better import/export api 2026-01-09 16:14:38 +03:00
Ilia Mashkov
13509a4145 chore: add comments for types and constants 2026-01-09 16:13:47 +03:00
Ilia Mashkov
09111a7c61 fix: import/export 2026-01-09 16:13:02 +03:00
Ilia Mashkov
b13c0d268b fix: import/export 2026-01-09 16:12:51 +03:00
Ilia Mashkov
1990860717 feat: add generic type for property value 2026-01-09 16:11:35 +03:00
Ilia Mashkov
6f7e863b13 fix: use proper types for fetching fonts 2026-01-09 16:09:56 +03:00
Ilia Mashkov
8ad29fd3a8 feat(FontCategory): separate types for font categories from different providers 2026-01-09 16:09:18 +03:00
Ilia Mashkov
de2688de5a delete: delete stores 2026-01-09 16:08:19 +03:00
Ilia Mashkov
1ebab2d77b feat: add data-testid attribute
Some checks failed
Lint / Lint Code (push) Failing after 7m16s
Test / Svelte Checks (push) Failing after 7m8s
2026-01-08 13:15:02 +03:00
Ilia Mashkov
fc00717359 feat: test coverage of ComboControl and CheckboxFilter 2026-01-08 13:14:04 +03:00
Ilia Mashkov
36a326817d feat: test coverage for utils
Some checks failed
Lint / Lint Code (push) Failing after 7m20s
Test / Svelte Checks (push) Failing after 7m20s
2026-01-07 17:26:59 +03:00
Ilia Mashkov
f4c2a38873 fix: imports path 2026-01-07 16:54:19 +03:00
Ilia Mashkov
614d6b0673 fix: imports path 2026-01-07 16:54:12 +03:00
Ilia Mashkov
f26f56ddef chore: move createVirtualizer 2026-01-07 16:53:44 +03:00
Ilia Mashkov
76f27a64b2 refactor(createTypographyControl): createControlStore rewrote to runes 2026-01-07 16:53:17 +03:00
Ilia Mashkov
baff3b9e27 refactor(createFilter): createFilterStore rewrote to runes 2026-01-07 16:52:17 +03:00
Ilia Mashkov
d15b90cfcb feat: move buildQueryString to separate directory 2026-01-07 16:49:37 +03:00
Ilia Mashkov
893bb02459 feat: move buildQueryString to separate directory 2026-01-07 16:49:18 +03:00
Ilia Mashkov
f7b19bd97f feat: move functions to separate files 2026-01-07 16:48:49 +03:00
Ilia Mashkov
2c4bfaba41 fix: rename file from .ts to .svelte.ts to support svelte runes 2026-01-07 14:27:25 +03:00
Ilia Mashkov
9fd98aca5d refactor(createFilterStore): move from store pattern to svelte 5 runes usage 2026-01-07 14:26:37 +03:00
Ilia Mashkov
0692711726 fix: import/export paths
Some checks failed
Lint / Lint Code (push) Failing after 7m18s
Test / Svelte Checks (push) Failing after 7m16s
2026-01-06 21:40:28 +03:00
Ilia Mashkov
86898bf83c chore: move utils directory into shared/lib 2026-01-06 21:39:17 +03:00
Ilia Mashkov
1950cd4095 refactor(VirtualList): refactor VirtualList with modern svelte 5 patterns 2026-01-06 21:38:53 +03:00
Ilia Mashkov
7a9f7e238c refactor(createVirtualizer): refactor createVirtualizerStore with modern svelte 5 patterns 2026-01-06 21:38:18 +03:00
Ilia Mashkov
1f19e964ca fix: import/export paths 2026-01-06 21:36:29 +03:00
Ilia Mashkov
eb10d58128 chore: move store creators to separate directories 2026-01-06 21:35:49 +03:00
Ilia Mashkov
c78ab826a2 chore: move fetch directory into shared/lib 2026-01-06 21:35:16 +03:00
Ilia Mashkov
931a2df1ee feat: test coverage for store creators 2026-01-06 21:34:05 +03:00
Ilia Mashkov
bea3f7ae7f chore: move store creators to separate directories 2026-01-06 21:33:30 +03:00
Ilia Mashkov
d1f035a6ad feat(api): create api calls for google fonts and fontshare 2026-01-06 21:31:25 +03:00
Ilia Mashkov
c0ccf4baff refactor(virtual): use store pattern instead of hook, fix styling
Store Pattern Migration:
- Created createVirtualizerStore using Svelte stores (writable/derived)
- Replaced useVirtualList hook with createVirtualizerStore
- Matches existing store patterns (createFilterStore, createControlStore)
- More Svelte-idiomatic than React-inspired hook pattern

Component Refactoring:
- Renamed FontVirtualList.svelte → VirtualList.svelte
- Moved component from shared/virtual/ → shared/ui/
- Updated to use store pattern instead of hook
- Removed pixel values from style tags (uses Tailwind CSS)
- Height now configurable via Tailwind classes (e.g., 'h-96', 'h-[500px]')
- Props changed from shorthand {fonts} to explicit items prop

File Changes:
- Deleted: useVirtualList.ts (replaced by store pattern)
- Deleted: FontVirtualList.svelte (renamed and moved)
- Deleted: useVirtualList.test.ts (updated to test store pattern)
- Updated: README.md with store pattern usage examples
- Updated: index.ts with migration guide
- Created: createVirtualizerStore.ts in shared/store/
- Created: VirtualList.svelte in shared/ui/
- Created: createVirtualizerStore.test.ts
- Created: barrel exports (shared/store/index.ts, shared/ui/index.ts)

Styling Improvements:
- All pixel values removed from <style> tags
- Uses Tailwind CSS for all styling
- Responsive height via Tailwind classes or props
- Only inline styles for dynamic positioning (required for virtualization)

TypeScript & Testing:
- Full TypeScript support with generics
- All 33 tests passing
- Type checking passes
- Linting passes (minor warnings only)

Breaking Changes:
- Component name: FontVirtualList → VirtualList
- Component location: $shared/virtual → $shared/ui
- Hook removed: useVirtualList → createVirtualizerStore
- Props change: {fonts} shorthand → items prop
- Import changes: $shared/virtual → $shared/ui and $shared/store

Documentation:
- Updated README.md with store pattern examples
- Added migration guide in virtual/index.ts
- Documented breaking changes and migration steps
2026-01-06 18:56:30 +03:00
Ilia Mashkov
10b7457f21 refactor(virtual): use store pattern instead of hook, fix styling
Store Pattern Migration:
- Created createVirtualizerStore using Svelte stores (writable/derived)
- Replaced useVirtualList hook with createVirtualizerStore
- Matches existing store patterns (createFilterStore, createControlStore)
- More Svelte-idiomatic than React-inspired hook pattern

Component Refactoring:
- Renamed FontVirtualList.svelte → VirtualList.svelte
- Moved component from shared/virtual/ → shared/ui/
- Updated to use store pattern instead of hook
- Removed pixel values from style tags (uses Tailwind CSS)
- Height now configurable via Tailwind classes (e.g., 'h-96', 'h-[500px]')
- Props changed from shorthand {fonts} to explicit items prop

File Changes:
- Deleted: useVirtualList.ts (replaced by store pattern)
- Deleted: FontVirtualList.svelte (renamed and moved)
- Deleted: useVirtualList.test.ts (updated to test store pattern)
- Updated: README.md with store pattern usage examples
- Updated: index.ts with migration guide
- Created: createVirtualizerStore.ts in shared/store/
- Created: VirtualList.svelte in shared/ui/
- Created: createVirtualizerStore.test.ts
- Created: barrel exports (shared/store/index.ts, shared/ui/index.ts)

Styling Improvements:
- All pixel values removed from <style> tags
- Uses Tailwind CSS for all styling
- Responsive height via Tailwind classes or props
- Only inline styles for dynamic positioning (required for virtualization)

TypeScript & Testing:
- Full TypeScript support with generics
- All 33 tests passing
- Type checking passes
- Linting passes (minor warnings only)

Breaking Changes:
- Component name: FontVirtualList → VirtualList
- Component location: $shared/virtual → $shared/ui
- Hook removed: useVirtualList → createVirtualizerStore
- Props change: {fonts} shorthand → items prop
- Import changes: $shared/virtual → $shared/ui and $shared/store

Documentation:
- Updated README.md with store pattern examples
- Added migration guide in virtual/index.ts
- Documented breaking changes and migration steps
2026-01-06 18:55:07 +03:00
Ilia Mashkov
2c666646cb style(font): fix lint warnings - remove unused imports and variables
- Removed unused FontFeatures, FontMetadata, FontProvider from normalize.ts imports
- Removed unused UnifiedFont from normalize.test.ts imports
- Removed unused FontSubset from store.ts imports
- Changed unused queryClient variables to void calls to suppress warnings
2026-01-06 15:24:34 +03:00
Ilia Mashkov
be14a62e83 refactor(font): split types into separate files for better maintainability 2026-01-06 15:23:08 +03:00
Ilia Mashkov
db814f0b93 fix(types): resolve import path and type issues after consolidation
- Added GoogleFontItem type alias for backward compatibility
- Updated normalize.ts to properly type Record<string, string> values
- Fixed import paths in Font index.ts (added subdirectory paths)
- Removed unused Readable import from store
- Removed type exports from normalize and API index files
- Updated stores index.ts to import types from parent types.ts
- All tests passing (129 tests)

All imports now use centralized types from model/types.ts:
- API clients re-export types for backward compatibility
- Normalize module imports types and exports functions
- Store module imports types and exports factory
- Main index.ts exports all types from model/types.ts
2026-01-06 15:11:16 +03:00
Ilia Mashkov
9f8b840e7a refactor(font): consolidate all types into single types.ts file
- Created unified model/types.ts with all type definitions
- Consolidated domain types (FontCategory, FontProvider, FontSubset)
- Consolidated Google Fonts API types (FontItem, GoogleFontsApiModel, etc.)
- Consolidated Fontshare API types (FontshareFont, FontshareStyle, etc.)
- Consolidated normalization types (UnifiedFont, FontStyleUrls, etc.)
- Consolidated store types (FontCollectionStore, FontCollectionFilters, etc.)
- Removed duplicate type files (font.ts, google_fonts.ts, fontshare_fonts.ts)
- Updated all imports to use consolidated types
- Updated normalize module to import from /Font
- Updated API clients to re-export types for backward compatibility
- Updated store to use centralized types
- Updated Font index.ts to export all types

Benefits:
- Centralized type definitions in single location
- Cleaner imports (single import from /Font)
- Better code organization with clear sections
- Follows FSD principles (types in model layer)
- No duplicate type definitions
2026-01-06 15:06:38 +03:00
Ilia Mashkov
9abec4210c feat(utils): add generic buildQueryString utility
- Add type-safe buildQueryString function to /utils
- Support primitives, arrays, and optional values
- Proper URL encoding for special characters
- Add comprehensive tests (25 test cases, all passing)
- Update Google Fonts API client to use shared utility
- Update Fontshare API client to use shared utility
- Export utility from /utils/index.ts

Benefits:
- DRY - Single source of truth for query string logic
- Type-safe - Proper TypeScript support with QueryParams type
- Tested - Comprehensive test coverage
- Maintainable - One place to fix bugs
2026-01-06 15:00:31 +03:00
Ilia Mashkov
29d1cc0cdc refactor(shared): rename fontCache to collectionCache
- Rename fontCache.ts to collectionCache.ts
- Rename FontCacheManager interface to CollectionCacheManager
- Make implementation fully generic (already was, just renamed interface)
- Update exports in shared/fetch/index.ts
- Fix getStats() to return derived store value for accurate statistics
- Add comprehensive test coverage for collection cache manager
  - 41 test cases covering all functionality
  - Tests for caching, deduplication, state tracking
  - Tests for statistics, reactivity, and edge cases

Closes task-1 of Phase 1 refactoring
2026-01-06 14:38:55 +03:00
7d2fe49e9c Merge pull request 'feature/vitest-setup' (#13) from feature/vitest-setup into main
Some checks failed
Build / build (push) Failing after 7m14s
Deploy Pipeline / pipeline (push) Failing after 7m8s
Lint / Lint Code (push) Failing after 7m14s
Test / Svelte Checks (push) Failing after 7m17s
Reviewed-on: #13
2026-01-06 09:24:56 +00:00
Ilia Mashkov
aa087c5c3e fix: move Item component from feature to Widget for FontMenu may be used in different places of the app
Some checks failed
Lint / Lint Code (push) Failing after 7m9s
Test / Svelte Checks (push) Failing after 7m12s
Build / build (pull_request) Failing after 7m14s
Lint / Lint Code (pull_request) Failing after 7m20s
Test / Svelte Checks (pull_request) Failing after 7m14s
2026-01-06 12:23:50 +03:00
Ilia Mashkov
943e6e77d3 feat: create tests for shared/ui components 2026-01-06 12:22:38 +03:00
Ilia Mashkov
809611cb10 feat: create tests for stores 2026-01-06 12:22:05 +03:00
Ilia Mashkov
ffad76a4c0 chore: setup vitest 2026-01-06 12:21:33 +03:00
3a3d6ec577 Merge pull request 'fix: add install-state.gz to gitignore' (#12) from fixes/gitignore into main
Some checks failed
Build / build (push) Failing after 7m18s
Deploy Pipeline / pipeline (push) Failing after 7m12s
Lint / Lint Code (push) Failing after 7m13s
Test / Svelte Checks (push) Failing after 7m7s
Reviewed-on: #12
2026-01-06 06:22:37 +00:00
Ilia Mashkov
ca1077df2f fix: add install-state.gz to gitignore
Some checks failed
Build / build (pull_request) Failing after 7m12s
Lint / Lint Code (pull_request) Failing after 7m25s
Test / Svelte Checks (pull_request) Failing after 7m18s
2026-01-06 09:21:56 +03:00
2e4a711a67 Merge pull request 'feature/setup-stotybook' (#11) from feature/setup-stotybook into main
Some checks failed
Build / build (push) Has been cancelled
Deploy Pipeline / pipeline (push) Has been cancelled
Lint / Lint Code (push) Has been cancelled
Test / Svelte Checks (push) Has been cancelled
Reviewed-on: #11
2026-01-06 06:19:35 +00:00
Ilia Mashkov
73419799ae feat(ComboControl): create stories for ComboControl component
Some checks failed
Lint / Lint Code (push) Failing after 7m10s
Test / Svelte Checks (push) Failing after 7m17s
Build / build (pull_request) Failing after 7m20s
Lint / Lint Code (pull_request) Failing after 7m16s
Test / Svelte Checks (pull_request) Failing after 7m14s
2026-01-06 09:16:21 +03:00
Ilia Mashkov
917b303240 feat: setup storybook for glyphdiff project 2026-01-05 14:43:19 +03:00
9e4667faf0 Merge pull request 'fix: exclude shadcn files from lefthook svetle-check' (#10) from fixes/exclude-shadcn-from-check into main
Some checks failed
Build / build (push) Failing after 7m16s
Deploy Pipeline / pipeline (push) Failing after 7m24s
Lint / Lint Code (push) Failing after 7m14s
Test / Svelte Checks (push) Failing after 7m19s
Reviewed-on: #10
2026-01-05 06:35:14 +00:00
Ilia Mashkov
4705e40f92 fix: exclude shadcn files from lefthook svetle-check
Some checks failed
Build / build (pull_request) Failing after 7m11s
Lint / Lint Code (pull_request) Failing after 7m26s
Test / Svelte Checks (pull_request) Failing after 7m23s
2026-01-05 09:34:01 +03:00
9cf91e0992 Merge pull request 'feature/typography-settings' (#9) from feature/typography-settings into main
Some checks failed
Build / build (push) Has been cancelled
Deploy Pipeline / pipeline (push) Has been cancelled
Lint / Lint Code (push) Has been cancelled
Test / Svelte Checks (push) Has been cancelled
Reviewed-on: #9
2026-01-05 06:29:16 +00:00
Ilia Mashkov
3d35f1901d feature(ComboControl):
Some checks failed
Lint / Lint Code (push) Failing after 7m14s
Test / Svelte Checks (push) Failing after 7m20s
Build / build (pull_request) Failing after 7m6s
Lint / Lint Code (pull_request) Failing after 7m14s
Test / Svelte Checks (pull_request) Failing after 7m16s
- create ComboControl component for typography settings (font size, font
  weight, line height)
- integrate it to TypographyMenu and integrate it to Layout
2026-01-05 09:03:31 +03:00
Ilia Mashkov
d8e5f5a0b5 fix(SetupFont): correct line height increase handler
- Fixed copy-paste error in SetupFontMenu.svelte line 43
- Changed onIncrease from fontSizeStore.increase to lineHeightStore.increase
- Line height control now correctly modifies line height instead of font size

Closes #?
2026-01-04 10:27:46 +03:00
90497fac16 Merge pull request 'feature/sidebar' (#8) from feature/sidebar into main
Some checks failed
Build / build (push) Failing after 7m10s
Deploy Pipeline / pipeline (push) Failing after 7m12s
Lint / Lint Code (push) Failing after 7m20s
Test / Svelte Checks (push) Failing after 7m18s
Reviewed-on: #8
2026-01-03 10:56:22 +00:00
Ilia Mashkov
b0afa0145d feat(FiltersSidebar): add callback to clear all filters
Some checks failed
Lint / Lint Code (push) Failing after 7m40s
Test / Svelte Checks (push) Failing after 7m20s
Build / build (pull_request) Failing after 7m28s
Lint / Lint Code (pull_request) Failing after 7m16s
Test / Svelte Checks (pull_request) Failing after 7m20s
2026-01-03 13:54:56 +03:00
Ilia Mashkov
e01a746460 feat(FilterFonts): join all the filters in one feature 2026-01-03 13:54:27 +03:00
Ilia Mashkov
53baacf05a feature(CheckboxFilter): move filter counter badge 2026-01-03 13:52:11 +03:00
Ilia Mashkov
ac41f324b1 fix(CheckboxFilter): change checkbox gaps
Some checks failed
Lint / Lint Code (push) Failing after 7m31s
Test / Svelte Checks (push) Failing after 7m21s
2026-01-03 13:06:51 +03:00
Ilia Mashkov
00aaecaa22 fix(CheckboxFilter): change checkbox gaps 2026-01-03 13:06:37 +03:00
Ilia Mashkov
bb4db09f87 chore: rename AppSidebar to FiltersSidebar 2026-01-03 13:05:16 +03:00
Ilia Mashkov
4f017c88d5 fix: delete comments from dprint config 2026-01-02 21:27:51 +03:00
Ilia Mashkov
23f3a5b803 feature: change filterStore model
Some checks failed
Lint / Lint Code (push) Failing after 7m17s
Test / Svelte Checks (push) Failing after 7m16s
2026-01-02 21:17:16 +03:00
Ilia Mashkov
d439e97729 feature: change filterStore model 2026-01-02 21:16:07 +03:00
Ilia Mashkov
1bb699ea2d chore: add documentation for svelte components 2026-01-02 21:15:40 +03:00
Ilia Mashkov
bf36f8e642 fix: style change 2026-01-02 20:42:36 +03:00
Ilia Mashkov
0742eb8c3d feat(AppSidebar): move filters and controls to separate components 2026-01-02 20:39:43 +03:00
Ilia Mashkov
109c69c1b9 fix: lint
Some checks failed
Lint / Lint Code (push) Failing after 7m13s
Test / Svelte Checks (push) Failing after 7m18s
2026-01-02 20:07:18 +03:00
Ilia Mashkov
ff665e1d26 feature: add filters for providers and font subsets
Some checks failed
Lint / Lint Code (push) Has been cancelled
Test / Svelte Checks (push) Has been cancelled
2026-01-02 20:06:35 +03:00
Ilia Mashkov
949c7c1b48 feat: delete unnecessary components 2026-01-02 20:03:20 +03:00
Ilia Mashkov
90899c0b3b fix(CategoryFilter): fix toggle behavior 2026-01-02 17:19:53 +03:00
Ilia Mashkov
4ba02b5933 fix: new dprint import format settings
Some checks failed
Lint / Lint Code (push) Failing after 7m9s
Test / Svelte Checks (push) Failing after 7m20s
2026-01-02 17:01:59 +03:00
Ilia Mashkov
3a2cc1c76b feat(dprint): setup import/export order 2026-01-02 17:00:58 +03:00
Ilia Mashkov
be267d43d8 feat(CheckboxFilter): add comprehencive documentation 2026-01-02 17:00:34 +03:00
Ilia Mashkov
14d7f0976c feat(app): add styles for better optimized transitions
Some checks failed
Lint / Lint Code (push) Failing after 7m18s
Test / Svelte Checks (push) Failing after 7m13s
2026-01-02 16:36:40 +03:00
Ilia Mashkov
98febdc24c feat(CheckboxFilter): improve CheckboxFilter animations for better UX 2026-01-02 16:36:04 +03:00
Ilia Mashkov
f8e62340e4 feat(shadcn): add Badge component 2026-01-02 16:35:11 +03:00
357 changed files with 31187 additions and 3618 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,32 +0,0 @@
name: Build
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install
run: yarn install --frozen-lockfile --prefer-offline
- 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,42 +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:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install
run: yarn install --frozen-lockfile --prefer-offline
- name: Validation
run: |
yarn oxlint .
yarn svelte-check
- name: Build for Production
run: yarn build
env:
NODE_ENV: production
- name: Deploy Step
run: |
echo "Deploying dist/ to ${{ github.event.inputs.environment || 'production' }}..."
# EXAMPLE: rsync -avz dist/ user@your-vps:/var/www/html/

View File

@@ -1,48 +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: '20'
cache: 'yarn'
- 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: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline

View File

@@ -1,69 +0,0 @@
name: Test
on:
push:
branches: [main, develop, "feature/*"]
pull_request:
branches: [main, develop]
workflow_dispatch:
jobs:
test:
name: Svelte Checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install
run: yarn install --frozen-lockfile --prefer-offline
- name: Type Check
run: yarn svelte-check --threshold warning
- name: Lint
run: yarn oxlint .
# e2e-tests:
# name: E2E Tests (Playwright)
# runs-on: ubuntu-latest
#
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
#
# - name: Setup Node.js
# uses: actions/setup-node@v4
# with:
# node-version: '20'
# cache: 'yarn'
#
# - name: Install dependencies
# run: yarn install --frozen-lockfile
#
# - name: Install Playwright browsers
# run: yarn playwright install --with-deps
#
# - name: Run Playwright tests
# run: yarn test:e2e
#
# - name: Upload Playwright report
# if: always()
# uses: actions/upload-artifact@v4
# with:
# name: playwright-report
# path: playwright-report/
# retention-days: 7
#
# - name: Upload Playwright screenshots (on failure)
# if: failure()
# uses: actions/upload-artifact@v4
# with:
# name: playwright-screenshots
# path: test-results/
# retention-days: 7
#
# Note: E2E tests are disabled until Playwright setup is complete.
# Uncomment this job section when Playwright tests are ready to run.

View File

@@ -0,0 +1,60 @@
name: Workflow
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'
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@stable --activate
- name: Persistent Yarn Cache
uses: actions/cache@v4
id: yarn-cache
with:
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
- name: Install dependencies
run: yarn install --immutable
- name: Build Svelte App
run: yarn build
- name: Lint
run: yarn lint
- name: Type Check
run: yarn check:shadcn-excluded
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

14
.gitignore vendored
View File

@@ -10,6 +10,9 @@ node_modules
/build
/dist
# Git worktrees (isolated development branches)
.worktrees
# OS
.DS_Store
Thumbs.db
@@ -23,6 +26,7 @@ Thumbs.db
# Yarn
.yarn
.yarn/**
.yarn/install-state.gz
.pnp.*
# Zed
@@ -33,3 +37,13 @@ vite.config.js.timestamp-*
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()}

47
.storybook/main.ts Normal file
View File

@@ -0,0 +1,47 @@
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': [
'../src/**/*.mdx',
'../src/**/*.stories.@(js|ts|svelte)',
],
'addons': [
{
name: '@storybook/addon-svelte-csf',
options: {
// Use modern template syntax for better performance
legacyTemplate: false,
},
},
'@chromatic-com/storybook',
'@storybook/addon-vitest',
'@storybook/addon-a11y',
'@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>

207
.storybook/preview.ts Normal file
View File

@@ -0,0 +1,207 @@
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,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// '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;

View File

@@ -0,0 +1,7 @@
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
import { setProjectAnnotations } from '@storybook/svelte-vite';
import * as projectAnnotations from './preview';
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);

Binary file not shown.

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

@@ -1,4 +1,5 @@
{
"$schema": "https://dprint.dev/schemas/v0.json",
"incremental": true,
"includes": ["**/*.{ts,tsx,js,jsx,svelte,json,md}"],
"excludes": [
@@ -15,14 +16,22 @@
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
],
"typescript": {
"lineWidth": 100,
"lineWidth": 120,
"indentWidth": 4,
"useTabs": false,
"semiColons": "prefer",
"quoteStyle": "preferSingle",
"trailingCommas": "onlyMultiLine",
"arrowFunction.useParentheses": "preferNone",
"importDeclaration.sortNamedImports": "caseInsensitive"
"module.sortImportDeclarations": "caseSensitive",
"module.sortExportDeclarations": "caseSensitive",
"importDeclaration.sortNamedImports": "caseSensitive",
"importDeclaration.forceMultiLine": "whenMultiple",
"importDeclaration.forceSingleLine": false,
"exportDeclaration.forceMultiLine": "whenMultiple",
"exportDeclaration.forceSingleLine": false
},
"json": {
"indentWidth": 2,
@@ -32,11 +41,15 @@
"lineWidth": 100
},
"markup": {
"printWidth": 100,
"printWidth": 120,
"indentWidth": 4,
"useTabs": false,
"quotes": "double",
"scriptIndent": false,
"styleIndent": false
"styleIndent": false,
"vBindStyle": "short",
"vOnStyle": "short",
"formatComments": true
}
}

View File

@@ -1,6 +0,0 @@
import { expect, test } from '@playwright/test';
test('home page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});

View File

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

View File

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

View File

@@ -2,32 +2,57 @@
"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",
"format": "dprint fmt",
"format:check": "dprint check",
"test:e2e": "playwright test",
"test": "npm run test:e2e"
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:unit:ui": "vitest --ui",
"test:unit:coverage": "vitest run --coverage",
"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": "yarn run test:unit",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.1.3",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@playwright/test": "^1.57.0",
"@storybook/addon-a11y": "^10.1.11",
"@storybook/addon-docs": "^10.1.11",
"@storybook/addon-svelte-csf": "^5.0.10",
"@storybook/addon-vitest": "^10.1.11",
"@storybook/svelte-vite": "^10.1.11",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@tsconfig/svelte": "^5.0.6",
"@types/jsdom": "^27",
"@vitest/browser-playwright": "^4.0.16",
"@vitest/coverage-v8": "^4.0.16",
"bits-ui": "^2.14.4",
"clsx": "^2.1.1",
"dprint": "^0.50.2",
"jsdom": "^27.4.0",
"lefthook": "^2.0.13",
"oxlint": "^1.35.0",
"playwright": "^1.57.0",
"storybook": "^10.1.11",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"svelte-language-server": "^0.17.23",
@@ -36,6 +61,12 @@
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.2.6"
"vite": "^7.2.6",
"vitest": "^4.0.16",
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@chenglou/pretext": "^0.0.5",
"@tanstack/svelte-query": "^6.0.14"
}
}

View File

@@ -1,6 +1,10 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: { command: 'yarn build && yarn preview', port: 4173 },
webServer: {
command: 'yarn build && yarn preview',
port: 4173,
reuseExistingServer: true,
},
testDir: 'e2e',
});

View File

@@ -1,8 +1,26 @@
<!--
Component: App
Application root with query provider and layout
-->
<script lang="ts">
/**
* App Component
*
* Application entry point component. Wraps the main page route within the shared
* layout shell. This is the root component mounted by the application.
*
* Structure:
* - QueryProvider provides TanStack Query client for data fetching
* - Layout provides sidebar, header/footer, and page container
* - Page renders the current route content
*/
import Page from '$routes/Page.svelte';
import { QueryProvider } from './providers';
import Layout from './ui/Layout.svelte';
</script>
<Layout>
<Page />
</Layout>
<QueryProvider>
<Layout>
<Page />
</Layout>
</QueryProvider>

View File

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

View File

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

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,13 +190,188 @@
--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;
}
}
/* Global utility - useful across your app */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Performance optimization for collapsible elements */
[data-state="open"] {
will-change: height;
}
/* Smooth focus transitions - good globally */
.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

@@ -1,5 +1,9 @@
declare module '*.svelte' {
import type { ComponentProps as SvelteComponentProps, ComponentType, Snippet } from 'svelte';
import type {
ComponentProps as SvelteComponentProps,
ComponentType,
Snippet,
} from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
interface Component {
@@ -31,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,32 +1,95 @@
<!--
Component: Layout
Application shell with providers and page wrapper
-->
<script lang="ts">
import favicon from '$shared/assets/favicon.svg';
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
import { AppSidebar } from '$widgets/AppSidebar';
/**
* Layout Component
*
* Root layout wrapper that provides the application shell structure. Handles favicon,
* toolbar provider initialization, and renders child routes with consistent structure.
*
* Layout structure:
* - Header area (currently empty, reserved for future use)
*
* - Footer area (currently empty, reserved for future use)
*/
import { BreadcrumbHeader } from '$entities/Breadcrumb';
import { themeManager } from '$features/ChangeAppTheme';
import 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';
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 class="app">
<header></header>
<Sidebar.Provider>
<AppSidebar />
<main>
<Sidebar.Trigger />
{@render children?.()}
</main>
</Sidebar.Provider>
<footer></footer>
</div>
<style>
#app-root {
width: 100%;
height: 100vh;
}
</style>
<ResponsiveProvider>
<div
id="app-root"
class={cn(
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
theme === 'dark' ? 'dark' : '',
)}
>
<TooltipProvider>
{#if fontsReady}
{@render children?.()}
{/if}
</TooltipProvider>
<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

@@ -0,0 +1,16 @@
/**
* Font API clients exports
*
* Exports API clients and normalization utilities
*/
// Proxy API (primary)
export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
} from './proxy/proxyFonts';
export type {
ProxyFontsParams,
ProxyFontsResponse,
} from './proxy/proxyFonts';

View File

@@ -0,0 +1,204 @@
/**
* 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 { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
seedFontCache,
} from './proxyFonts';
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
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();
queryClient.clear();
});
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([]);
});
});
describe('seedFontCache', () => {
test('should populate cache with multiple fonts', () => {
const fonts = [
createMockFont({ id: '1', name: 'A' }),
createMockFont({ id: '2', name: 'B' }),
];
seedFontCache(fonts);
expect(queryClient.getQueryData(fontKeys.detail('1'))).toEqual(fonts[0]);
expect(queryClient.getQueryData(fontKeys.detail('2'))).toEqual(fonts[1]);
});
test('should update existing cached fonts with new data', () => {
const id = 'update-me';
queryClient.setQueryData(fontKeys.detail(id), createMockFont({ id, name: 'Old' }));
const updated = createMockFont({ id, name: 'New' });
seedFontCache([updated]);
expect(queryClient.getQueryData(fontKeys.detail(id))).toEqual(updated);
});
test('should handle empty input arrays gracefully', () => {
const spy = vi.spyOn(queryClient, 'setQueryData');
seedFontCache([]);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
});
});

View File

@@ -0,0 +1,199 @@
/**
* 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 { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type { UnifiedFont } from '../../model/types';
/**
* Normalizes cache by seeding individual font entries from collection responses.
* This ensures that a font fetched in a list or batch is available via its detail key.
*
* @param fonts - Array of fonts to cache
*/
export function seedFontCache(fonts: UnifiedFont[]): void {
fonts.forEach(font => {
queryClient.setQueryData(fontKeys.detail(font.id), font);
});
}
/**
* Proxy API base URL
*/
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,13 +1,4 @@
export type { FontCategory, FontProvider, FontSubset } from './model/font';
export type {
FontshareApiModel,
FontshareDesigner,
FontshareFeature,
FontshareFont,
FontsharePublisher,
FontshareStyle,
FontshareStyleProperties,
FontshareTag,
FontshareWeight,
} from './model/fontshare_fonts';
export type { FontFiles, FontItem, FontVariant, GoogleFontsApiModel } from './model/google_fonts';
export * from './api';
export * from './lib';
export * from './model';
export * from './ui';

View File

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

View File

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

View File

@@ -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

@@ -0,0 +1,53 @@
export { getFontUrl } from './getFontUrl/getFontUrl';
// Mock data helpers for Storybook and testing
export {
createCategoriesFilter,
createErrorState,
createGenericFilter,
createLoadingState,
createMockComparisonStore,
// Filter mocks
createMockFilter,
createMockFontApiResponse,
createMockFontStoreState,
// Store mocks
createMockQueryState,
createMockReactiveState,
createMockStore,
createProvidersFilter,
createSubsetsFilter,
createSuccessState,
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

@@ -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

@@ -0,0 +1,88 @@
import type { ControlModel } from '$shared/lib';
import type { ControlId } from '../types/typography';
/**
* Font size constants
*/
export const DEFAULT_FONT_SIZE = 48;
export const MIN_FONT_SIZE = 8;
export const MAX_FONT_SIZE = 100;
export const FONT_SIZE_STEP = 1;
/**
* Font weight constants
*/
export const DEFAULT_FONT_WEIGHT = 400;
export const MIN_FONT_WEIGHT = 100;
export const MAX_FONT_WEIGHT = 900;
export const FONT_WEIGHT_STEP = 100;
/**
* Line height constants
*/
export const DEFAULT_LINE_HEIGHT = 1.5;
export const MIN_LINE_HEIGHT = 1;
export const MAX_LINE_HEIGHT = 2;
export const LINE_HEIGHT_STEP = 0.05;
/**
* Letter spacing constants
*/
export const DEFAULT_LETTER_SPACING = 0;
export const MIN_LETTER_SPACING = -0.1;
export const MAX_LETTER_SPACING = 0.5;
export const LETTER_SPACING_STEP = 0.01;
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
step: FONT_SIZE_STEP,
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Size',
},
{
id: 'font_weight',
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Weight',
},
{
id: 'line_height',
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Leading',
},
{
id: 'letter_spacing',
value: DEFAULT_LETTER_SPACING,
max: MAX_LETTER_SPACING,
min: MIN_LETTER_SPACING,
step: LETTER_SPACING_STEP,
increaseLabel: 'Increase Letter Spacing',
decreaseLabel: 'Decrease Letter Spacing',
controlLabel: 'Tracking',
},
];
/**
* Font size multipliers
*/
export const MULTIPLIER_S = 0.5;
export const MULTIPLIER_M = 0.75;
export const MULTIPLIER_L = 1;

View File

@@ -1,441 +0,0 @@
import type { CollectionApiModel } from '../../../shared/types/collection';
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2' as const;
/**
* Model of Fontshare API response
* @see https://fontshare.com
*/
export type FontshareApiModel = CollectionApiModel<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,104 +0,0 @@
/**
* 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[];
}
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: string;
/**
* 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;
}
/**
* 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

@@ -0,0 +1,3 @@
export * from './const/const';
export * from './store';
export * from './types';

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

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

View File

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

View File

@@ -0,0 +1,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

@@ -0,0 +1,12 @@
// Applied fonts manager
export * from './appliedFontsStore/appliedFontsStore.svelte';
// Batch font store
export { BatchFontStore } from './batchFontStore.svelte';
// Single FontStore
export {
createFontStore,
FontStore,
fontStore,
} from './fontStore/fontStore.svelte';

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

@@ -0,0 +1,36 @@
/**
* ============================================================================
* SINGLE EXPORT POINT
* ============================================================================
*
* This is the single export point for all Font types.
* All imports should use: `import { X } from '$entities/Font/model/types'`
*/
// Font domain and model types
export type {
FilterGroup,
FilterType,
FontCategory,
FontFeatures,
FontFilters,
FontMetadata,
FontProvider,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
UnifiedFont,
UnifiedFontVariant,
} from './font';
// Store types
export type {
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
} from './store';
export * from './store/appliedFonts';
export * from './typography';

View File

@@ -0,0 +1,48 @@
/**
* ============================================================================
* STORE TYPES
* ============================================================================
*/
import type {
FontCategory,
FontProvider,
FontSubset,
UnifiedFont,
} from './font';
/**
* Font collection state
*/
export interface FontCollectionState {
/** All cached fonts */
fonts: Record<string, UnifiedFont>;
/** Active filters */
filters: FontCollectionFilters;
/** Sort configuration */
sort: FontCollectionSort;
}
/**
* Font collection filters
*/
export interface FontCollectionFilters {
/** Search query */
searchQuery: string;
/** Filter by providers */
providers?: FontProvider[];
/** Filter by categories */
categories?: FontCategory[];
/** Filter by subsets */
subsets?: FontSubset[];
}
/**
* Font collection sort configuration
*/
export interface FontCollectionSort {
/** Sort field */
field: 'name' | 'popularity' | 'category';
/** Sort direction */
direction: 'asc' | 'desc';
}

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
<!--
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 {
DEFAULT_FONT_WEIGHT,
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 = DEFAULT_FONT_WEIGHT,
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

@@ -0,0 +1,142 @@
<!--
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,
fontStore,
} from '../../model';
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,
);
let visibleFonts = $state<UnifiedFont[]>([]);
function handleInternalVisibleChange(items: UnifiedFont[]) {
visibleFonts = items;
// Forward the call to any external listener
onVisibleItemsChange?.(items);
}
// Re-touch whenever visible set or weight changes — fixes weight-change gap
$effect(() => {
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
const url = getFontUrl(item, weight);
if (!url) return [];
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
});
if (configs.length > 0) {
appliedFontsManager.touch(configs);
}
});
// Pin visible fonts so the eviction policy never removes on-screen entries.
// Cleanup captures the snapshot values, so a weight change unpins the old
// weight before pinning the new one.
$effect(() => {
const w = weight;
const fonts = visibleFonts;
for (const f of fonts) {
appliedFontsManager.pin(f.id, w, f.features?.isVariable);
}
return () => {
for (const f of fonts) {
appliedFontsManager.unpin(f.id, w, f.features?.isVariable);
}
};
});
/**
* Load more fonts by moving to the next page
*/
function loadMore() {
if (
!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

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

View File

@@ -1,5 +0,0 @@
import { FONT_CATEGORIES } from './model/state';
import { categoryFilterStore } from './store/categoryFilterStore';
import CategoryFilter from './ui/CategoryFilter.svelte';
export { CategoryFilter, categoryFilterStore, FONT_CATEGORIES };

View File

@@ -1,37 +0,0 @@
import type { FilterModel } from '$shared/store/createFilterStore';
/**
* Model of state for CategoryFilter
*/
export type CategoryFilterModel = FilterModel;
export const FONT_CATEGORIES = [
{
id: 'serif',
name: 'Serif',
},
{
id: 'sans-serif',
name: 'Sans-serif',
},
{
id: 'display',
name: 'Display',
},
{
id: 'handwriting',
name: 'Handwriting',
},
{
id: 'monospace',
name: 'Monospace',
},
{
id: 'script',
name: 'Script',
},
{
id: 'slab',
name: 'Slab',
},
] as const;

View File

@@ -1,16 +0,0 @@
import { FONT_CATEGORIES } from '$entities/Font/model/font';
import { createFilterStore } from '$shared/store/createFilterStore';
import type { CategoryFilterModel } from '../model/state';
/**
* Initial state for CategoryFilter
*/
export const initialState: CategoryFilterModel = {
searchQuery: '',
categories: FONT_CATEGORIES,
};
/**
* CategoryFilter store
*/
export const categoryFilterStore = createFilterStore(initialState);

View File

@@ -1,20 +0,0 @@
<script lang="ts">
import CheckboxFilter from '$shared/ui/CheckboxFilter/CheckboxFilter.svelte';
import { categoryFilterStore } from '../store/categoryFilterStore';
const { categories } = $derived($categoryFilterStore);
function didCategoryToggle(categoryId: string) {
if (categories?.find(category => category.id === categoryId)) {
categoryFilterStore.deselectCategory(categoryId);
} else {
categoryFilterStore.selectCategory(categoryId);
}
}
</script>
<CheckboxFilter
filterName="Font category"
categories={categories}
onCategoryToggle={didCategoryToggle}
/>

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