Compare commits

..

508 Commits

Author SHA1 Message Date
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
266 changed files with 23981 additions and 1433 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

10
.gitignore vendored
View File

@@ -23,6 +23,7 @@ Thumbs.db
# Yarn
.yarn
.yarn/**
.yarn/install-state.gz
.pnp.*
# Zed
@@ -33,3 +34,12 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/docs
AGENTS.md
*.md
!README.md
*storybook.log
storybook-static
# Tests
coverage/

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>

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>

65
.storybook/preview.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { Preview } from '@storybook/svelte-vite';
import Decorator from './Decorator.svelte';
import StoryStage from './StoryStage.svelte';
import '../src/app/styles/app.css';
const preview: Preview = {
parameters: {
layout: 'fullscreen',
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: '400px',
},
},
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: [
// 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

@@ -16,7 +16,7 @@
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
],
"typescript": {
"lineWidth": 100,
"lineWidth": 120,
"indentWidth": 4,
"useTabs": false,
"semiColons": "prefer",
@@ -24,12 +24,10 @@
"trailingCommas": "onlyMultiLine",
"arrowFunction.useParentheses": "preferNone",
// Import sorting configuration
"module.sortImportDeclarations": "caseSensitive",
"module.sortExportDeclarations": "caseSensitive",
"importDeclaration.sortNamedImports": "caseSensitive",
// Additional import formatting options
"importDeclaration.forceMultiLine": "whenMultiple",
"importDeclaration.forceSingleLine": false,
"exportDeclaration.forceMultiLine": "whenMultiple",
@@ -43,14 +41,13 @@
"lineWidth": 100
},
"markup": {
"printWidth": 100,
"printWidth": 120,
"indentWidth": 4,
"useTabs": false,
"quotes": "double",
"scriptIndent": false,
"styleIndent": false,
// Svelte-specific formatting
"vBindStyle": "short",
"vOnStyle": "short",
"formatComments": true

View File

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

@@ -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"
"vaul-svelte": "^1.0.0-next.7",
"vite": "^7.2.6",
"vitest": "^4.0.16",
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@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,22 @@
<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>
<QueryProvider>
<Layout>
<Page />
</Layout>
</Layout>
</QueryProvider>

View File

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

@@ -37,6 +37,28 @@
--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);
--background-20: oklch(1 0 0 / 20%);
--background-40: oklch(1 0 0 / 40%);
--background-60: oklch(1 0 0 / 60%);
--background-80: oklch(1 0 0 / 80%);
--background-95: oklch(1 0 0 / 95%);
--background-subtle: oklch(0.98 0 0);
--background-muted: oklch(0.97 0.002 286.375);
--text-muted: oklch(0.552 0.016 285.938);
--text-subtle: oklch(0.705 0.015 286.067);
--text-soft: oklch(0.5 0.01 286);
--border-subtle: oklch(0.95 0.003 286.32);
--border-muted: oklch(0.92 0.004 286.32);
--border-soft: oklch(0.88 0.005 286.32);
--gradient-from: oklch(0.98 0.002 286.32);
--gradient-via: oklch(1 0 0);
--gradient-to: oklch(0.98 0.002 286.32);
--font-mono: 'Major Mono Display';
}
.dark {
@@ -71,6 +93,26 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--background-20: oklch(0.21 0.006 285.885 / 20%);
--background-40: oklch(0.21 0.006 285.885 / 40%);
--background-60: oklch(0.21 0.006 285.885 / 60%);
--background-80: oklch(0.21 0.006 285.885 / 80%);
--background-95: oklch(0.21 0.006 285.885 / 95%);
--background-subtle: oklch(0.18 0.005 285.823);
--background-muted: oklch(0.274 0.006 286.033);
--text-muted: oklch(0.705 0.015 286.067);
--text-subtle: oklch(0.552 0.016 285.938);
--text-soft: oklch(0.8 0.01 286);
--border-subtle: oklch(1 0 0 / 8%);
--border-muted: oklch(1 0 0 / 10%);
--border-soft: oklch(1 0 0 / 15%);
--gradient-from: oklch(0.25 0.005 285.885);
--gradient-via: oklch(0.21 0.006 285.885);
--gradient-to: oklch(0.25 0.005 285.885);
}
@theme inline {
@@ -109,6 +151,24 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-background-20: var(--background-20);
--color-background-40: var(--background-40);
--color-background-60: var(--background-60);
--color-background-80: var(--background-80);
--color-background-95: var(--background-95);
--color-background-subtle: var(--background-subtle);
--color-background-muted: var(--background-muted);
--color-text-muted: var(--text-muted);
--color-text-subtle: var(--text-subtle);
--color-text-soft: var(--text-soft);
--color-border-subtle: var(--border-subtle);
--color-border-muted: var(--border-muted);
--color-border-soft: var(--border-soft);
--color-gradient-from: var(--gradient-from);
--color-gradient-via: var(--gradient-via);
--color-gradient-to: var(--gradient-to);
--font-mono: 'Major Mono Display', monospace;
--font-sans: 'Karla', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
}
@layer base {
@@ -117,6 +177,8 @@
}
body {
@apply bg-background text-foreground;
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
font-optical-sizing: auto;
}
}
@@ -138,3 +200,108 @@
.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;
}
.barlow {
font-family: "Barlow", system-ui, Inter, Roboto, "Segoe UI", Arial, sans-serif;
}
* {
scrollbar-width: thin;
scrollbar-color: hsl(0 0% 70% / 0.4) transparent;
}
.dark * {
scrollbar-color: hsl(0 0% 40% / 0.5) transparent;
}
/* ---- Webkit / Blink ---- */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(0 0% 70% / 0);
border-radius: 3px;
transition: background 0.2s ease;
}
/* Show thumb when container is hovered or actively scrolling */
:hover > ::-webkit-scrollbar-thumb,
::-webkit-scrollbar-thumb:hover,
*:hover::-webkit-scrollbar-thumb {
background: hsl(0 0% 70% / 0.4);
}
::-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: transparent;
}
/* Dark mode */
.dark ::-webkit-scrollbar-thumb {
background: hsl(0 0% 40% / 0);
}
.dark :hover > ::-webkit-scrollbar-thumb,
.dark ::-webkit-scrollbar-thumb:hover,
.dark *:hover::-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);
}
/* ---- Behavior ---- */
* {
scroll-behavior: smooth;
scrollbar-gutter: stable;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
overscroll-behavior-y: none;
}

View File

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

View File

@@ -1,32 +1,107 @@
<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 GD from '$shared/assets/GD.svg';
import { ResponsiveProvider } from '$shared/lib';
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import {
type Snippet,
onMount,
} from 'svelte';
let { children } = $props();
interface Props {
children: Snippet;
}
let { children }: Props = $props();
let fontsReady = $state(false);
/**
* Sets fontsReady flag to true when font for the page logo is loaded.
*/
onMount(async () => {
if (!('fonts' in document)) {
fontsReady = true;
return;
}
const required = ['100'];
const missing = required.filter(
w => !document.fonts.check(`${w} 1em Barlow`),
);
if (missing.length > 0) {
await Promise.all(
missing.map(w => document.fonts.load(`${w} 1em Barlow`)),
);
}
fontsReady = true;
});
</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=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
/>
<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"
media="print"
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
/>
<noscript>
<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"
/>
</noscript>
<title>Compare Typography & Typefaces | GlyphDiff</title>
</svelte:head>
<div class="app">
<header></header>
<ResponsiveProvider>
<div id="app-root" class="min-h-screen flex flex-col bg-background">
<header>
<BreadcrumbHeader />
</header>
<Sidebar.Provider>
<AppSidebar />
<main>
<Sidebar.Trigger />
<!-- <ScrollArea class="h-screen w-screen"> -->
<main class="flex-1 w-full mx-auto px-4 pt-0 pb-10 sm:px-6 sm:pt-8 sm:pb-12 md:px-8 md:pt-10 md:pb-16 lg:px-10 lg:pt-12 lg:pb-20 xl:px-16 relative">
<TooltipProvider>
{#if fontsReady}
{@render children?.()}
{/if}
</TooltipProvider>
</main>
</Sidebar.Provider>
<!-- </ScrollArea> -->
<footer></footer>
</div>
<style>
#app-root {
width: 100%;
height: 100vh;
}
</style>
</div>
</ResponsiveProvider>

View File

@@ -0,0 +1,2 @@
export { scrollBreadcrumbsStore } from './model';
export { BreadcrumbHeader } from './ui';

View File

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

View File

@@ -0,0 +1,39 @@
import type { Snippet } from 'svelte';
export interface BreadcrumbItem {
/**
* Index of the item to display
*/
index: number;
/**
* ID of the item to navigate to
*/
id?: string;
/**
* Title snippet to render
*/
title: Snippet<[{ className?: string }]>;
}
class ScrollBreadcrumbsStore {
#items = $state<BreadcrumbItem[]>([]);
get items() {
// Keep them sorted by index for Swiss orderliness
return this.#items.sort((a, b) => a.index - b.index);
}
add(item: BreadcrumbItem) {
if (!this.#items.find(i => i.index === item.index)) {
this.#items.push(item);
}
}
remove(index: number) {
this.#items = this.#items.filter(i => i.index !== index);
}
}
export function createScrollBreadcrumbsStore() {
return new ScrollBreadcrumbsStore();
}
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();

View File

@@ -0,0 +1,78 @@
<!--
Component: BreadcrumbHeader
Fixed header for breadcrumbs navigation for sections in the page
-->
<script lang="ts">
import { smoothScroll } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
fly,
slide,
} from 'svelte/transition';
import { scrollBreadcrumbsStore } from '../../model';
</script>
{#if scrollBreadcrumbsStore.items.length > 0}
<div
transition:slide={{ duration: 200 }}
class="
fixed top-0 left-0 right-0 z-100
backdrop-blur-lg bg-background-20
border-b border-border-muted
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
h-10 sm:h-12
"
>
<div class="max-w-8xl mx-auto px-4 sm:px-6 h-full flex items-center gap-2 sm:gap-4">
<h1 class={cn('barlow font-extralight text-sm sm:text-base')}>
GLYPHDIFF
</h1>
<div class="h-3.5 sm:h-4 w-px bg-border-subtle hidden sm:block"></div>
<nav class="flex items-center gap-2 sm:gap-3 overflow-x-auto scrollbar-hide flex-1">
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
<div
in:fly={{ duration: 300, y: -10, x: 100, opacity: 0 }}
out:fly={{ duration: 300, y: 10, x: 100, opacity: 0 }}
class="flex items-center gap-2 sm:gap-3 whitespace-nowrap shrink-0"
>
<span class="font-mono text-[8px] sm:text-[9px] text-text-muted tracking-wider">
{String(item.index).padStart(2, '0')}
</span>
<a href={`#${item.id}`} use:smoothScroll>
{@render item.title({
className: 'text-[9px] sm:text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-foreground',
})}</a>
{#if idx < scrollBreadcrumbsStore.items.length - 1}
<div class="flex items-center gap-0.5 opacity-40">
<div class="w-1 h-px bg-text-muted"></div>
<div class="w-1 h-px bg-text-muted"></div>
<div class="w-1 h-px bg-text-muted"></div>
</div>
{/if}
</div>
{/each}
</nav>
<div class="flex items-center gap-1.5 sm:gap-2 opacity-50 ml-auto">
<div class="w-px h-2 sm:h-2.5 bg-border-subtle hidden sm:block"></div>
<span class="font-mono text-[7px] sm:text-[8px] text-text-muted tracking-wider">
[{scrollBreadcrumbsStore.items.length}]
</span>
</div>
</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,3 @@
import BreadcrumbHeader from './BreadcrumbHeader/BreadcrumbHeader.svelte';
export { BreadcrumbHeader };

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
/**
* Font API clients exports
*
* Exports API clients and normalization utilities
*/
// Proxy API (PRIMARY - NEW)
export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
} from './proxy/proxyFonts';
export type {
ProxyFontsParams,
ProxyFontsResponse,
} from './proxy/proxyFonts';
// Google Fonts API (DEPRECATED - kept for backward compatibility)
export {
fetchGoogleFontFamily,
fetchGoogleFonts,
} from './google/googleFonts';
export type {
GoogleFontItem,
GoogleFontsParams,
GoogleFontsResponse,
} from './google/googleFonts';
// Fontshare API (DEPRECATED - kept for backward compatibility)
export {
fetchAllFontshareFonts,
fetchFontshareFontBySlug,
fetchFontshareFonts,
} from './fontshare/fontshare';
export type {
FontshareParams,
FontshareResponse,
} from './fontshare/fontshare';

View File

@@ -0,0 +1,279 @@
/**
* 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.
*
* Fallback: If proxy API fails, falls back to Fontshare API for development.
*
* @see https://api.glyphdiff.com/api/v1/fonts
*/
import { api } from '$shared/api/api';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type { UnifiedFont } from '../../model/types';
import type {
FontCategory,
FontSubset,
} from '../../model/types';
/**
* Proxy API base URL
*/
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
/**
* Whether to use proxy API (true) or fallback (false)
*
* Set to true when your proxy API is ready:
* const USE_PROXY_API = true;
*
* Set to false to use Fontshare API as fallback during development:
* const USE_PROXY_API = false;
*
* The app will automatically fall back to Fontshare API if the proxy fails.
*/
const USE_PROXY_API = true;
/**
* Proxy API parameters
*
* Maps directly to the proxy API query parameters
*/
export interface ProxyFontsParams extends QueryParams {
/**
* Font provider filter ("google" or "fontshare")
* Omit to fetch from both providers
*/
provider?: 'google' | 'fontshare';
/**
* Font category filter
*/
category?: FontCategory;
/**
* Character subset filter
*/
subset?: FontSubset;
/**
* 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
*
* If proxy API fails or is unavailable, falls back to Fontshare API for development.
*
* @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> {
// Try proxy API first if enabled
if (USE_PROXY_API) {
try {
const queryString = buildQueryString(params);
const url = `${PROXY_API_URL}${queryString}`;
console.log('[fetchProxyFonts] Fetching from proxy API', { params, url });
const response = await api.get<ProxyFontsResponse>(url);
// Validate response has fonts array
if (!response.data || !Array.isArray(response.data.fonts)) {
console.error('[fetchProxyFonts] Invalid response from proxy API', response.data);
throw new Error('Proxy API returned invalid response');
}
console.log('[fetchProxyFonts] Proxy API success', {
count: response.data.fonts.length,
});
return response.data;
} catch (error) {
console.warn('[fetchProxyFonts] Proxy API failed, using fallback', error);
// Check if it's a network error or proxy not available
const isNetworkError = error instanceof Error
&& (error.message.includes('Failed to fetch')
|| error.message.includes('Network')
|| error.message.includes('404')
|| error.message.includes('500'));
if (isNetworkError) {
// Fall back to Fontshare API
console.log('[fetchProxyFonts] Using Fontshare API as fallback');
return await fetchFontshareFallback(params);
}
// Re-throw other errors
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to fetch fonts from proxy API: ${String(error)}`);
}
}
// Use Fontshare API directly
console.log('[fetchProxyFonts] Using Fontshare API (proxy disabled)');
return await fetchFontshareFallback(params);
}
/**
* Fallback to Fontshare API when proxy is unavailable
*
* Maps proxy API params to Fontshare API params and normalizes response
*/
async function fetchFontshareFallback(
params: ProxyFontsParams,
): Promise<ProxyFontsResponse> {
// Import dynamically to avoid circular dependency
const { fetchFontshareFonts } = await import('$entities/Font/api/fontshare/fontshare');
const { normalizeFontshareFonts } = await import('$entities/Font/lib/normalize/normalize');
// Map proxy params to Fontshare params
const fontshareParams = {
q: params.q,
categories: params.category ? [params.category] : undefined,
page: params.offset ? Math.floor(params.offset / (params.limit || 50)) + 1 : undefined,
limit: params.limit,
};
const response = await fetchFontshareFonts(fontshareParams);
const normalizedFonts = normalizeFontshareFonts(response.fonts);
return {
fonts: normalizedFonts,
total: response.count_total,
limit: params.limit || response.count,
offset: params.offset || 0,
};
}
/**
* 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 [];
// Use proxy API if enabled
if (USE_PROXY_API) {
const queryString = ids.join(',');
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
try {
const response = await api.get<UnifiedFont[]>(url);
return response.data ?? [];
} catch (error) {
console.warn('[fetchFontsByIds] Proxy API batch fetch failed, falling back', error);
// Fallthrough to fallback
}
}
// Fallback: Fetch individually (not efficient but functional for fallback)
const results = await Promise.all(
ids.map(id => fetchProxyFontById(id)),
);
return results.filter((f): f is UnifiedFont => !!f);
}

View File

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

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,29 @@
import type {
FontWeight,
UnifiedFont,
} from '../../model';
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
/**
* Constructs a URL for a font based on the provided font and weight.
* @param font - The font object.
* @param weight - The weight of the font.
* @returns The URL for the font.
*/
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;
// 1. Try exact match (Backend now maps "100".."900" to VF URL if variable)
if (font.styles.variants?.[weightKey]) {
return font.styles.variants[weightKey];
}
// 2. Fallbacks for Static Fonts (if exact weight missing)
// Try 'regular' or '400' as safe defaults
return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular'];
}

View File

@@ -0,0 +1,58 @@
export {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './normalize/normalize';
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,
FONTHARE_FONTS,
generateMixedCategoryFonts,
generateMockFonts,
generatePaginatedFonts,
generateSequentialFilter,
GENERIC_FILTERS,
getAllMockFonts,
getFontsByCategory,
getFontsByProvider,
GOOGLE_FONTS,
MOCK_FILTERS,
MOCK_FILTERS_ALL_SELECTED,
MOCK_FILTERS_EMPTY,
MOCK_FILTERS_SELECTED,
MOCK_FONT_STORE_STATES,
MOCK_STORES,
type MockFilterOptions,
type MockFilters,
mockFontshareFont,
type MockFontshareFontOptions,
type MockFontStoreState,
// Font mocks
mockGoogleFont,
// Types
type MockGoogleFontOptions,
type MockQueryObserverResult,
type MockQueryState,
mockUnifiedFont,
type MockUnifiedFontOptions,
UNIFIED_FONTS,
} from './mocks';

View File

@@ -0,0 +1,348 @@
/**
* ============================================================================
* 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
// ============================================================================
/**
* Google Fonts categories
*/
export const GOOGLE_CATEGORIES: Property<'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'>[] = [
{ id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
{ id: 'serif', name: 'Serif', value: 'serif' },
{ id: 'display', name: 'Display', value: 'display' },
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
];
/**
* Fontshare categories (mapped to common naming)
*/
export const FONTHARE_CATEGORIES: Property<'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script'>[] = [
{ id: 'sans', name: 'Sans', value: 'sans' },
{ id: 'serif', name: 'Serif', value: 'serif' },
{ id: 'slab', name: 'Slab', value: 'slab' },
{ id: 'display', name: 'Display', value: 'display' },
{ id: 'handwritten', name: 'Handwritten', value: 'handwritten' },
{ id: 'script', name: 'Script', value: 'script' },
];
/**
* Unified categories (combines both providers)
*/
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' },
];
// ============================================================================
// 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,630 @@
/**
* ============================================================================
* 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 {
FontItem,
FontshareFont,
GoogleFontItem,
} from '$entities/Font/model/types';
import type {
FontFeatures,
FontMetadata,
FontStyleUrls,
UnifiedFont,
} from '$entities/Font/model/types';
// ============================================================================
// GOOGLE FONTS MOCKS
// ============================================================================
/**
* Options for creating a mock Google Font
*/
export interface MockGoogleFontOptions {
/** Font family name (default: 'Mock Font') */
family?: string;
/** Font category (default: 'sans-serif') */
category?: 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
variants?: FontVariant[];
/** Font subsets (default: ['latin']) */
subsets?: string[];
/** Font version (default: 'v30') */
version?: string;
/** Last modified date (default: current ISO date) */
lastModified?: string;
/** Custom file URLs (if not provided, mock URLs are generated) */
files?: Partial<Record<FontVariant, string>>;
/** Popularity rank (1 = most popular) */
popularity?: number;
}
/**
* Default mock Google Font
*/
export function mockGoogleFont(options: MockGoogleFontOptions = {}): GoogleFontItem {
const {
family = 'Mock Font',
category = 'sans-serif',
variants = ['regular', '700', 'italic', '700italic'],
subsets = ['latin'],
version = 'v30',
lastModified = new Date().toISOString().split('T')[0],
files,
popularity = 1,
} = options;
const baseUrl = `https://fonts.gstatic.com/s/${family.toLowerCase().replace(/\s+/g, '')}/${version}`;
return {
family,
category,
variants: variants as FontVariant[],
subsets,
version,
lastModified,
files: files ?? {
regular: `${baseUrl}/KFOmCnqEu92Fr1Me4W.woff2`,
'700': `${baseUrl}/KFOlCnqEu92Fr1MmWUlfBBc9.woff2`,
italic: `${baseUrl}/KFOkCnqEu92Fr1Mu51xIIzI.woff2`,
'700italic': `${baseUrl}/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2`,
},
menu: `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}`,
};
}
/**
* Preset Google Font mocks
*/
export const GOOGLE_FONTS: Record<string, GoogleFontItem> = {
roboto: mockGoogleFont({
family: 'Roboto',
category: 'sans-serif',
variants: ['100', '300', '400', '500', '700', '900', 'italic', '700italic'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
popularity: 1,
}),
openSans: mockGoogleFont({
family: 'Open Sans',
category: 'sans-serif',
variants: ['300', '400', '500', '600', '700', '800', 'italic', '700italic'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
popularity: 2,
}),
lato: mockGoogleFont({
family: 'Lato',
category: 'sans-serif',
variants: ['100', '300', '400', '700', '900', 'italic', '700italic'],
subsets: ['latin', 'latin-ext'],
popularity: 3,
}),
playfairDisplay: mockGoogleFont({
family: 'Playfair Display',
category: 'serif',
variants: ['400', '500', '600', '700', '800', '900', 'italic', '700italic'],
subsets: ['latin', 'latin-ext', 'cyrillic'],
popularity: 10,
}),
montserrat: mockGoogleFont({
family: 'Montserrat',
category: 'sans-serif',
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic', '700italic'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
popularity: 4,
}),
sourceSansPro: mockGoogleFont({
family: 'Source Sans Pro',
category: 'sans-serif',
variants: ['200', '300', '400', '600', '700', '900', 'italic', '700italic'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
popularity: 5,
}),
merriweather: mockGoogleFont({
family: 'Merriweather',
category: 'serif',
variants: ['300', '400', '700', '900', 'italic', '700italic'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
popularity: 15,
}),
robotoSlab: mockGoogleFont({
family: 'Roboto Slab',
category: 'serif',
variants: ['100', '300', '400', '500', '700', '900'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
popularity: 8,
}),
oswald: mockGoogleFont({
family: 'Oswald',
category: 'sans-serif',
variants: ['200', '300', '400', '500', '600', '700'],
subsets: ['latin', 'latin-ext', 'vietnamese'],
popularity: 6,
}),
raleway: mockGoogleFont({
family: 'Raleway',
category: 'sans-serif',
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic'],
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
popularity: 7,
}),
};
// ============================================================================
// FONTHARE MOCKS
// ============================================================================
/**
* Options for creating a mock Fontshare font
*/
export interface MockFontshareFontOptions {
/** Font name (default: 'Mock Font') */
name?: string;
/** URL-friendly slug (default: derived from name) */
slug?: string;
/** Font category (default: 'sans') */
category?: 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script' | 'mono';
/** Script (default: 'latin') */
script?: string;
/** Whether this is a variable font (default: false) */
isVariable?: boolean;
/** Font version (default: '1.0') */
version?: string;
/** Popularity/views count (default: 1000) */
views?: number;
/** Usage tags */
tags?: string[];
/** Font weights available */
weights?: number[];
/** Publisher name */
publisher?: string;
/** Designer name */
designer?: string;
}
/**
* Create a mock Fontshare style
*/
function mockFontshareStyle(
weight: number,
isItalic: boolean,
isVariable: boolean,
slug: string,
): FontshareFont['styles'][number] {
const weightLabel = weight === 400 ? 'Regular' : weight === 700 ? 'Bold' : weight.toString();
const suffix = isItalic ? 'italic' : '';
const variablePrefix = isVariable ? 'variable-' : '';
return {
id: `style-${weight}${isItalic ? '-italic' : ''}`,
default: weight === 400 && !isItalic,
file: `//cdn.fontshare.com/wf/${slug}-${variablePrefix}${weight}${suffix}.woff2`,
is_italic: isItalic,
is_variable: isVariable,
properties: {},
weight: {
label: isVariable ? 'Variable' + (isItalic ? ' Italic' : '') : weightLabel,
name: isVariable ? 'Variable' + (isItalic ? 'Italic' : '') : weightLabel,
native_name: null,
number: isVariable ? 0 : weight,
weight: isVariable ? 0 : weight,
},
};
}
/**
* Default mock Fontshare font
*/
export function mockFontshareFont(options: MockFontshareFontOptions = {}): FontshareFont {
const {
name = 'Mock Font',
slug = name.toLowerCase().replace(/\s+/g, '-'),
category = 'sans',
script = 'latin',
isVariable = false,
version = '1.0',
views = 1000,
tags = [],
weights = [400, 700],
publisher = 'Mock Foundry',
designer = 'Mock Designer',
} = options;
// Generate styles based on weights and variable setting
const styles: FontshareFont['styles'] = isVariable
? [
mockFontshareStyle(0, false, true, slug),
mockFontshareStyle(0, true, true, slug),
]
: weights.flatMap(weight => [
mockFontshareStyle(weight, false, false, slug),
mockFontshareStyle(weight, true, false, slug),
]);
return {
id: `mock-${slug}`,
name,
native_name: null,
slug,
category,
script,
publisher: {
bio: `Mock publisher bio for ${publisher}`,
email: null,
id: `pub-${slug}`,
links: [],
name: publisher,
},
designers: [
{
bio: `Mock designer bio for ${designer}`,
links: [],
name: designer,
},
],
related_families: null,
display_publisher_as_designer: false,
trials_enabled: true,
show_latin_metrics: false,
license_type: 'ofl',
languages: 'English, Spanish, French, German',
inserted_at: '2021-03-12T20:49:05Z',
story: `<p>A mock font story for ${name}.</p>`,
version,
views,
views_recent: Math.floor(views * 0.1),
is_hot: views > 5000,
is_new: views < 500,
is_shortlisted: null,
is_top: views > 10000,
axes: isVariable
? [
{
name: 'Weight',
property: 'wght',
range_default: 400,
range_left: 300,
range_right: 700,
},
]
: [],
font_tags: tags.map(name => ({ name })),
features: [],
styles,
};
}
/**
* Preset Fontshare font mocks
*/
export const FONTHARE_FONTS: Record<string, FontshareFont> = {
satoshi: mockFontshareFont({
name: 'Satoshi',
slug: 'satoshi',
category: 'sans',
isVariable: true,
views: 15000,
tags: ['Branding', 'Logos', 'Editorial'],
publisher: 'Indian Type Foundry',
designer: 'Denis Shelabovets',
}),
generalSans: mockFontshareFont({
name: 'General Sans',
slug: 'general-sans',
category: 'sans',
isVariable: true,
views: 12000,
tags: ['UI', 'Branding', 'Display'],
publisher: 'Indestructible Type',
designer: 'Eugene Tantsur',
}),
clashDisplay: mockFontshareFont({
name: 'Clash Display',
slug: 'clash-display',
category: 'display',
isVariable: false,
views: 8000,
tags: ['Headlines', 'Posters', 'Branding'],
weights: [400, 500, 600, 700],
publisher: 'Letterogika',
designer: 'Matěj Trnka',
}),
fonta: mockFontshareFont({
name: 'Fonta',
slug: 'fonta',
category: 'serif',
isVariable: false,
views: 5000,
tags: ['Editorial', 'Books', 'Magazines'],
weights: [300, 400, 500, 600, 700],
publisher: 'Fonta',
designer: 'Alexei Vanyashin',
}),
aileron: mockFontshareFont({
name: 'Aileron',
slug: 'aileron',
category: 'sans',
isVariable: false,
views: 3000,
tags: ['Display', 'Headlines'],
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
publisher: 'Sorkin Type',
designer: 'Sorkin Type',
}),
beVietnamPro: mockFontshareFont({
name: 'Be Vietnam Pro',
slug: 'be-vietnam-pro',
category: 'sans',
isVariable: true,
views: 20000,
tags: ['UI', 'App', 'Web'],
publisher: 'ildefox',
designer: 'Manh Nguyen',
}),
};
// ============================================================================
// UNIFIED FONT MOCKS
// ============================================================================
/**
* Options for creating a mock UnifiedFont
*/
export interface MockUnifiedFontOptions {
/** Unique identifier (default: derived from name) */
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,84 @@
/**
* ============================================================================
* 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 {
FONTHARE_FONTS,
generateMixedCategoryFonts,
generateMockFonts,
getAllMockFonts,
getFontsByCategory,
getFontsByProvider,
GOOGLE_FONTS,
mockFontshareFont,
type MockFontshareFontOptions,
mockGoogleFont,
type MockGoogleFontOptions,
mockUnifiedFont,
type MockUnifiedFontOptions,
UNIFIED_FONTS,
} from './fonts.mock';
// Filter mocks
export {
createCategoriesFilter,
createGenericFilter,
createMockFilter,
createProvidersFilter,
createSubsetsFilter,
FONT_PROVIDERS,
FONT_SUBSETS,
FONTHARE_CATEGORIES,
generateSequentialFilter,
GENERIC_FILTERS,
GOOGLE_CATEGORIES,
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,590 @@
/**
* ============================================================================
* 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 = MOCK_STORES.unifiedFontStore();
* ```
*/
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: () => {},
};
},
};
// ============================================================================
// 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,582 @@
import {
describe,
expect,
it,
} from 'vitest';
import type {
FontItem,
FontshareFont,
GoogleFontItem,
} from '../../model/types';
import {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './normalize';
describe('Font Normalization', () => {
describe('normalizeGoogleFont', () => {
const mockGoogleFont: GoogleFontItem = {
family: 'Roboto',
category: 'sans-serif',
variants: ['regular', '700', 'italic', '700italic'],
subsets: ['latin', 'latin-ext'],
files: {
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
'700italic': 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
},
version: 'v30',
lastModified: '2022-01-01',
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
};
it('normalizes Google Font to unified model', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.id).toBe('Roboto');
expect(result.name).toBe('Roboto');
expect(result.provider).toBe('google');
expect(result.category).toBe('sans-serif');
});
it('maps font variants correctly', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.variants).toEqual(['regular', '700', 'italic', '700italic']);
});
it('maps subsets correctly', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.subsets).toContain('latin');
expect(result.subsets).toContain('latin-ext');
expect(result.subsets).toHaveLength(2);
});
it('maps style URLs correctly', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.styles.regular).toBeDefined();
expect(result.styles.bold).toBeDefined();
expect(result.styles.italic).toBeDefined();
expect(result.styles.boldItalic).toBeDefined();
});
it('includes metadata', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.metadata.cachedAt).toBeDefined();
expect(result.metadata.version).toBe('v30');
expect(result.metadata.lastModified).toBe('2022-01-01');
});
it('marks Google Fonts as non-variable', () => {
const result = normalizeGoogleFont(mockGoogleFont);
expect(result.features.isVariable).toBe(false);
expect(result.features.tags).toEqual([]);
});
it('handles sans-serif category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'sans-serif' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('sans-serif');
});
it('handles serif category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'serif' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('serif');
});
it('handles display category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'display' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('display');
});
it('handles handwriting category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'handwriting' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('handwriting');
});
it('handles cursive category (maps to handwriting)', () => {
const font: FontItem = { ...mockGoogleFont, category: 'cursive' as any };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('handwriting');
});
it('handles monospace category', () => {
const font: FontItem = { ...mockGoogleFont, category: 'monospace' };
const result = normalizeGoogleFont(font);
expect(result.category).toBe('monospace');
});
it('filters invalid subsets', () => {
const font = {
...mockGoogleFont,
subsets: ['latin', 'latin-ext', 'invalid-subset'],
};
const result = normalizeGoogleFont(font);
expect(result.subsets).not.toContain('invalid-subset');
expect(result.subsets).toHaveLength(2);
});
it('maps variant weights correctly', () => {
const font: GoogleFontItem = {
...mockGoogleFont,
variants: ['regular', '100', '400', '700', '900'] as any,
};
const result = normalizeGoogleFont(font);
expect(result.variants).toContain('regular');
expect(result.variants).toContain('100');
expect(result.variants).toContain('400');
expect(result.variants).toContain('700');
expect(result.variants).toContain('900');
});
});
describe('normalizeFontshareFont', () => {
const mockFontshareFont: FontshareFont = {
id: '20e9fcdc-1e41-4559-a43d-1ede0adc8896',
name: 'Satoshi',
native_name: null,
slug: 'satoshi',
category: 'Sans',
script: 'latin',
publisher: {
bio: 'Indian Type Foundry',
email: null,
id: 'test-id',
links: [],
name: 'Indian Type Foundry',
},
designers: [
{
bio: 'Designer bio',
links: [],
name: 'Designer Name',
},
],
related_families: null,
display_publisher_as_designer: false,
trials_enabled: true,
show_latin_metrics: false,
license_type: 'itf_ffl',
languages: 'Afar, Afrikaans',
inserted_at: '2021-03-12T20:49:05Z',
story: '<p>Font story</p>',
version: '1.0',
views: 10000,
views_recent: 500,
is_hot: true,
is_new: false,
is_shortlisted: false,
is_top: true,
axes: [],
font_tags: [
{ name: 'Branding' },
{ name: 'Logos' },
],
features: [
{
name: 'Alternate t',
on_by_default: false,
tag: 'ss01',
},
],
styles: [
{
id: 'style-id-1',
default: true,
file: '//cdn.fontshare.com/wf/satoshi.woff2',
is_italic: false,
is_variable: false,
properties: {},
weight: {
label: 'Regular',
name: 'Regular',
native_name: null,
number: 400,
weight: 400,
},
},
{
id: 'style-id-2',
default: false,
file: '//cdn.fontshare.com/wf/satoshi-bold.woff2',
is_italic: false,
is_variable: false,
properties: {},
weight: {
label: 'Bold',
name: 'Bold',
native_name: null,
number: 700,
weight: 700,
},
},
{
id: 'style-id-3',
default: false,
file: '//cdn.fontshare.com/wf/satoshi-italic.woff2',
is_italic: true,
is_variable: false,
properties: {},
weight: {
label: 'Regular',
name: 'Regular',
native_name: null,
number: 400,
weight: 400,
},
},
{
id: 'style-id-4',
default: false,
file: '//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
is_italic: true,
is_variable: false,
properties: {},
weight: {
label: 'Bold',
name: 'Bold',
native_name: null,
number: 700,
weight: 700,
},
},
],
};
it('normalizes Fontshare font to unified model', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.id).toBe('satoshi');
expect(result.name).toBe('Satoshi');
expect(result.provider).toBe('fontshare');
expect(result.category).toBe('sans-serif');
});
it('uses slug as unique identifier', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.id).toBe('satoshi');
});
it('extracts variant names from styles', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.variants).toContain('Regular');
expect(result.variants).toContain('Bold');
expect(result.variants).toContain('Regularitalic');
expect(result.variants).toContain('Bolditalic');
});
it('maps Fontshare Sans to sans-serif category', () => {
const font = { ...mockFontshareFont, category: 'Sans' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('sans-serif');
});
it('maps Fontshare Serif to serif category', () => {
const font = { ...mockFontshareFont, category: 'Serif' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('serif');
});
it('maps Fontshare Display to display category', () => {
const font = { ...mockFontshareFont, category: 'Display' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('display');
});
it('maps Fontshare Script to handwriting category', () => {
const font = { ...mockFontshareFont, category: 'Script' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('handwriting');
});
it('maps Fontshare Mono to monospace category', () => {
const font = { ...mockFontshareFont, category: 'Mono' };
const result = normalizeFontshareFont(font);
expect(result.category).toBe('monospace');
});
it('maps style URLs correctly', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.styles.regular).toBe('//cdn.fontshare.com/wf/satoshi.woff2');
expect(result.styles.bold).toBe('//cdn.fontshare.com/wf/satoshi-bold.woff2');
expect(result.styles.italic).toBe('//cdn.fontshare.com/wf/satoshi-italic.woff2');
expect(result.styles.boldItalic).toBe(
'//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
);
});
it('handles variable fonts', () => {
const variableFont: FontshareFont = {
...mockFontshareFont,
axes: [
{
name: 'wght',
property: 'wght',
range_default: 400,
range_left: 300,
range_right: 900,
},
],
styles: [
{
id: 'var-style',
default: true,
file: '//cdn.fontshare.com/wf/satoshi-variable.woff2',
is_italic: false,
is_variable: true,
properties: {},
weight: {
label: 'Variable',
name: 'Variable',
native_name: null,
number: 0,
weight: 0,
},
},
],
};
const result = normalizeFontshareFont(variableFont);
expect(result.features.isVariable).toBe(true);
expect(result.features.axes).toHaveLength(1);
expect(result.features.axes?.[0].name).toBe('wght');
});
it('extracts font tags', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.features.tags).toContain('Branding');
expect(result.features.tags).toContain('Logos');
expect(result.features.tags).toHaveLength(2);
});
it('includes popularity from views', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.metadata.popularity).toBe(10000);
});
it('includes metadata', () => {
const result = normalizeFontshareFont(mockFontshareFont);
expect(result.metadata.cachedAt).toBeDefined();
expect(result.metadata.version).toBe('1.0');
expect(result.metadata.lastModified).toBe('2021-03-12T20:49:05Z');
});
it('handles missing subsets gracefully', () => {
const font = {
...mockFontshareFont,
script: 'invalid-script',
};
const result = normalizeFontshareFont(font);
expect(result.subsets).toEqual([]);
});
it('handles empty tags', () => {
const font = {
...mockFontshareFont,
font_tags: [],
};
const result = normalizeFontshareFont(font);
expect(result.features.tags).toBeUndefined();
});
it('handles empty axes', () => {
const font = {
...mockFontshareFont,
axes: [],
};
const result = normalizeFontshareFont(font);
expect(result.features.isVariable).toBe(false);
expect(result.features.axes).toBeUndefined();
});
});
describe('normalizeGoogleFonts', () => {
it('normalizes array of Google Fonts', () => {
const fonts: GoogleFontItem[] = [
{
family: 'Roboto',
category: 'sans-serif',
variants: ['regular'],
subsets: ['latin'],
files: { regular: 'url' },
version: 'v1',
lastModified: '2022-01-01',
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
},
{
family: 'Open Sans',
category: 'sans-serif',
variants: ['regular'],
subsets: ['latin'],
files: { regular: 'url' },
version: 'v1',
lastModified: '2022-01-01',
menu: 'https://fonts.googleapis.com/css2?family=Open+Sans',
},
];
const result = normalizeGoogleFonts(fonts);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Roboto');
expect(result[1].name).toBe('Open Sans');
});
it('returns empty array for empty input', () => {
const result = normalizeGoogleFonts([]);
expect(result).toEqual([]);
});
});
describe('normalizeFontshareFonts', () => {
it('normalizes array of Fontshare fonts', () => {
const fonts: FontshareFont[] = [
{
...mockMinimalFontshareFont('font1', 'Font 1'),
},
{
...mockMinimalFontshareFont('font2', 'Font 2'),
},
];
const result = normalizeFontshareFonts(fonts);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Font 1');
expect(result[1].name).toBe('Font 2');
});
it('returns empty array for empty input', () => {
const result = normalizeFontshareFonts([]);
expect(result).toEqual([]);
});
});
describe('edge cases', () => {
it('handles Google Font with missing optional fields', () => {
const font: Partial<GoogleFontItem> = {
family: 'Test Font',
category: 'sans-serif',
variants: ['regular'],
subsets: ['latin'],
files: { regular: 'url' },
};
const result = normalizeGoogleFont(font as GoogleFontItem);
expect(result.id).toBe('Test Font');
expect(result.metadata.version).toBeUndefined();
expect(result.metadata.lastModified).toBeUndefined();
});
it('handles Fontshare font with minimal data', () => {
const result = normalizeFontshareFont(mockMinimalFontshareFont('slug', 'Name'));
expect(result.id).toBe('slug');
expect(result.name).toBe('Name');
expect(result.provider).toBe('fontshare');
});
it('handles unknown Fontshare category', () => {
const font = {
...mockMinimalFontshareFont('slug', 'Name'),
category: 'Unknown Category',
};
const result = normalizeFontshareFont(font);
expect(result.category).toBe('sans-serif'); // fallback
});
});
});
/**
* Helper function to create minimal Fontshare font mock
*/
function mockMinimalFontshareFont(slug: string, name: string): FontshareFont {
return {
id: 'test-id',
name,
native_name: null,
slug,
category: 'Sans',
script: 'latin',
publisher: {
bio: '',
email: null,
id: '',
links: [],
name: '',
},
designers: [],
related_families: null,
display_publisher_as_designer: false,
trials_enabled: false,
show_latin_metrics: false,
license_type: '',
languages: '',
inserted_at: '',
story: '',
version: '1.0',
views: 0,
views_recent: 0,
is_hot: false,
is_new: false,
is_shortlisted: null,
is_top: false,
axes: [],
font_tags: [],
features: [],
styles: [
{
id: 'style-id',
default: true,
file: '//cdn.fontshare.com/wf/test.woff2',
is_italic: false,
is_variable: false,
properties: {},
weight: {
label: 'Regular',
name: 'Regular',
native_name: null,
number: 400,
weight: 400,
},
},
],
};
}

View File

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

View File

@@ -1,16 +0,0 @@
import type { Category } from '$shared/store/createFilterStore';
/**
* Font category
*/
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
/**
* Font provider
*/
export type FontProvider = 'google' | 'fontshare';
/**
* Font subset
*/
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';

View File

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

View File

@@ -0,0 +1,142 @@
/** @vitest-environment jsdom */
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { AppliedFontsManager } from './appliedFontsStore.svelte';
describe('AppliedFontsManager', () => {
let manager: AppliedFontsManager;
let mockFontFaceSet: any;
let mockFetch: any;
let failUrls: Set<string>;
beforeEach(() => {
vi.useFakeTimers();
failUrls = new Set();
mockFontFaceSet = {
add: vi.fn(),
delete: vi.fn(),
};
// 1. Properly mock FontFace as a constructor function
// The actual implementation passes buffer (ArrayBuffer) as second arg, not URL string
const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) {
this.name = name;
this.bufferOrUrl = bufferOrUrl;
this.load = vi.fn().mockImplementation(() => {
// For error tests, we track which URLs should fail via failUrls
// The fetch mock will have already rejected for those URLs
return Promise.resolve(this);
});
});
vi.stubGlobal('FontFace', MockFontFace);
// 2. Mock document.fonts safely
Object.defineProperty(document, 'fonts', {
value: mockFontFaceSet,
configurable: true,
writable: true,
});
vi.stubGlobal('crypto', {
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any,
});
// 3. Mock fetch to return fake ArrayBuffer data
mockFetch = vi.fn((url: string) => {
if (failUrls.has(url)) {
return Promise.reject(new Error('Network error'));
}
return Promise.resolve({
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
clone: () => ({
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
}),
} as Response);
});
vi.stubGlobal('fetch', mockFetch);
manager = new AppliedFontsManager();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('should batch multiple font requests into a single process', async () => {
const configs = [
{ id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 },
{ id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 },
];
manager.touch(configs);
// Advance to trigger the 16ms debounced #processQueue
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('lato-400', 400)).toBe('loaded');
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
});
it('should handle font loading errors gracefully', async () => {
// Suppress expected console error for clean test logs
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failUrl = 'https://example.com/fail.ttf';
failUrls.add(failUrl);
const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 };
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('broken', 400)).toBe('error');
spy.mockRestore();
});
it('should purge fonts after TTL expires', async () => {
const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded');
// Move clock forward past TTL (5m) and Purge Interval (1m)
// advanceTimersByTimeAsync is key here; it handles the promises inside the interval
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
expect(mockFontFaceSet.delete).toHaveBeenCalled();
});
it('should NOT purge fonts that are still being "touched"', async () => {
const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 };
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
// Advance 4 minutes
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);
// Refresh touch
manager.touch([config]);
// Advance another 2 minutes (Total 6 since start)
await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
expect(manager.getFontStatus('active', 400)).toBe('loaded');
});
});

View File

@@ -0,0 +1,354 @@
import { SvelteMap } from 'svelte/reactivity';
/** Loading state of a font. Failed loads may be retried up to MAX_RETRIES. */
export type FontStatus = 'loading' | 'loaded' | 'error';
/** Configuration for a font load request. */
export interface FontConfigRequest {
/**
* Unique identifier for the font (e.g., "lato", "roboto").
*/
id: string;
/**
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
*/
name: string;
/**
* URL pointing to the font file (typically .ttf or .woff2).
*/
url: string;
/**
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
*/
weight: number;
/**
* Variable fonts load once per ID; static fonts load per weight.
*/
isVariable?: boolean;
}
/**
* 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 {
// Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf`
#loadedFonts = new Map<string, FontFace>();
// Last-used timestamps for LRU cleanup. Key: `{id}@{weight}` or `{id}@vf`, Value: unix timestamp (ms)
#usageTracker = new Map<string, number>();
// Fonts queued for loading by `touch()`, processed by `#processQueue()`
#queue = new Map<string, FontConfigRequest>();
// 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;
// Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped
#retryCounts = new Map<string, number>();
readonly #MAX_RETRIES = 3;
readonly #PURGE_INTERVAL = 60000; // 60 seconds
readonly #TTL = 5 * 60 * 1000; // 5 minutes
readonly #CACHE_NAME = 'font-cache-v1'; // Versioned for future invalidation
// Reactive status map for Svelte components to track font states
statuses = new SvelteMap<string, FontStatus>();
// Starts periodic cleanup timer (browser-only).
constructor() {
if (typeof window !== 'undefined') {
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
}
}
// Generates font key: `{id}@vf` for variable, `{id}@{weight}` for static.
#getFontKey(id: string, weight: number, isVariable: boolean): string {
return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`;
}
/**
* Requests fonts to be loaded. Updates usage tracking and queues new fonts.
*
* Retry behavior: 'loaded' and 'loading' fonts are skipped; 'error' fonts retry if count < MAX_RETRIES.
* Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms).
*/
touch(configs: FontConfigRequest[]) {
if (this.#abortController.signal.aborted) return;
const now = Date.now();
let hasNewItems = false;
for (const config of configs) {
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
this.#usageTracker.set(key, now);
const status = this.statuses.get(key);
if (status === 'loaded' || status === 'loading' || this.#queue.has(key)) continue;
if (status === 'error' && (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES) continue;
this.#queue.set(key, config);
hasNewItems = true;
}
if (hasNewItems && !this.#timeoutId) {
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';
}
}
}
/** Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() (Chrome/Edge) or MessageChannel fallback. */
async #yieldToMain(): Promise<void> {
// @ts-expect-error - scheduler not in TypeScript lib yet
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
await scheduler.yield();
} else {
await new Promise<void>(resolve => {
const ch = new MessageChannel();
ch.port1.onmessage = () => resolve();
ch.port2.postMessage(null);
});
}
}
/** Returns optimal concurrent fetches based on Network Information API: 1 for 2G, 2 for 3G, 4 for 4G/default. */
#getEffectiveConcurrency(): number {
const nav = navigator as any;
const conn = nav.connection;
if (!conn) return 4;
switch (conn.effectiveType) {
case 'slow-2g':
case '2g':
return 1;
case '3g':
return 2;
default:
return 4;
}
}
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
#shouldDeferNonCritical(): boolean {
const nav = navigator as any;
return nav.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() {
this.#timeoutId = null;
this.#pendingType = null;
let entries = Array.from(this.#queue.entries());
if (!entries.length) return;
this.#queue.clear();
if (this.#shouldDeferNonCritical()) {
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
}
// Phase 1: Concurrent fetching (I/O bound, non-blocking)
const concurrency = this.#getEffectiveConcurrency();
const buffers = new Map<string, ArrayBuffer>();
for (let i = 0; i < entries.length; i += concurrency) {
const chunk = entries.slice(i, i + concurrency);
const results = await Promise.allSettled(
chunk.map(async ([key, config]) => {
this.statuses.set(key, 'loading');
const buffer = await this.#fetchFontBuffer(
config.url,
this.#abortController.signal,
);
buffers.set(key, buffer);
}),
);
for (let j = 0; j < results.length; j++) {
if (results[j].status === 'rejected') {
const [key, config] = chunk[j];
console.error(`Font fetch failed: ${config.name}`, (results[j] as PromiseRejectedResult).reason);
this.statuses.set(key, 'error');
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
}
}
}
// Phase 2: Sequential parsing (CPU-intensive, yields periodically)
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
let lastYield = performance.now();
const YIELD_INTERVAL = 8; // ms
for (const [key, config] of entries) {
const buffer = buffers.get(key);
if (!buffer) continue;
try {
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
const font = new FontFace(config.name, buffer, {
weight: weightRange,
style: 'normal',
display: 'swap',
});
await font.load();
document.fonts.add(font);
this.#loadedFonts.set(key, font);
this.statuses.set(key, 'loaded');
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') continue;
console.error(`Font parse failed: ${config.name}`, e);
this.statuses.set(key, 'error');
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
}
const shouldYield = hasInputPending
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
: (performance.now() - lastYield > YIELD_INTERVAL);
if (shouldYield) {
await this.#yieldToMain();
lastYield = performance.now();
}
}
}
/**
* Fetches font with cache-aside pattern: checks Cache API first, falls back to network.
* Cache failures (private browsing, quota limits) are silently ignored.
*/
async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
try {
if (typeof caches !== 'undefined') {
const cache = await caches.open(this.#CACHE_NAME);
const cached = await cache.match(url);
if (cached) return cached.arrayBuffer();
}
} catch {
// Cache unavailable (private browsing, security restrictions) — fall through to network
}
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
try {
if (typeof caches !== 'undefined') {
const cache = await caches.open(this.#CACHE_NAME);
await cache.put(url, response.clone());
}
} catch {
// Cache write failed (quota, storage pressure) — return font anyway
}
return response.arrayBuffer();
}
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. */
#purgeUnused() {
const now = Date.now();
for (const [key, lastUsed] of this.#usageTracker) {
if (now - lastUsed < this.#TTL) continue;
const font = this.#loadedFonts.get(key);
if (font) document.fonts.delete(font);
this.#loadedFonts.delete(key);
this.#usageTracker.delete(key);
this.statuses.delete(key);
this.#retryCounts.delete(key);
}
}
/** Returns current loading status for a font, or undefined if never requested. */
getFontStatus(id: string, weight: number, isVariable = false) {
return this.statuses.get(this.#getFontKey(id, weight, isVariable));
}
/** 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.fonts.ready can reject in some edge cases
// (e.g., document unloaded). Silently resolve.
}
}
/** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */
destroy() {
this.#abortController.abort();
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;
}
if (this.#intervalId) {
clearInterval(this.#intervalId);
this.#intervalId = null;
}
if (typeof document !== 'undefined') {
for (const font of this.#loadedFonts.values()) {
document.fonts.delete(font);
}
}
this.#loadedFonts.clear();
this.#usageTracker.clear();
this.#retryCounts.clear();
this.statuses.clear();
this.#queue.clear();
}
}
/** Singleton instance — use throughout the application for unified font loading state. */
export const appliedFontsManager = new AppliedFontsManager();

View File

@@ -0,0 +1,158 @@
import { queryClient } from '$shared/api/queryClient';
import {
type QueryKey,
QueryObserver,
type QueryObserverOptions,
type QueryObserverResult,
} from '@tanstack/query-core';
import type { UnifiedFont } from '../types';
/** */
export abstract class BaseFontStore<TParams extends Record<string, any>> {
cleanup: () => void;
#bindings = $state<(() => Partial<TParams>)[]>([]);
#internalParams = $state<TParams>({} as TParams);
params = $derived.by(() => {
let merged = { ...this.#internalParams };
// Loop through every "Cable" plugged into the store
// Loop through every "Cable" plugged into the store
for (const getter of this.#bindings) {
const bindingResult = getter();
merged = { ...merged, ...bindingResult };
}
return merged as TParams;
});
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
protected observer: QueryObserver<UnifiedFont[], Error>;
protected qc = queryClient;
constructor(initialParams: TParams) {
this.#internalParams = initialParams;
this.observer = new QueryObserver(this.qc, this.getOptions());
// Sync TanStack -> Svelte State
this.observer.subscribe(r => {
this.result = r;
});
// Sync Svelte State -> TanStack Options
this.cleanup = $effect.root(() => {
$effect(() => {
this.observer.setOptions(this.getOptions());
});
});
}
/**
* Mandatory: Child must define how to fetch data and what the key is.
*/
protected abstract getQueryKey(params: TParams): QueryKey;
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
return {
queryKey: this.getQueryKey(params),
queryFn: () => this.fetchFn(params),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
};
}
// --- Common Getters ---
get fonts() {
return this.result.data ?? [];
}
get isLoading() {
return this.result.isLoading;
}
get isFetching() {
return this.result.isFetching;
}
get isError() {
return this.result.isError;
}
get isEmpty() {
return !this.isLoading && this.fonts.length === 0;
}
// --- Common Actions ---
addBinding(getter: () => Partial<TParams>) {
this.#bindings.push(getter);
return () => {
this.#bindings = this.#bindings.filter(b => b !== getter);
};
}
setParams(newParams: Partial<TParams>) {
this.#internalParams = { ...this.params, ...newParams };
}
/**
* Invalidate cache and refetch
*/
invalidate() {
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
}
destroy() {
this.cleanup();
}
/**
* Manually refetch
*/
async refetch() {
await this.observer.refetch();
}
/**
* Prefetch with different params (for hover states, pagination, etc.)
*/
async prefetch(params: TParams) {
await this.qc.prefetchQuery(this.getOptions(params));
}
/**
* Cancel ongoing queries
*/
cancel() {
this.qc.cancelQueries({
queryKey: this.getQueryKey(this.params),
});
}
/**
* Clear cache for current params
*/
clearCache() {
this.qc.removeQueries({
queryKey: this.getQueryKey(this.params),
});
}
/**
* Get cached data without triggering fetch
*/
getCachedData() {
return this.qc.getQueryData<UnifiedFont[]>(
this.getQueryKey(this.params),
);
}
/**
* Set data manually (optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
this.qc.setQueryData(
this.getQueryKey(this.params),
updater,
);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
/**
* ============================================================================
* DOMAIN TYPES
* ============================================================================
*/
import type { FontCategory as FontshareFontCategory } from './fontshare';
import type { FontCategory as GoogleFontCategory } from './google';
/**
* Font category
*/
export type FontCategory = GoogleFontCategory | FontshareFontCategory;
/**
* Font provider
*/
export type FontProvider = 'google' | 'fontshare';
/**
* Font subset
*/
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
/**
* Filter state
*/
export interface FontFilters {
providers: FontProvider[];
categories: FontCategory[];
subsets: FontSubset[];
}
export type CheckboxFilter = 'providers' | 'categories' | 'subsets';
export type FilterType = CheckboxFilter | 'searchQuery';
/**
* Standard font weights
*/
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
* - Numeric weights: "400", "700", etc.
* - Italic variants: "400italic", "700italic", etc.
* - Legacy names: "regular", "italic", "bold", "bolditalic"
*/
export type FontVariant =
| FontWeight
| FontWeightItalic
| 'regular'
| 'italic'
| 'bold'
| 'bolditalic';

View File

@@ -1,12 +1,39 @@
import type { CollectionApiModel } from '../../../shared/types/collection';
/**
* ============================================================================
* FONTHARE API TYPES
* ============================================================================
*/
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2/fonts' as const;
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2' as const;
export type FontCategory = 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script';
/**
* Model of Fontshare API response
* @see https://fontshare.com
*
* Fontshare API uses 'fonts' key instead of 'items' for the array
*/
export type FontshareApiModel = CollectionApiModel<FontshareFont>;
export interface FontshareApiModel {
/**
* Number of items returned in current page/response
*/
count: number;
/**
* Total number of items available across all pages
*/
count_total: number;
/**
* Indicates if there are more items available beyond this page
*/
has_more: boolean;
/**
* Array of fonts (Fontshare uses 'fonts' key, not 'items')
*/
fonts: FontshareFont[];
}
/**
* Individual font metadata from Fontshare API

View File

@@ -1,3 +1,13 @@
/**
* ============================================================================
* GOOGLE FONTS API TYPES
* ============================================================================
*/
import type { FontVariant } from './common';
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
/**
* Model of google fonts api response
*/
@@ -9,6 +19,9 @@ export interface GoogleFontsApiModel {
items: FontItem[];
}
/**
* Individual font from Google Fonts API
*/
export interface FontItem {
/**
* Font family name (e.g., "Roboto", "Open Sans", "Lato")
@@ -20,7 +33,7 @@ export interface FontItem {
* Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace")
* Useful for grouping and filtering fonts by style
*/
category: string;
category: FontCategory;
/**
* Available font variants for this font family
@@ -70,28 +83,10 @@ export interface FontItem {
}
/**
* Standard font weights that can appear in Google Fonts API
* Type alias for backward compatibility
* Google Fonts API font item
*/
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';
export type GoogleFontItem = FontItem;
/**
* Google Fonts API file mapping

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { type UnifiedFont } from '../../model';
interface Props {
/**
* Object with information about font
*/
font: UnifiedFont;
/**
* Is element fully visible
*/
isFullyVisible: boolean;
/**
* Is element partially visible
*/
isPartiallyVisible: boolean;
/**
* From 0 to 1
*/
proximity: number;
/**
* Children snippet
*/
children: Snippet<[font: UnifiedFont]>;
}
const { font, children }: Props = $props();
</script>
<div
class={cn(
'pb-1 will-change-transform transition-transform duration-200 ease-out',
'hover:scale-[0.98]', // Simple CSS hover effect
)}
>
{@render children?.(font)}
</div>

View File

@@ -0,0 +1,129 @@
<!--
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 FontConfigRequest,
type UnifiedFont,
appliedFontsManager,
unifiedFontStore,
} from '../../model';
interface Props extends
Omit<
ComponentProps<typeof VirtualList<UnifiedFont>>,
'items' | 'total' | 'isLoading' | 'onVisibleItemsChange' | 'onNearBottom'
>
{
/**
* Callback for when visible items change
*/
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
/**
* Weight of the font
*/
weight: number;
/**
* Skeleton snippet
*/
skeleton?: Snippet;
}
let {
children,
onVisibleItemsChange,
weight,
skeleton,
...rest
}: Props = $props();
const isLoading = $derived(
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
);
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
const configs: FontConfigRequest[] = [];
visibleItems.forEach(item => {
const url = getFontUrl(item, weight);
if (url) {
configs.push({
id: item.id,
name: item.name,
weight,
url,
isVariable: item.features?.isVariable,
});
}
});
// Auto-register fonts with the manager
appliedFontsManager.touch(configs);
// Forward the call to any external listener
// onVisibleItemsChange?.(visibleItems);
}
/**
* Load more fonts by moving to the next page
*/
function loadMore() {
if (
!unifiedFontStore.pagination.hasMore
|| unifiedFontStore.isFetching
) {
return;
}
unifiedFontStore.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 } = unifiedFontStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items
if (hasMore && !unifiedFontStore.isFetching) {
loadMore();
}
}
</script>
<div class="relative w-full h-full">
{#if skeleton && isLoading && unifiedFontStore.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={unifiedFontStore.fonts}
total={unifiedFontStore.pagination.total}
isLoading={isLoading}
onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom}
{...rest}
>
{#snippet children(scope)}
{@render children(scope)}
{/snippet}
</VirtualList>
{/if}
</div>

View File

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

View File

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

View File

@@ -1,40 +0,0 @@
import type {
Category,
FilterModel,
} from '$shared/store/createFilterStore';
/**
* Model of state for CategoryFilter
*/
export type CategoryFilterModel = FilterModel;
export const FONT_CATEGORIES: Category[] = [
{
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,18 +0,0 @@
import { createFilterStore } from '$shared/store/createFilterStore';
import {
type CategoryFilterModel,
FONT_CATEGORIES,
} from '../model/state';
/**
* Initial state for CategoryFilter
*/
export const initialState: CategoryFilterModel = {
searchQuery: '',
categories: FONT_CATEGORIES,
};
/**
* CategoryFilter store
*/
export const categoryFilterStore = createFilterStore(initialState);

View File

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

View File

@@ -0,0 +1,110 @@
<!--
Component: FontSampler
Displays a sample text with a given font in a contenteditable element.
-->
<script lang="ts">
import {
FontApplicator,
type UnifiedFont,
} from '$entities/Font';
import { controlManager } from '$features/SetupFont';
import {
ContentEditable,
Footnote,
// IconButton,
} from '$shared/ui';
// import XIcon from '@lucide/svelte/icons/x';
interface Props {
/**
* Font info
*/
font: UnifiedFont;
/**
* Text to display
*/
text: string;
/**
* Index of the font sampler
*/
index?: number;
/**
* Font settings
*/
fontSize?: number;
lineHeight?: number;
letterSpacing?: number;
}
let { font, text = $bindable(), index = 0, ...restProps }: Props = $props();
const fontWeight = $derived(controlManager.weight);
const fontSize = $derived(controlManager.renderedSize);
const lineHeight = $derived(controlManager.height);
const letterSpacing = $derived(controlManager.spacing);
</script>
<div
class="
w-full h-full rounded-xl sm:rounded-2xl
flex flex-col
bg-background-80
border border-border-muted
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
relative overflow-hidden
"
style:font-weight={fontWeight}
>
<div class="px-4 sm:px-5 md:px-6 py-2.5 sm:py-3 border-b border-border-subtle flex items-center justify-between">
<div class="flex items-center gap-2 sm:gap-2.5">
<Footnote>
typeface_{String(index).padStart(3, '0')}
</Footnote>
<div class="w-px h-2 sm:h-2.5 bg-border-subtle"></div>
<div class="font-bold text-foreground">
{font.name}
</div>
</div>
<!--
<IconButton
onclick={removeSample}
class="w-5 h-5 rounded-full hover:bg-transparent flex items-center justify-center transition-colors group translate-x-1/2 cursor-pointer"
>
{#snippet icon({ className })}
<XIcon class={className} />
{/snippet}
</IconButton>
-->
</div>
<div class="p-4 sm:p-5 md:p-8 relative z-10">
<FontApplicator {font} weight={fontWeight}>
<ContentEditable
bind:text
{...restProps}
{fontSize}
{lineHeight}
{letterSpacing}
/>
</FontApplicator>
</div>
<div class="px-4 sm:px-5 md:px-6 py-1.5 sm:py-2 border-t border-border-subtle w-full flex flex-row gap-2 sm:gap-4 bg-background mt-auto">
<Footnote class="text-[7px] sm:text-[8px] tracking-wider ml-auto">
SZ:{fontSize}PX
</Footnote>
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
WGT:{fontWeight}
</Footnote>
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
LH:{lineHeight?.toFixed(2)}
</Footnote>
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider">
LTR:{letterSpacing}
</Footnote>
</div>
</div>

View File

@@ -0,0 +1,3 @@
import FontSampler from './FontSampler/FontSampler.svelte';
export { FontSampler };

View File

@@ -0,0 +1,18 @@
export {
createFilterManager,
type FilterManager,
mapManagerToParams,
} from './lib';
export {
FONT_CATEGORIES,
FONT_PROVIDERS,
FONT_SUBSETS,
} from './model/const/const';
export { filterManager } from './model/state/manager.svelte';
export {
FilterControls,
Filters,
} from './ui';

View File

@@ -0,0 +1,68 @@
import { createFilter } from '$shared/lib';
import { createDebouncedState } from '$shared/lib/helpers';
import type { FilterConfig } from '../../model';
/**
* Create a filter manager instance.
* - Uses debounce to update search query for better performance.
* - Manages filter instances for each group.
*
* @param config - Configuration for the filter manager.
* @returns - An instance of the filter manager.
*/
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
const search = createDebouncedState(config.queryValue ?? '');
// Create filter instances upfront
const groups = $state(
config.groups.map(config => ({
id: config.id,
label: config.label,
instance: createFilter({ properties: config.properties }),
})),
);
// Derived: any selection across all groups
const hasAnySelection = $derived(
groups.some(group => group.instance.selectedProperties.length > 0),
);
return {
// Getter for queryValue (immediate value for UI)
get queryValue() {
return search.immediate;
},
// Setter for queryValue
set queryValue(value) {
search.immediate = value;
},
// Getter for queryValue (debounced value for logic)
get debouncedQueryValue() {
return search.debounced;
},
// Direct array reference (reactive)
get groups() {
return groups;
},
// Derived values
get hasAnySelection() {
return hasAnySelection;
},
// Global action
deselectAllGlobal: () => {
groups.forEach(group => group.instance.deselectAll());
},
// Helper to get group by id
getGroup: (id: string) => {
return groups.find(g => g.id === id);
},
};
}
export type FilterManager = ReturnType<typeof createFilterManager>;

View File

@@ -0,0 +1,6 @@
export {
createFilterManager,
type FilterManager,
} from './filterManager/filterManager.svelte';
export { mapManagerToParams } from './mapper/mapManagerToParams';

View File

@@ -0,0 +1,54 @@
import type { ProxyFontsParams } from '$entities/Font/api';
import type { FilterManager } from '../filterManager/filterManager.svelte';
/**
* Maps filter manager to proxy API parameters.
*
* Transforms UI filter state into proxy API query parameters.
* Handles conversion from filter groups to API-specific parameters.
*
* @param manager - Filter manager instance with reactive state
* @returns - Partial proxy API parameters ready for API call
*
* @example
* ```ts
* // Example filter manager state:
* // {
* // queryValue: 'roboto',
* // providers: ['google'],
* // categories: ['sans-serif'],
* // subsets: ['latin']
* // }
*
* const params = mapManagerToParams(manager);
* // Returns: { provider: 'google', category: 'sans-serif', subset: 'latin', q: 'roboto' }
* ```
*/
export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsParams> {
const providers = manager.getGroup('providers')?.instance.selectedProperties.map(p => p.value);
const categories = manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value);
const subsets = manager.getGroup('subsets')?.instance.selectedProperties.map(p => p.value);
return {
// Search query (debounced)
q: manager.debouncedQueryValue || undefined,
// Provider filter (single value - proxy API doesn't support array)
// Use first provider if multiple selected, or undefined if none/all selected
provider: providers && providers.length === 1
? (providers[0] as 'google' | 'fontshare')
: undefined,
// Category filter (single value - proxy API doesn't support array)
// Use first category if multiple selected, or undefined if none/all selected
category: categories && categories.length === 1
? (categories[0] as ProxyFontsParams['category'])
: undefined,
// Subset filter (single value - proxy API doesn't support array)
// Use first subset if multiple selected, or undefined if none/all selected
subset: subsets && subsets.length === 1
? (subsets[0] as ProxyFontsParams['subset'])
: undefined,
};
}

View File

@@ -0,0 +1,90 @@
import type {
FontCategory,
FontProvider,
FontSubset,
} from '$entities/Font';
import type { Property } from '$shared/lib';
export const FONT_CATEGORIES: Property<FontCategory>[] = [
{
id: 'serif',
name: 'Serif',
value: 'serif',
},
{
id: 'sans-serif',
name: 'Sans-serif',
value: 'sans-serif',
},
{
id: 'display',
name: 'Display',
value: 'display',
},
{
id: 'handwriting',
name: 'Handwriting',
value: 'handwriting',
},
{
id: 'monospace',
name: 'Monospace',
value: 'monospace',
},
{
id: 'script',
name: 'Script',
value: 'script',
},
{
id: 'slab',
name: 'Slab',
value: 'slab',
},
] as const;
export const FONT_PROVIDERS: Property<FontProvider>[] = [
{
id: 'google',
name: 'Google Fonts',
value: 'google',
},
{
id: 'fontshare',
name: 'Fontshare',
value: 'fontshare',
},
] as const;
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',
},
] as const;

View File

@@ -0,0 +1,6 @@
export type {
FilterConfig,
FilterGroupConfig,
} from './types/filter';
export { filterManager } from './state/manager.svelte';

View File

@@ -0,0 +1,29 @@
import { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
import {
FONT_CATEGORIES,
FONT_PROVIDERS,
FONT_SUBSETS,
} from '../const/const';
const initialConfig = {
queryValue: '',
groups: [
{
id: 'providers',
label: 'Font provider',
properties: FONT_PROVIDERS,
},
{
id: 'subsets',
label: 'Font subset',
properties: FONT_SUBSETS,
},
{
id: 'categories',
label: 'Font category',
properties: FONT_CATEGORIES,
},
],
};
export const filterManager = createFilterManager(initialConfig);

View File

@@ -0,0 +1,12 @@
import type { Property } from '$shared/lib';
export interface FilterGroupConfig<TValue extends string> {
id: string;
label: string;
properties: Property<TValue>[];
}
export interface FilterConfig<TValue extends string> {
queryValue?: string;
groups: FilterGroupConfig<TValue>[];
}

View File

@@ -0,0 +1,15 @@
<!--
Component: Filters
Renders a list of CheckboxFilter components for each filter group.
-->
<script lang="ts">
import { CheckboxFilter } from '$shared/ui';
import { filterManager } from '../../model';
</script>
{#each filterManager.groups as group (group.id)}
<CheckboxFilter
displayedLabel={group.label}
filter={group.instance}
/>
{/each}

View File

@@ -0,0 +1,46 @@
<!--
Component: FiltersControl
Renders a group of action buttons for filter operations.
- Reset: Clears all active filters (outline variant for secondary action)
-->
<script lang="ts">
import { Button } from '$shared/shadcn/ui/button';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import Rotate from '@lucide/svelte/icons/rotate-ccw';
import { cubicOut } from 'svelte/easing';
import { Tween } from 'svelte/motion';
import { filterManager } from '../../model';
interface Props {
class?: string;
}
const { class: className }: Props = $props();
const transform = new Tween(
{ scale: 1, rotate: 0 },
{ duration: 150, easing: cubicOut },
);
function handleClick() {
filterManager.deselectAllGlobal();
transform.set({ scale: 0.98, rotate: 1 }).then(() => {
transform.set({ scale: 1, rotate: 0 });
});
}
</script>
<div
class={cn('flex flex-row gap-2', className)}
style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)"
>
<Button
variant="ghost"
class="group flex flex-1 cursor-pointer gap-1"
onclick={handleClick}
>
<Rotate class="size-4 group-hover:-rotate-180 transition-transform duration-300" />
Reset
</Button>
</div>

View File

@@ -0,0 +1,7 @@
import Filters from './Filters/Filters.svelte';
import FilterControls from './FiltersControl/FilterControls.svelte';
export {
FilterControls,
Filters,
};

View File

@@ -1,7 +0,0 @@
import { FONT_PROVIDERS } from './model/state';
import { providersFilterStore } from './store/providersFilterStore';
export {
FONT_PROVIDERS,
providersFilterStore,
};

View File

@@ -1,20 +0,0 @@
import type {
Category,
FilterModel,
} from '$shared/store/createFilterStore';
/**
* Model of state for ProvidersFilter
*/
export type ProvidersFilterModel = FilterModel;
export const FONT_PROVIDERS: Category[] = [
{
id: 'google',
name: 'Google Fonts',
},
{
id: 'fontshare',
name: 'Fontshare',
},
] as const;

View File

@@ -1,18 +0,0 @@
import { createFilterStore } from '$shared/store/createFilterStore';
import {
FONT_PROVIDERS,
type ProvidersFilterModel,
} from '../model/state';
/**
* Initial state for ProvidersFilter
*/
export const initialState: ProvidersFilterModel = {
searchQuery: '',
categories: FONT_PROVIDERS,
};
/**
* ProvidersFilter store
*/
export const providersFilterStore = createFilterStore(initialState);

View File

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

View File

@@ -0,0 +1,215 @@
import {
type ControlDataModel,
type ControlModel,
type PersistentStore,
type TypographyControl,
createPersistentStore,
createTypographyControl,
} from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity';
import {
type ControlId,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
} from '../../model';
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
export interface Control extends ControlOnlyFields<ControlId> {
instance: TypographyControl;
}
export class TypographyControlManager {
#controls = new SvelteMap<string, Control>();
#multiplier = $state(1);
#storage: PersistentStore<TypographySettings>;
#baseSize = $state(DEFAULT_FONT_SIZE);
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
this.#storage = storage;
// Initial Load
const saved = storage.value;
this.#baseSize = saved.fontSize;
// Setup Controls
configs.forEach(config => {
const initialValue = this.#getInitialValue(config.id, saved);
this.#controls.set(config.id, {
...config,
instance: createTypographyControl({
...config,
value: initialValue,
}),
});
});
// The Sync Effect (UI -> Storage)
// We access .value explicitly to ensure Svelte 5 tracks the dependency
$effect.root(() => {
$effect(() => {
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
const fontSize = this.#baseSize;
const fontWeight = this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
const lineHeight = this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
const letterSpacing = this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
// Syncing back to storage
this.#storage.value = {
fontSize,
fontWeight,
lineHeight,
letterSpacing,
};
});
// The Font Size Proxy Effect
// This handles the "Multiplier" logic specifically for the Font Size Control
$effect(() => {
const ctrl = this.#controls.get('font_size')?.instance;
if (!ctrl) return;
// If the user moves the slider/clicks buttons in the UI:
// We update the baseSize (User Intent)
const currentDisplayValue = ctrl.value;
const calculatedBase = currentDisplayValue / this.#multiplier;
// Only update if the difference is significant (prevents rounding jitter)
if (Math.abs(this.#baseSize - calculatedBase) > 0.01) {
this.#baseSize = calculatedBase;
}
});
});
}
#getInitialValue(id: string, saved: TypographySettings): number {
if (id === 'font_size') return saved.fontSize * this.#multiplier;
if (id === 'font_weight') return saved.fontWeight;
if (id === 'line_height') return saved.lineHeight;
if (id === 'letter_spacing') return saved.letterSpacing;
return 0;
}
// --- Getters / Setters ---
get multiplier() {
return this.#multiplier;
}
set multiplier(value: number) {
if (this.#multiplier === value) return;
this.#multiplier = value;
// When multiplier changes, we must update the Font Size Control's display value
const ctrl = this.#controls.get('font_size')?.instance;
if (ctrl) {
ctrl.value = this.#baseSize * this.#multiplier;
}
}
/** The scaled size for CSS usage */
get renderedSize() {
return this.#baseSize * this.#multiplier;
}
/** The base size (User Preference) */
get baseSize() {
return this.#baseSize;
}
set baseSize(val: number) {
this.#baseSize = val;
const ctrl = this.#controls.get('font_size')?.instance;
if (ctrl) ctrl.value = val * this.#multiplier;
}
/**
* Getters for controls
*/
get controls() {
return Array.from(this.#controls.values());
}
get weightControl() {
return this.#controls.get('font_weight')?.instance;
}
get sizeControl() {
return this.#controls.get('font_size')?.instance;
}
get heightControl() {
return this.#controls.get('line_height')?.instance;
}
get spacingControl() {
return this.#controls.get('letter_spacing')?.instance;
}
/**
* Getters for values (besides font-size)
*/
get weight() {
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
}
get height() {
return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
}
get spacing() {
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
}
reset() {
this.#storage.clear();
const defaults = this.#storage.value;
this.#baseSize = defaults.fontSize;
// Reset all control instances
this.#controls.forEach(c => {
if (c.id === 'font_size') {
c.instance.value = defaults.fontSize * this.#multiplier;
} else {
// Map storage key to control id
const key = c.id.replace('_', '') as keyof TypographySettings;
// Simplified for brevity, you'd map these properly:
if (c.id === 'font_weight') c.instance.value = defaults.fontWeight;
if (c.id === 'line_height') c.instance.value = defaults.lineHeight;
if (c.id === 'letter_spacing') c.instance.value = defaults.letterSpacing;
}
});
}
}
/**
* Storage schema for typography settings
*/
export interface TypographySettings {
fontSize: number;
fontWeight: number;
lineHeight: number;
letterSpacing: number;
}
/**
* Creates a typography control manager that handles a collection of typography controls.
*
* @param configs - Array of control configurations.
* @param storageId - Persistent storage identifier.
* @returns - Typography control manager instance.
*/
export function createTypographyControlManager(
configs: ControlModel<ControlId>[],
storageId: string = 'glyphdiff:typography',
) {
const storage = createPersistentStore<TypographySettings>(storageId, {
fontSize: DEFAULT_FONT_SIZE,
fontWeight: DEFAULT_FONT_WEIGHT,
lineHeight: DEFAULT_LINE_HEIGHT,
letterSpacing: DEFAULT_LETTER_SPACING,
});
return new TypographyControlManager(configs, storage);
}

View File

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

View File

@@ -0,0 +1,88 @@
import type { ControlModel } from '$shared/lib';
import type { ControlId } from '..';
/**
* 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: 'Font 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: 'Font 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: 'Line Height',
},
{
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: 'Letter Spacing',
},
];
/**
* Font size multipliers
*/
export const MULTIPLIER_S = 0.5;
export const MULTIPLIER_M = 0.75;
export const MULTIPLIER_L = 1;

View File

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

View File

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

View File

@@ -0,0 +1,134 @@
<!--
Component: TypographyMenu
Provides a menu for selecting and configuring typography settings
- On mobile the menu is displayed as a drawer
-->
<script lang="ts">
import type { ResponsiveManager } from '$shared/lib';
import {
Content as ItemContent,
Root as ItemRoot,
} from '$shared/shadcn/ui/item';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
ComboControlV2,
Drawer,
IconButton,
} from '$shared/ui';
import { Label } from '$shared/ui';
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
controlManager,
} from '../model';
interface Props {
class?: string;
hidden?: boolean;
}
const { class: className, hidden = false }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
const [send, receive] = crossfade({
duration: 300,
easing: cubicOut,
fallback(node, params) {
// If it can't find a pair, it falls back to a simple fade/slide
return {
duration: 300,
css: t => `opacity: ${t}; transform: translateY(${(1 - t) * 10}px);`,
};
},
});
/**
* Sets the common font size multiplier based on the current responsive state.
*/
$effect(() => {
if (!responsive) {
return;
}
switch (true) {
case responsive.isMobile:
controlManager.multiplier = MULTIPLIER_S;
break;
case responsive.isTablet:
controlManager.multiplier = MULTIPLIER_M;
break;
case responsive.isDesktop:
controlManager.multiplier = MULTIPLIER_L;
break;
default:
controlManager.multiplier = MULTIPLIER_L;
break;
}
});
</script>
<div
class={cn(
'w-auto max-screen z-10 flex justify-center',
hidden && 'hidden',
className,
)}
in:receive={{ key: 'panel' }}
out:send={{ key: 'panel' }}
>
{#if responsive.isMobile}
<Drawer>
{#snippet trigger({ onClick })}
<IconButton onclick={onClick}>
{#snippet icon({ className })}
<SlidersIcon class={className} />
{/snippet}
</IconButton>
{/snippet}
{#snippet content({ className })}
<Label
class="mt-6 mb-12 px-2"
text="Typography Controls"
align="center"
/>
<div class={cn(className, 'flex flex-col gap-8')}>
{#each controlManager.controls as control (control.id)}
<ComboControlV2
control={control.instance}
orientation="horizontal"
label={control.controlLabel}
reduced
/>
{/each}
</div>
{/snippet}
</Drawer>
{:else}
<ItemRoot
variant="outline"
class="w-full sm:w-auto max-w-full sm:max-w-max p-2 sm:p-2.5 rounded-xl sm:rounded-2xl backdrop-blur-lg"
>
<ItemContent class="flex flex-row justify-center items-center max-w-full sm:max-w-max">
<div class="sm:py-2 sm:px-10 flex flex-row items-center gap-2">
<div class="flex flex-row gap-3">
{#each controlManager.controls as control (control.id)}
<ComboControlV2
control={control.instance}
increaseLabel={control.increaseLabel}
decreaseLabel={control.decreaseLabel}
controlLabel={control.controlLabel}
orientation="vertical"
showScale={false}
/>
{/each}
</div>
</div>
</ItemContent>
</ItemRoot>
{/if}
</div>

View File

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

View File

@@ -1,7 +0,0 @@
import { FONT_SUBSETS } from './model/state';
import { subsetsFilterStore } from './store/subsetsFilterStore';
export {
FONT_SUBSETS,
subsetsFilterStore,
};

View File

@@ -1,36 +0,0 @@
import type {
Category,
FilterModel,
} from '$shared/store/createFilterStore';
/**
* Model of state for SubsetsFilter
*/
export type SubsetsFilterModel = FilterModel;
export const FONT_SUBSETS: Category[] = [
{
id: 'latin',
name: 'Latin',
},
{
id: 'latin-ext',
name: 'Latin Extended',
},
{
id: 'cyrillic',
name: 'Cyrillic',
},
{
id: 'greek',
name: 'Greek',
},
{
id: 'arabic',
name: 'Arabic',
},
{
id: 'devanagari',
name: 'Devanagari',
},
] as const;

View File

@@ -1,18 +0,0 @@
import { createFilterStore } from '$shared/store/createFilterStore';
import {
FONT_SUBSETS,
type SubsetsFilterModel,
} from '../model/state';
/**
* Initial state for SubsetsFilter
*/
export const initialState: SubsetsFilterModel = {
searchQuery: '',
categories: FONT_SUBSETS,
};
/**
* SubsetsFilter store
*/
export const subsetsFilterStore = createFilterStore(initialState);

View File

@@ -1,7 +1,152 @@
<script>
<!--
Component: Page
Description: The main page component of the application.
-->
<script lang="ts">
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
Logo,
Section,
} from '$shared/ui';
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
import { FontSearch } from '$widgets/FontSearch';
import { SampleList } from '$widgets/SampleList';
import CodeIcon from '@lucide/svelte/icons/code';
import EyeIcon from '@lucide/svelte/icons/eye';
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
import ScanSearchIcon from '@lucide/svelte/icons/search';
import {
type Snippet,
getContext,
} from 'svelte';
import { cubicIn } from 'svelte/easing';
import { fade } from 'svelte/transition';
let searchContainer: HTMLElement;
let isExpanded = $state(true);
const responsive = getContext<ResponsiveManager>('responsive');
function handleTitleStatusChanged(
index: number,
isPast: boolean,
title?: Snippet<[{ className?: string }]>,
id?: string,
) {
if (isPast && title) {
scrollBreadcrumbsStore.add({ index, title, id });
} else {
scrollBreadcrumbsStore.remove(index);
}
return () => {
scrollBreadcrumbsStore.remove(index);
};
}
</script>
<h1>Welcome to Svelte + Vite</h1>
<p>
Visit <a href="https://svelte.dev/docs">svelte.dev/docs</a> to read the documentation
</p>
<!-- Font List -->
<div
class="p-2 sm:p-3 md:p-4 h-full grid gap-3 sm:gap-4 grid-cols-[max-content_1fr]"
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
>
<Section
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-8"
onTitleStatusChange={handleTitleStatusChanged}
>
{#snippet icon({ className })}
<CodeIcon class={className} />
{/snippet}
{#snippet description({ className })}
<span class={className}> Project_Codename </span>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'col-start-0 col-span-2')}>
<Logo />
</div>
{/snippet}
</Section>
<Section
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
index={1}
id="optical_comparator"
onTitleStatusChange={handleTitleStatusChanged}
stickyTitle={responsive.isDesktopLarge}
stickyOffset="4rem"
>
{#snippet icon({ className })}
<EyeIcon class={className} />
{/snippet}
{#snippet title({ className })}
<h1 class={className}>
Optical<br />Comparator
</h1>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
<ComparisonSlider />
</div>
{/snippet}
</Section>
<Section
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
index={2}
id="query_module"
onTitleStatusChange={handleTitleStatusChanged}
stickyTitle={responsive.isDesktopLarge}
stickyOffset="4rem"
>
{#snippet icon({ className })}
<ScanSearchIcon class={className} />
{/snippet}
{#snippet title({ className })}
<h2 class={className}>
Query<br />Module
</h2>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
<FontSearch bind:showFilters={isExpanded} />
</div>
{/snippet}
</Section>
<Section
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
index={3}
id="sample_set"
onTitleStatusChange={handleTitleStatusChanged}
stickyTitle={responsive.isDesktopLarge}
stickyOffset="4rem"
>
{#snippet icon({ className })}
<LineSquiggleIcon class={className} />
{/snippet}
{#snippet title({ className })}
<h2 class={className}>
Sample<br />Set
</h2>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
<SampleList />
</div>
{/snippet}
</Section>
</div>
<style>
.content {
/* Tells the browser to skip rendering off-screen content */
content-visibility: auto;
/* Helps the browser reserve space without calculating everything */
contain-intrinsic-size: 1px 1000px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

View File

@@ -56,6 +56,5 @@ export const api = {
body: JSON.stringify(body),
}),
delete: <T>(url: string, options?: RequestInit) =>
request<T>(url, { ...options, method: 'DELETE' }),
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
};

View File

@@ -0,0 +1,26 @@
import { QueryClient } from '@tanstack/query-core';
/**
* Query client instance
*/
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
/**
* Default staleTime: 5 minutes
*/
staleTime: 5 * 60 * 1000,
/**
* Default gcTime: 10 minutes
*/
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: true,
retry: 3,
/**
* Exponential backoff
*/
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
});

4
src/shared/assets/GD.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg width="52" height="35" viewBox="0 0 52 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.608 34.368C8.496 34.368 6.64 33.968 5.04 33.168C3.44 32.336 2.192 31.184 1.296 29.712C0.432 28.208 0 26.48 0 24.528V9.84C0 7.888 0.432 6.176 1.296 4.704C2.192 3.2 3.44 2.048 5.04 1.248C6.64 0.415999 8.496 0 10.608 0C12.688 0 14.528 0.415999 16.128 1.248C17.728 2.048 18.96 3.2 19.824 4.704C20.688 6.176 21.12 7.872 21.12 9.792V10.512C21.12 10.832 20.96 10.992 20.64 10.992H20.16C19.84 10.992 19.68 10.832 19.68 10.512V9.744C19.68 7.216 18.848 5.184 17.184 3.648C15.52 2.112 13.328 1.344 10.608 1.344C7.856 1.344 5.632 2.128 3.936 3.696C2.272 5.232 1.44 7.264 1.44 9.792V24.576C1.44 27.104 2.272 29.152 3.936 30.72C5.632 32.256 7.856 33.024 10.608 33.024C13.328 33.024 15.52 32.272 17.184 30.768C18.848 29.232 19.68 27.2 19.68 24.672V19.152C19.68 19.024 19.616 18.96 19.488 18.96H11.472C11.152 18.96 10.992 18.8 10.992 18.48V18.144C10.992 17.824 11.152 17.664 11.472 17.664H20.64C20.96 17.664 21.12 17.824 21.12 18.144V24.48C21.12 26.464 20.688 28.208 19.824 29.712C18.96 31.184 17.728 32.336 16.128 33.168C14.528 33.968 12.688 34.368 10.608 34.368Z" fill="white"/>
<path d="M31.2124 33.984C30.8924 33.984 30.7324 33.824 30.7324 33.504V0.863997C30.7324 0.543998 30.8924 0.383998 31.2124 0.383998H42.1084C45.0204 0.383998 47.3084 1.168 48.9724 2.736C50.6684 4.272 51.5164 6.4 51.5164 9.12V25.248C51.5164 27.968 50.6684 30.112 48.9724 31.68C47.3084 33.216 45.0204 33.984 42.1084 33.984H31.2124ZM32.1724 32.448C32.1724 32.576 32.2364 32.64 32.3644 32.64H42.2044C44.6364 32.64 46.5564 31.984 47.9644 30.672C49.3724 29.328 50.0764 27.504 50.0764 25.2V9.216C50.0764 6.88 49.3724 5.056 47.9644 3.744C46.5564 2.4 44.6364 1.728 42.2044 1.728H32.3644C32.2364 1.728 32.1724 1.792 32.1724 1.92V32.448Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

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