Compare commits

...

338 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
197 changed files with 14426 additions and 1404 deletions

View File

@@ -42,3 +42,19 @@ jobs:
- name: Type Check - name: Type Check
run: yarn check:shadcn-excluded 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

2
.gitignore vendored
View File

@@ -35,6 +35,8 @@ vite.config.ts.timestamp-*
/docs /docs
AGENTS.md AGENTS.md
*.md
!README.md
*storybook.log *storybook.log
storybook-static storybook-static

View File

@@ -0,0 +1,29 @@
<!--
Component: Decorator
Global Storybook decorator that wraps all stories with necessary providers.
This provides:
- ResponsiveManager context for breakpoint tracking
- TooltipProvider for shadcn Tooltip components
-->
<script lang="ts">
import { createResponsiveManager } from '$shared/lib';
import type { ResponsiveManager } from '$shared/lib';
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import { setContext } from 'svelte';
interface Props {
children: import('svelte').Snippet;
}
let { children }: Props = $props();
// Create and provide responsive context
const responsiveManager = createResponsiveManager();
$effect(() => responsiveManager.init());
setContext<ResponsiveManager>('responsive', responsiveManager);
</script>
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
{@render children()}
</TooltipProvider>

View File

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

View File

@@ -1,4 +1,16 @@
import type { StorybookConfig } from '@storybook/svelte-vite'; 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 = { const config: StorybookConfig = {
'stories': [ 'stories': [
@@ -9,7 +21,8 @@ const config: StorybookConfig = {
{ {
name: '@storybook/addon-svelte-csf', name: '@storybook/addon-svelte-csf',
options: { options: {
legacyTemplate: true, // Enables the legacy template syntax // Use modern template syntax for better performance
legacyTemplate: false,
}, },
}, },
'@chromatic-com/storybook', '@chromatic-com/storybook',
@@ -18,5 +31,17 @@ const config: StorybookConfig = {
'@storybook/addon-docs', '@storybook/addon-docs',
], ],
'framework': '@storybook/svelte-vite', '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; export default config;

View File

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

View File

@@ -1,9 +1,11 @@
import type { Preview } from '@storybook/svelte-vite'; import type { Preview } from '@storybook/svelte-vite';
import Decorator from './Decorator.svelte';
import StoryStage from './StoryStage.svelte';
import '../src/app/styles/app.css'; import '../src/app/styles/app.css';
const preview: Preview = { const preview: Preview = {
parameters: { parameters: {
layout: 'fullscreen',
controls: { controls: {
matchers: { matchers: {
color: /(background|color)$/i, color: /(background|color)$/i,
@@ -17,7 +19,47 @@ const preview: Preview = {
// 'off' - skip a11y checks entirely // 'off' - skip a11y checks entirely
test: 'todo', 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; export default preview;

5
Caddyfile Normal file
View File

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

27
Dockerfile Normal file
View File

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

140
README.md
View File

@@ -1,37 +1,38 @@
# GlyphDiff # GlyphDiff
A modern, high-performance font exploration tool for browsing and comparing fonts from Google Fonts and Fontshare. 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.
## Features ## Features
- **Multi-Provider Support**: Access fonts from Google Fonts and Fontshare in one place - **Multi-Provider Catalog**: Browse fonts from Google Fonts and Fontshare in one place
- **Fast Virtual Scrolling**: Handles thousands of fonts smoothly with custom virtualization - **Side-by-Side Comparison**: Compare up to 4 fonts simultaneously with customizable text, size, and typography settings
- **Advanced Filtering**: Filter by category, provider, and character subsets - **Advanced Filtering**: Filter by category, provider, character subsets, and weight
- **Responsive Design**: Beautiful UI built with shadcn components and Tailwind CSS - **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 - **Type-Safe**: Full TypeScript coverage with strict mode enabled
## 🛠️ Tech Stack ## Tech Stack
- **Frontend**: Svelte 5 with runes (reactive primitives) - **Framework**: Svelte 5 with reactive primitives (runes)
- **Styling**: Tailwind CSS v4 + shadcn-svelte components - **Styling**: Tailwind CSS v4
- **Data Fetching**: TanStack Query for caching and state management - **Components**: shadcn-svelte (via bits-ui)
- **Architecture**: Feature-Sliced Design (FSD) methodology - **State Management**: TanStack Query for async data
- **Testing**: Playwright (E2E), Vitest (unit), Storybook (components) - **Architecture**: Feature-Sliced Design (FSD)
- **Quality**: Oxlint (linting), Dprint (formatting), Lefthook (git hooks) - **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks)
## 📁 Architecture ## Project Structure
``` ```
src/ src/
├── app/ # App shell, layout, providers ├── app/ # App shell, layout, providers
├── widgets/ # Composed UI blocks ├── widgets/ # Composed UI blocks (ComparisonSlider, SampleList, FontSearch)
├── features/ # Business features (filters, search) ├── features/ # Business features (filters, search, display)
├── entities/ # Domain entities (Font models, stores) ├── entities/ # Domain models and stores (Font, Breadcrumb)
├── shared/ # Reusable utilities, UI components, helpers ├── shared/ # Reusable utilities, UI components, helpers
└── routes/ # Page-level components └── routes/ # Page-level components
``` ```
## 🚀 Quick Start ## Quick Start
```bash ```bash
# Install dependencies # Install dependencies
@@ -40,81 +41,38 @@ yarn install
# Start development server # Start development server
yarn dev yarn dev
# Open in browser # Build for production
yarn dev -- --open
```
## 📦 Available Scripts
| 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 with dprint |
| `yarn test` | Run all tests (E2E + unit) |
| `yarn test:e2e` | Run Playwright E2E tests |
| `yarn test:unit` | Run Vitest unit tests |
| `yarn storybook` | Start Storybook dev server |
## 🧪 Development
### Type Checking
```bash
yarn check # Single run
yarn check:watch # Watch mode
```
### Testing
```bash
yarn test:unit # Unit tests
yarn test:unit:watch # Watch mode
yarn test:unit:ui # Vitest UI
yarn test:e2e # E2E tests with Playwright
yarn test:e2e --ui # Interactive test runner
```
### Code Quality
```bash
yarn lint # Lint code
yarn format # Format code
yarn format:check # Check formatting
```
## 🎯 Key Components
- **VirtualList**: Custom high-performance list virtualization using Svelte 5 runes
- **FontList**: Displays fonts with loading, empty, and error states
- **FilterControls**: Multi-filter system for category, provider, and subsets
- **TypographyControl**: Dynamic typography adjustment controls
## 📝 Code Style
- **Path Aliases**: Use `$app/`, `$shared/`, `$features/`, `$entities/`, `$widgets/`, `$routes/`
- **Components**: PascalCase (e.g., `CheckboxFilter.svelte`)
- **Formatting**: 100 char line width, 4-space indent, single quotes
- **Imports**: Auto-sorted by dprint, separated by blank line
- **Type Safety**: Strict TypeScript, JSDoc comments for public APIs
## 🏗️ Building for Production
```bash
yarn build yarn build
# Preview production build
yarn preview yarn preview
``` ```
## 📚 Learn More ## Available Scripts
- [Svelte 5 Documentation](https://svelte-5-preview.vercel.app/docs) | Command | Description |
- [Feature-Sliced Design](https://feature-sliced.design) | ------------------- | -------------------------- |
- [Tailwind CSS v4](https://tailwindcss.com/blog/tailwindcss-v4-alpha) | `yarn dev` | Start development server |
- [shadcn-svelte](https://www.shadcn-svelte.com) | `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 |
## 📄 License ## Code Style
- **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
## 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 MIT

View File

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

View File

@@ -61,6 +61,7 @@
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^7.2.6", "vite": "^7.2.6",
"vitest": "^4.0.16", "vitest": "^4.0.16",
"vitest-browser-svelte": "^2.0.1" "vitest-browser-svelte": "^2.0.1"

View File

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

View File

@@ -37,6 +37,28 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32); --sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067); --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 { .dark {
@@ -71,6 +93,26 @@
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938); --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 { @theme inline {
@@ -109,6 +151,24 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --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 { @layer base {
@@ -117,6 +177,8 @@
} }
body { body {
@apply bg-background text-foreground; @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 ~ * { .peer:focus-visible ~ * {
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); 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; const content: string;
export default content; 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

@@ -3,47 +3,105 @@
* Layout Component * Layout Component
* *
* Root layout wrapper that provides the application shell structure. Handles favicon, * Root layout wrapper that provides the application shell structure. Handles favicon,
* sidebar provider initialization, and renders child routes with consistent structure. * toolbar provider initialization, and renders child routes with consistent structure.
* *
* Layout structure: * Layout structure:
* - Header area (currently empty, reserved for future use) * - Header area (currently empty, reserved for future use)
* - Collapsible sidebar with main content area
* - Footer area (currently empty, reserved for future use)
* *
* Uses Sidebar.Provider to enable mobile-responsive collapsible sidebar behavior * - Footer area (currently empty, reserved for future use)
* throughout the application.
*/ */
import favicon from '$shared/assets/favicon.svg'; import { BreadcrumbHeader } from '$entities/Breadcrumb';
import * as Sidebar from '$shared/shadcn/ui/sidebar/index'; import GD from '$shared/assets/GD.svg';
import { FiltersSidebar } from '$widgets/FiltersSidebar'; import { ResponsiveProvider } from '$shared/lib';
import TypographyMenu from '$widgets/TypographySettings/ui/TypographyMenu.svelte'; import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import {
type Snippet,
onMount,
} from 'svelte';
/** Slot content for route pages to render */ interface Props {
let { children } = $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> </script>
<svelte:head> <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://api.fontshare.com" />
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" /> <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> </svelte:head>
<div id="app-root"> <ResponsiveProvider>
<header></header> <div id="app-root" class="min-h-screen flex flex-col bg-background">
<header>
<BreadcrumbHeader />
</header>
<Sidebar.Provider> <!-- <ScrollArea class="h-screen w-screen"> -->
<FiltersSidebar /> <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">
<main class="w-dvw"> <TooltipProvider>
<TypographyMenu /> {#if fontsReady}
{@render children?.()} {@render children?.()}
{/if}
</TooltipProvider>
</main> </main>
</Sidebar.Provider> <!-- </ScrollArea> -->
<footer></footer> <footer></footer>
</div> </div>
</ResponsiveProvider>
<style>
#app-root {
width: 100%;
height: 100vh;
}
</style>

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

@@ -4,6 +4,18 @@
* Exports API clients and normalization utilities * 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 { export {
fetchGoogleFontFamily, fetchGoogleFontFamily,
fetchGoogleFonts, fetchGoogleFonts,
@@ -14,6 +26,7 @@ export type {
GoogleFontsResponse, GoogleFontsResponse,
} from './google/googleFonts'; } from './google/googleFonts';
// Fontshare API (DEPRECATED - kept for backward compatibility)
export { export {
fetchAllFontshareFonts, fetchAllFontshareFonts,
fetchFontshareFontBySlug, fetchFontshareFontBySlug,

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,3 +1,15 @@
// Proxy API (PRIMARY)
export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
} from './api/proxy/proxyFonts';
export type {
ProxyFontsParams,
ProxyFontsResponse,
} from './api/proxy/proxyFonts';
// Fontshare API (DEPRECATED)
export { export {
fetchAllFontshareFonts, fetchAllFontshareFonts,
fetchFontshareFontBySlug, fetchFontshareFontBySlug,
@@ -7,6 +19,8 @@ export type {
FontshareParams, FontshareParams,
FontshareResponse, FontshareResponse,
} from './api/fontshare/fontshare'; } from './api/fontshare/fontshare';
// Google Fonts API (DEPRECATED)
export { export {
fetchGoogleFontFamily, fetchGoogleFontFamily,
fetchGoogleFonts, fetchGoogleFonts,
@@ -42,7 +56,6 @@ export type {
FontshareFont, FontshareFont,
FontshareLink, FontshareLink,
FontsharePublisher, FontsharePublisher,
FontshareStore,
FontshareStyle, FontshareStyle,
FontshareStyleProperties, FontshareStyleProperties,
FontshareTag, FontshareTag,
@@ -61,17 +74,59 @@ export type {
export { export {
appliedFontsManager, appliedFontsManager,
createFontshareStore, createUnifiedFontStore,
fetchFontshareFontsQuery, unifiedFontStore,
fontshareStore,
selectedFontsStore,
} from './model'; } from './model';
// Stores // Mock data helpers for Storybook and testing
export { export {
createGoogleFontsStore, createCategoriesFilter,
GoogleFontsStore, createErrorState,
} from './model/services/fetchGoogleFonts.svelte'; 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 // UI elements
export { export {

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

@@ -4,3 +4,55 @@ export {
normalizeGoogleFont, normalizeGoogleFont,
normalizeGoogleFonts, normalizeGoogleFonts,
} from './normalize/normalize'; } 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

@@ -24,11 +24,9 @@ describe('Font Normalization', () => {
subsets: ['latin', 'latin-ext'], subsets: ['latin', 'latin-ext'],
files: { files: {
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2', regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
'700': '700': 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2', italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
'700italic': '700italic': 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
}, },
version: 'v30', version: 'v30',
lastModified: '2022-01-01', lastModified: '2022-01-01',

View File

@@ -12,6 +12,7 @@ import type {
FontshareFont, FontshareFont,
GoogleFontItem, GoogleFontItem,
UnifiedFont, UnifiedFont,
UnifiedFontVariant,
} from '../../model/types'; } from '../../model/types';
/** /**
@@ -186,7 +187,7 @@ export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont {
const variants = apiFont.styles.map(style => { const variants = apiFont.styles.map(style => {
const weightLabel = style.weight.label; const weightLabel = style.weight.label;
const isItalic = style.is_italic; const isItalic = style.is_italic;
return isItalic ? `${weightLabel}italic` : weightLabel; return (isItalic ? `${weightLabel}italic` : weightLabel) as UnifiedFontVariant;
}); });
// Map styles to URLs // Map styles to URLs

View File

@@ -34,12 +34,10 @@ export type {
UnifiedFontVariant, UnifiedFontVariant,
} from './types'; } from './types';
export { fetchFontshareFontsQuery } from './services';
export { export {
appliedFontsManager, appliedFontsManager,
createFontshareStore, createUnifiedFontStore,
type FontshareStore, type FontConfigRequest,
fontshareStore, type UnifiedFontStore,
selectedFontsStore, unifiedFontStore,
} from './store'; } from './store';

View File

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

View File

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

View File

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

View File

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

@@ -1,150 +1,354 @@
import { SvelteMap } from 'svelte/reactivity'; 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'; 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;
}
/** /**
* Manager that handles loading of the fonts * Manages web font loading with caching, adaptive concurrency, and automatic cleanup.
* Adds <link /> tags to <head /> *
* - Uses batch loading to reduce the number of requests * **Two-Phase Loading Strategy:**
* - Uses a queue to prevent too many requests at once * 1. *Concurrent Fetching*: Font files fetched in parallel (network I/O is non-blocking)
* - Purges unused fonts after a certain time * 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
*/ */
class AppliedFontsManager { export class AppliedFontsManager {
// Stores: slug -> timestamp of last visibility // 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>(); #usageTracker = new Map<string, number>();
// Stores: slug -> batchId
#slugToBatch = new Map<string, string>();
// Stores: batchId -> HTMLLinkElement (for physical cleanup)
#batchElements = new Map<string, HTMLLinkElement>();
#queue = new Set<string>(); // 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; #timeoutId: ReturnType<typeof setTimeout> | null = null;
#PURGE_INTERVAL = 60000; // Check every minute
#TTL = 5 * 60 * 1000; // 5 minutes
#CHUNK_SIZE = 3;
// 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>(); statuses = new SvelteMap<string, FontStatus>();
// Starts periodic cleanup timer (browser-only).
constructor() { constructor() {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// Start the "Janitor" loop this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
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();
}
} }
} }
/** /**
* Updates the 'last seen' timestamp for fonts. * Fetches font with cache-aside pattern: checks Cache API first, falls back to network.
* Prevents them from being purged while they are on screen. * Cache failures (private browsing, quota limits) are silently ignored.
*/ */
touch(slugs: string[]) { async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
const now = Date.now(); try {
const toRegister: string[] = []; if (typeof caches !== 'undefined') {
const cache = await caches.open(this.#CACHE_NAME);
slugs.forEach(slug => { const cached = await cache.match(url);
this.#usageTracker.set(slug, now); if (cached) return cached.arrayBuffer();
if (!this.#slugToBatch.has(slug)) {
toRegister.push(slug);
} }
}); } catch {
// Cache unavailable (private browsing, security restrictions) — fall through to network
if (toRegister.length > 0) this.registerFonts(toRegister);
}
registerFonts(slugs: string[]) {
const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s));
if (newSlugs.length === 0) return;
newSlugs.forEach(s => this.#queue.add(s));
if (this.#timeoutId) clearTimeout(this.#timeoutId);
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
}
getFontStatus(slug: string) {
return this.statuses.get(slug);
}
#processQueue() {
const fullQueue = Array.from(this.#queue);
if (fullQueue.length === 0) return;
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) {
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE));
} }
this.#queue.clear(); const response = await fetch(url, { signal });
this.#timeoutId = null; if (!response.ok) throw new Error(`HTTP ${response.status}`);
}
try {
#createBatch(slugs: string[]) { if (typeof caches !== 'undefined') {
if (typeof document === 'undefined') return; const cache = await caches.open(this.#CACHE_NAME);
await cache.put(url, response.clone());
const batchId = crypto.randomUUID(); }
// font-display=swap included for better UX } catch {
const query = slugs.map(s => `f[]=${s.toLowerCase()}@400`).join('&'); // Cache write failed (quota, storage pressure) — return font anyway
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`; }
// Mark all as loading immediately return response.arrayBuffer();
slugs.forEach(slug => this.statuses.set(slug, 'loading'));
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
link.dataset.batchId = batchId;
document.head.appendChild(link);
this.#batchElements.set(batchId, link);
slugs.forEach(slug => {
this.#slugToBatch.set(slug, batchId);
// Use the Native Font Loading API
// format: "font-size font-family"
document.fonts.load(`1em "${slug}"`)
.then(loadedFonts => {
if (loadedFonts.length > 0) {
this.statuses.set(slug, 'loaded');
} else {
this.statuses.set(slug, 'error');
}
})
.catch(() => {
this.statuses.set(slug, 'error');
});
});
} }
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. */
#purgeUnused() { #purgeUnused() {
const now = Date.now(); const now = Date.now();
const batchesToPotentialDelete = new Set<string>(); for (const [key, lastUsed] of this.#usageTracker) {
const slugsToDelete: string[] = []; if (now - lastUsed < this.#TTL) continue;
// Identify expired slugs const font = this.#loadedFonts.get(key);
for (const [slug, lastUsed] of this.#usageTracker.entries()) { if (font) document.fonts.delete(font);
if (now - lastUsed > this.#TTL) {
const batchId = this.#slugToBatch.get(slug); this.#loadedFonts.delete(key);
if (batchId) batchesToPotentialDelete.add(batchId); this.#usageTracker.delete(key);
slugsToDelete.push(slug); 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);
} }
} }
// Only remove a batch if ALL fonts in that batch are expired this.#loadedFonts.clear();
batchesToPotentialDelete.forEach(batchId => { this.#usageTracker.clear();
const batchSlugs = Array.from(this.#slugToBatch.entries()) this.#retryCounts.clear();
.filter(([_, bId]) => bId === batchId) this.statuses.clear();
.map(([slug]) => slug); this.#queue.clear();
const allExpired = batchSlugs.every(s => slugsToDelete.includes(s));
if (allExpired) {
this.#batchElements.get(batchId)?.remove();
this.#batchElements.delete(batchId);
batchSlugs.forEach(s => {
this.#slugToBatch.delete(s);
this.#usageTracker.delete(s);
});
}
});
} }
} }
/** Singleton instance — use throughout the application for unified font loading state. */
export const appliedFontsManager = new AppliedFontsManager(); export const appliedFontsManager = new AppliedFontsManager();

View File

@@ -9,7 +9,6 @@ import type { UnifiedFont } from '../types';
/** */ /** */
export abstract class BaseFontStore<TParams extends Record<string, any>> { export abstract class BaseFontStore<TParams extends Record<string, any>> {
// params = $state<TParams>({} as TParams);
cleanup: () => void; cleanup: () => void;
#bindings = $state<(() => Partial<TParams>)[]>([]); #bindings = $state<(() => Partial<TParams>)[]>([]);
@@ -18,9 +17,11 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
params = $derived.by(() => { params = $derived.by(() => {
let merged = { ...this.#internalParams }; let merged = { ...this.#internalParams };
// Loop through every "Cable" plugged into the store
// Loop through every "Cable" plugged into the store // Loop through every "Cable" plugged into the store
for (const getter of this.#bindings) { for (const getter of this.#bindings) {
merged = { ...merged, ...getter() }; const bindingResult = getter();
merged = { ...merged, ...bindingResult };
} }
return merged as TParams; return merged as TParams;
@@ -54,11 +55,12 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
protected abstract getQueryKey(params: TParams): QueryKey; protected abstract getQueryKey(params: TParams): QueryKey;
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>; protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
private getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> { protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
return { return {
queryKey: this.getQueryKey(params), queryKey: this.getQueryKey(params),
queryFn: () => this.fetchFn(params), queryFn: () => this.fetchFn(params),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
}; };
} }

View File

@@ -1,32 +0,0 @@
import type { FontshareParams } from '../../api';
import { fetchFontshareFontsQuery } from '../services';
import type { UnifiedFont } from '../types';
import { BaseFontStore } from './baseFontStore.svelte';
/**
* Fontshare store wrapping TanStack Query with runes
*/
export class FontshareStore extends BaseFontStore<FontshareParams> {
constructor(initialParams: FontshareParams = {}) {
super(initialParams);
}
protected getQueryKey(params: FontshareParams) {
return ['fontshare', params] as const;
}
protected async fetchFn(params: FontshareParams): Promise<UnifiedFont[]> {
return fetchFontshareFontsQuery(params);
}
// Provider-specific methods (shortcuts)
setSearch(search: string) {
this.setParams({ q: search } as any);
}
}
export function createFontshareStore(params: FontshareParams = {}) {
return new FontshareStore(params);
}
export const fontshareStore = new FontshareStore();

View File

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

View File

@@ -6,18 +6,15 @@
* Single export point for the unified font store infrastructure. * Single export point for the unified font store infrastructure.
*/ */
// export { // Primary store (unified)
// createUnifiedFontStore,
// UNIFIED_FONT_STORE_KEY,
// type UnifiedFontStore,
// } from './unifiedFontStore.svelte';
export { export {
createFontshareStore, createUnifiedFontStore,
type FontshareStore, type UnifiedFontStore,
fontshareStore, unifiedFontStore,
} from './fontshareStore.svelte'; } from './unifiedFontStore.svelte';
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; // Applied fonts manager (CSS loading - unchanged)
export {
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte'; appliedFontsManager,
type FontConfigRequest,
} from './appliedFontsStore/appliedFontsStore.svelte';

View File

@@ -1,7 +0,0 @@
import { createEntityStore } from '$shared/lib';
import type { UnifiedFont } from '../../types';
/**
* Store that handles collection of selected fonts
*/
export const selectedFontsStore = createEntityStore<UnifiedFont>([]);

View File

@@ -1,25 +1,377 @@
import { type Filter } from '$shared/lib'; /**
import { SvelteMap } from 'svelte/reactivity'; * Unified font store
import type { FontProvider } from '../types'; *
import type { CheckboxFilter } from '../types/common'; * Single source of truth for font data, powered by the proxy API.
import type { BaseFontStore } from './baseFontStore.svelte'; * Extends BaseFontStore for TanStack Query integration and reactivity.
import { createFontshareStore } from './fontshareStore.svelte'; *
import type { ProviderParams } from './types'; * 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
*/
export class UnitedFontStore { import type { QueryObserverOptions } from '@tanstack/query-core';
private sources: Partial<Record<FontProvider, BaseFontStore<ProviderParams>>>; import type { ProxyFontsParams } from '../../api';
import { fetchProxyFonts } from '../../api';
import type { UnifiedFont } from '../types';
import { BaseFontStore } from './baseFontStore.svelte';
filters: SvelteMap<CheckboxFilter, Filter>; /**
queryValue = $state(''); * 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);
constructor(initialConfig: Partial<Record<FontProvider, ProviderParams>> = {}) { /**
this.sources = { * Accumulated fonts from all pages (for infinite scroll)
fontshare: createFontshareStore(initialConfig?.fontshare), */
#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,
}; };
this.filters = new SvelteMap(); });
/**
* 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;
}
});
});
} }
get fonts() { /**
return Object.values(this.sources).map(store => store.fonts).flat(); * 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

@@ -32,3 +32,27 @@ export interface FontFilters {
export type CheckboxFilter = 'providers' | 'categories' | 'subsets'; export type CheckboxFilter = 'providers' | 'categories' | 'subsets';
export type FilterType = CheckboxFilter | 'searchQuery'; 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

@@ -4,6 +4,8 @@
* ============================================================================ * ============================================================================
*/ */
import type { FontVariant } from './common';
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'; export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
/** /**
@@ -86,30 +88,6 @@ export interface FontItem {
*/ */
export type GoogleFontItem = FontItem; export type GoogleFontItem = FontItem;
/**
* Standard font weights that can appear in Google Fonts API
*/
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
/**
* Italic variant format: e.g., "100italic", "400italic", "700italic"
*/
export type FontWeightItalic = `${FontWeight}italic`;
/**
* All possible font variants in Google Fonts API
* - Numeric weights: "400", "700", etc.
* - Italic variants: "400italic", "700italic", etc.
* - Legacy names: "regular", "italic", "bold", "bolditalic"
*/
export type FontVariant =
| FontWeight
| FontWeightItalic
| 'regular'
| 'italic'
| 'bold'
| 'bolditalic';
/** /**
* Google Fonts API file mapping * Google Fonts API file mapping
* Dynamic keys that match the variants array * Dynamic keys that match the variants array

View File

@@ -12,15 +12,15 @@ export type {
FontCategory, FontCategory,
FontProvider, FontProvider,
FontSubset, FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
} from './common'; } from './common';
// Google Fonts API types // Google Fonts API types
export type { export type {
FontFiles, FontFiles,
FontItem, FontItem,
FontVariant,
FontWeight,
FontWeightItalic,
GoogleFontItem, GoogleFontItem,
GoogleFontsApiModel, GoogleFontsApiModel,
} from './google'; } from './google';

View File

@@ -8,17 +8,18 @@ import type {
FontCategory, FontCategory,
FontProvider, FontProvider,
FontSubset, FontSubset,
FontVariant,
} from './common'; } from './common';
/** /**
* Font variant types (standardized) * Font variant types (standardized)
*/ */
export type UnifiedFontVariant = string; export type UnifiedFontVariant = FontVariant;
/** /**
* Font style URLs * Font style URLs
*/ */
export interface FontStyleUrls { export interface LegacyFontStyleUrls {
/** Regular weight URL */ /** Regular weight URL */
regular?: string; regular?: string;
/** Italic URL */ /** Italic URL */
@@ -29,6 +30,10 @@ export interface FontStyleUrls {
boldItalic?: string; boldItalic?: string;
} }
export interface FontStyleUrls extends LegacyFontStyleUrls {
variants?: Partial<Record<UnifiedFontVariant, string>>;
}
/** /**
* Font metadata * Font metadata
*/ */

View File

@@ -2,23 +2,27 @@
Component: FontApplicator Component: FontApplicator
Loads fonts from fontshare with link tag Loads fonts from fontshare with link tag
- Loads font only if it's not already applied - Loads font only if it's not already applied
- Uses IntersectionObserver to detect when font is visible - Reacts to font load status to show/hide content
- Adds smooth transition when font is loaded - Adds smooth transition when font appears
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { appliedFontsManager } from '../../model'; import { prefersReducedMotion } from 'svelte/motion';
import {
type UnifiedFont,
appliedFontsManager,
} from '../../model';
interface Props { interface Props {
/** /**
* Font name to set * Applied font
*/ */
name: string; font: UnifiedFont;
/** /**
* Font id to load * Font weight
*/ */
id: string; weight?: number;
/** /**
* Additional classes * Additional classes
*/ */
@@ -29,28 +33,43 @@ interface Props {
children?: Snippet; children?: Snippet;
} }
let { name, id, className, children }: Props = $props(); let {
let element: Element; font,
weight = 400,
className,
children,
}: Props = $props();
$effect(() => { const status = $derived(
const observer = new IntersectionObserver(entries => { appliedFontsManager.getFontStatus(
if (entries[0].isIntersecting) { font.id,
appliedFontsManager.touch([id]); weight,
} font.features.isVariable,
}); ),
observer.observe(element); );
return () => observer.disconnect();
});
const isLoading = $derived(appliedFontsManager.getFontStatus(id) === 'loading'); // 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> </script>
<div <div
bind:this={element} style:font-family={shouldReveal
style:font-family={name} ? `'${font.name}'`
: 'system-ui, -apple-system, sans-serif'}
class={cn( class={cn(
'transition-all duration-200 ease-out', transitionClasses,
isLoading ? 'opacity-0 translate-y-1' : 'opacity-100 translate-y-0', // 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, className,
)} )}
> >

View File

@@ -1,84 +1,39 @@
<!--
Component: FontListItem
Displays a font item with a checkbox and its characteristics in badges.
-->
<script lang="ts"> <script lang="ts">
import { Badge } from '$shared/shadcn/ui/badge'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Checkbox } from '$shared/shadcn/ui/checkbox'; import type { Snippet } from 'svelte';
import { Label } from '$shared/shadcn/ui/label'; import { type UnifiedFont } from '../../model';
import {
type UnifiedFont,
selectedFontsStore,
} from '../../model';
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
interface Props { interface Props {
/** /**
* Object with information about font * Object with information about font
*/ */
font: UnifiedFont; 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 }: Props = $props(); const { font, children }: Props = $props();
const handleChange = (checked: boolean) => {
if (checked) {
selectedFontsStore.addOne(font);
} else {
selectedFontsStore.removeOne(font.id);
}
};
const selected = $derived(selectedFontsStore.has(font.id));
</script> </script>
<div class="pb-1"> <div
<Label class={cn(
for={font.id} 'pb-1 will-change-transform transition-transform duration-200 ease-out',
class=" 'hover:scale-[0.98]', // Simple CSS hover effect
w-full hover:bg-accent/50 flex items-start gap-3 rounded-lg border border-transparent p-3 )}
active:scale-[0.98] active:transition-transform active:duration-75 >
has-aria-checked:border-blue-600 {@render children?.(font)}
has-aria-checked:bg-blue-50
dark:has-aria-checked:border-blue-900
dark:has-aria-checked:bg-blue-950
"
>
<div class="w-full">
<div class="flex flex-row gap-1 w-full items-center justify-between">
<div class="flex flex-col gap-1 transition-all duration-150 ease-out">
<div class="flex flex-row gap-1">
<Badge variant="outline" class="text-[0.5rem]">
{font.provider}
</Badge>
<Badge variant="outline" class="text-[0.5rem]">
{font.category}
</Badge>
</div>
<FontApplicator
id={font.id}
className="text-2xl"
name={font.name}
>
{font.name}
</FontApplicator>
</div>
<Checkbox
id={font.id}
checked={selected}
onCheckedChange={handleChange}
class="
transition-all duration-150 ease-out
data-[state=checked]:scale-100
data-[state=checked]:border-blue-600
data-[state=checked]:bg-blue-600
data-[state=checked]:text-white
dark:data-[state=checked]:border-blue-700
dark:data-[state=checked]:bg-blue-700
"
/>
</div>
</div>
</Label>
</div> </div>

View File

@@ -3,33 +3,127 @@
- Renders a virtualized list of fonts - Renders a virtualized list of fonts
- Handles font registration with the manager - Handles font registration with the manager
--> -->
<script lang="ts" generics="T extends { id: string }"> <script lang="ts">
import { VirtualList } from '$shared/ui'; import {
import type { ComponentProps } from 'svelte'; Skeleton,
import { appliedFontsManager } from '../../model'; 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<T>>, 'onVisibleItemsChange'> { interface Props extends
onVisibleItemsChange?: (items: T[]) => void; 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 { items, children, onVisibleItemsChange, ...rest }: Props = $props(); let {
children,
onVisibleItemsChange,
weight,
skeleton,
...rest
}: Props = $props();
function handleInternalVisibleChange(visibleItems: T[]) { 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 // Auto-register fonts with the manager
const slugs = visibleItems.map(item => item.id); appliedFontsManager.touch(configs);
appliedFontsManager.registerFonts(slugs);
// Forward the call to any external listener // Forward the call to any external listener
onVisibleItemsChange?.(visibleItems); // 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> </script>
<VirtualList <div class="relative w-full h-full">
{items} {#if skeleton && isLoading && unifiedFontStore.fonts.length === 0}
{...rest} <!-- Show skeleton only on initial load when no fonts are loaded yet -->
onVisibleItemsChange={handleInternalVisibleChange} <div transition:fade={{ duration: 300 }}>
> {@render skeleton()}
{#snippet children(scope)} </div>
{@render children(scope)} {:else}
{/snippet} <!-- VirtualList persists during pagination - no destruction/recreation -->
</VirtualList> <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

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

View File

@@ -1 +0,0 @@
export { displayedFontsStore } from './store';

View File

@@ -1,28 +0,0 @@
import { selectedFontsStore } from '$entities/Font';
/**
* Store for displayed font samples
* - Handles shown text
* - Stores selected fonts for display
*/
export class DisplayedFontsStore {
#sampleText = $state('The quick brown fox jumps over the lazy dog');
#displayedFonts = $derived.by(() => {
return selectedFontsStore.all;
});
get fonts() {
return this.#displayedFonts;
}
get text() {
return this.#sampleText;
}
set text(text: string) {
this.#sampleText = text;
}
}
export const displayedFontsStore = new DisplayedFontsStore();

View File

@@ -1 +0,0 @@
export { displayedFontsStore } from './displayedFontsStore.svelte';

View File

@@ -1,14 +0,0 @@
<!--
Component: FontDisplay
Displays a grid of FontSampler components for each displayed font.
-->
<script>
import { displayedFontsStore } from '../../model';
import FontSampler from '../FontSampler/FontSampler.svelte';
</script>
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))]">
{#each displayedFontsStore.fonts as font (font.id)}
<FontSampler font={font} bind:text={displayedFontsStore.text} />
{/each}
</div>

View File

@@ -7,7 +7,13 @@ import {
FontApplicator, FontApplicator,
type UnifiedFont, type UnifiedFont,
} from '$entities/Font'; } from '$entities/Font';
import { ContentEditable } from '$shared/ui'; import { controlManager } from '$features/SetupFont';
import {
ContentEditable,
Footnote,
// IconButton,
} from '$shared/ui';
// import XIcon from '@lucide/svelte/icons/x';
interface Props { interface Props {
/** /**
@@ -18,6 +24,10 @@ interface Props {
* Text to display * Text to display
*/ */
text: string; text: string;
/**
* Index of the font sampler
*/
index?: number;
/** /**
* Font settings * Font settings
*/ */
@@ -26,21 +36,75 @@ interface Props {
letterSpacing?: number; letterSpacing?: number;
} }
let { let { font, text = $bindable(), index = 0, ...restProps }: Props = $props();
font,
text = $bindable(), const fontWeight = $derived(controlManager.weight);
...restProps const fontSize = $derived(controlManager.renderedSize);
}: Props = $props(); const lineHeight = $derived(controlManager.height);
const letterSpacing = $derived(controlManager.spacing);
</script> </script>
<div <div
class=" class="
w-full rounded-xl w-full h-full rounded-xl sm:rounded-2xl
bg-white p-6 border border-slate-200 flex flex-col
shadow-sm dark:border-slate-800 dark:bg-slate-950 bg-background-80
border border-border-muted
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
relative overflow-hidden
" "
style:font-weight={fontWeight}
> >
<FontApplicator id={font.id} name={font.name}> <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">
<ContentEditable bind:text={text} {...restProps} /> <div class="flex items-center gap-2 sm:gap-2.5">
</FontApplicator> <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> </div>

View File

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

View File

@@ -15,5 +15,4 @@ export { filterManager } from './model/state/manager.svelte';
export { export {
FilterControls, FilterControls,
Filters, Filters,
FontSearch,
} from './ui'; } from './ui';

View File

@@ -1,18 +1,54 @@
import type { FontshareParams } from '$entities/Font'; import type { ProxyFontsParams } from '$entities/Font/api';
import type { FilterManager } from '../filterManager/filterManager.svelte'; import type { FilterManager } from '../filterManager/filterManager.svelte';
/** /**
* Maps filter manager to fontshare params. * Maps filter manager to proxy API parameters.
* *
* @param manager - Filter manager instance. * Transforms UI filter state into proxy API query parameters.
* @returns - Partial fontshare params. * 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<FontshareParams> { 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 { return {
q: manager.debouncedQueryValue, // Search query (debounced)
// Map groups to specific API keys q: manager.debouncedQueryValue || undefined,
categories: manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value)
?? [], // Provider filter (single value - proxy API doesn't support array)
tags: manager.getGroup('tags')?.instance.selectedProperties.map(p => p.value) ?? [], // 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

@@ -5,15 +5,42 @@
--> -->
<script lang="ts"> <script lang="ts">
import { Button } from '$shared/shadcn/ui/button'; 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'; 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> </script>
<div class="flex flex-row gap-2"> <div
class={cn('flex flex-row gap-2', className)}
style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)"
>
<Button <Button
variant="outline" variant="ghost"
class="flex-1 cursor-pointer" class="group flex flex-1 cursor-pointer gap-1"
onclick={filterManager.deselectAllGlobal} onclick={handleClick}
> >
<Rotate class="size-4 group-hover:-rotate-180 transition-transform duration-300" />
Reset Reset
</Button> </Button>
</div> </div>

View File

@@ -1,33 +0,0 @@
<!--
Component: FontSearch
Combines search input with font list display
-->
<script lang="ts">
import { fontshareStore } from '$entities/Font';
import { SearchBar } from '$shared/ui';
import { onMount } from 'svelte';
import { mapManagerToParams } from '../../lib';
import { filterManager } from '../../model';
import SuggestedFonts from '../SuggestedFonts/SuggestedFonts.svelte';
onMount(() => {
/**
* The Pairing:
* We "plug" this manager into the global store.
* addBinding returns a function that removes this binding when the component unmounts.
*/
const unbind = fontshareStore.addBinding(() => mapManagerToParams(filterManager));
return unbind;
});
</script>
<SearchBar
id="font-search"
class="w-full"
placeholder="Search fonts by name..."
bind:value={filterManager.queryValue}
>
<SuggestedFonts />
</SearchBar>

View File

@@ -1,17 +0,0 @@
<!--
Component: SuggestedFonts
Renders a list of suggested fonts in a virtualized list to improve performance.
-->
<script lang="ts">
import {
FontListItem,
FontVirtualList,
fontshareStore,
} from '$entities/Font';
</script>
<FontVirtualList items={fontshareStore.fonts}>
{#snippet children({ item: font })}
<FontListItem {font} />
{/snippet}
</FontVirtualList>

View File

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

View File

@@ -1,10 +1,13 @@
import SetupFontMenu from './ui/SetupFontMenu.svelte'; export { TypographyMenu } from './ui';
export { export {
type ControlId,
controlManager, controlManager,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
FONT_SIZE_STEP, FONT_SIZE_STEP,
FONT_WEIGHT_STEP, FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP, LINE_HEIGHT_STEP,
@@ -14,5 +17,12 @@ export {
MIN_FONT_SIZE, MIN_FONT_SIZE,
MIN_FONT_WEIGHT, MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT, MIN_LINE_HEIGHT,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from './model'; } from './model';
export { SetupFontMenu };
export {
createTypographyControlManager,
type TypographyControlManager,
} from './lib';

View File

@@ -1,28 +1,215 @@
import { import {
type ControlDataModel,
type ControlModel, type ControlModel,
type PersistentStore,
type TypographyControl,
createPersistentStore,
createTypographyControl, createTypographyControl,
} from '$shared/lib'; } 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. * Creates a typography control manager that handles a collection of typography controls.
* *
* @param configs - Array of control configurations. * @param configs - Array of control configurations.
* @param storageId - Persistent storage identifier.
* @returns - Typography control manager instance. * @returns - Typography control manager instance.
*/ */
export function createTypographyControlManager(configs: ControlModel[]) { export function createTypographyControlManager(
const controls = $state( configs: ControlModel<ControlId>[],
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({ storageId: string = 'glyphdiff:typography',
id, ) {
increaseLabel, const storage = createPersistentStore<TypographySettings>(storageId, {
decreaseLabel, fontSize: DEFAULT_FONT_SIZE,
controlLabel, fontWeight: DEFAULT_FONT_WEIGHT,
instance: createTypographyControl(config), lineHeight: DEFAULT_LINE_HEIGHT,
})), letterSpacing: DEFAULT_LETTER_SPACING,
); });
return new TypographyControlManager(configs, storage);
return {
get controls() {
return controls;
},
};
} }

View File

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

View File

@@ -1,7 +1,10 @@
import type { ControlModel } from '$shared/lib';
import type { ControlId } from '..';
/** /**
* Font size constants * Font size constants
*/ */
export const DEFAULT_FONT_SIZE = 16; export const DEFAULT_FONT_SIZE = 48;
export const MIN_FONT_SIZE = 8; export const MIN_FONT_SIZE = 8;
export const MAX_FONT_SIZE = 100; export const MAX_FONT_SIZE = 100;
export const FONT_SIZE_STEP = 1; export const FONT_SIZE_STEP = 1;
@@ -21,3 +24,65 @@ export const DEFAULT_LINE_HEIGHT = 1.5;
export const MIN_LINE_HEIGHT = 1; export const MIN_LINE_HEIGHT = 1;
export const MAX_LINE_HEIGHT = 2; export const MAX_LINE_HEIGHT = 2;
export const LINE_HEIGHT_STEP = 0.05; 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

@@ -1,7 +1,9 @@
export { export {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
FONT_SIZE_STEP, FONT_SIZE_STEP,
FONT_WEIGHT_STEP, FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP, LINE_HEIGHT_STEP,
@@ -11,6 +13,12 @@ export {
MIN_FONT_SIZE, MIN_FONT_SIZE,
MIN_FONT_WEIGHT, MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT, MIN_LINE_HEIGHT,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from './const/const'; } from './const/const';
export { controlManager } from './state/manager.svelte'; export {
type ControlId,
controlManager,
} from './state/manager.svelte';

View File

@@ -1,54 +1,6 @@
import type { ControlModel } from '$shared/lib';
import { createTypographyControlManager } from '../../lib'; import { createTypographyControlManager } from '../../lib';
import { import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../const/const';
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LINE_HEIGHT,
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,
} from '../const/const';
const controlData: ControlModel[] = [ export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
step: FONT_SIZE_STEP,
increaseLabel: 'Increase Font Size', export const controlManager = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA);
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',
},
];
export const controlManager = createTypographyControlManager(controlData);

View File

@@ -1,20 +0,0 @@
<!--
Component: SetupFontMenu
Contains controls for setting up font properties.
-->
<script lang="ts">
import { Separator } from '$shared/shadcn/ui/separator/index';
import { Trigger as SidebarTrigger } from '$shared/shadcn/ui/sidebar';
import { ComboControl } from '$shared/ui';
import { controlManager } from '../model';
</script>
<div class="p-2 flex flex-row items-center gap-2">
<SidebarTrigger />
<Separator orientation="vertical" class="h-full" />
<div class="flex flex-row gap-2">
{#each controlManager.controls as control (control.id)}
<ComboControl control={control.instance} />
{/each}
</div>
</div>

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,12 +1,152 @@
<!--
Component: Page
Description: The main page component of the application.
-->
<script lang="ts"> <script lang="ts">
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte'; 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;
* Page Component
*/ 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> </script>
<!-- Font List --> <!-- Font List -->
<div class="p-2"> <div
<FontDisplay /> 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> </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), body: JSON.stringify(body),
}), }),
delete: <T>(url: string, options?: RequestInit) => delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
request<T>(url, { ...options, method: 'DELETE' }),
}; };

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

View File

@@ -1,32 +0,0 @@
// Check if we are in a browser environment
const isBrowser = typeof window !== 'undefined';
// A class to manage motion preference and provide a single instance for use everywhere
class MotionPreference {
// Reactive state
#reduced = $state(false);
constructor() {
if (isBrowser) {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// Set initial value immediately
this.#reduced = mediaQuery.matches;
// Simple listener that updates the reactive state
const handleChange = (e: MediaQueryListEvent) => {
this.#reduced = e.matches;
};
mediaQuery.addEventListener('change', handleChange);
}
}
// Getter allows us to use 'motion.reduced' reactively in components
get reduced() {
return this.#reduced;
}
}
// Export a single instance to be used everywhere
export const motion = new MotionPreference();

View File

@@ -0,0 +1,293 @@
/**
* Interface representing a line of text with its measured width.
*/
export interface LineData {
/**
* Line's text
*/
text: string;
/**
* It's width
*/
width: number;
}
/**
* Creates a helper for splitting text into lines and calculating character proximity.
* This is used by the ComparisonSlider (TestTen) to render morphing text.
*
* @param text - The text to split and measure
* @param fontA - The first font definition
* @param fontB - The second font definition
* @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState)
*/
export function createCharacterComparison<
T extends { name: string; id: string } | undefined = undefined,
>(
text: () => string,
fontA: () => T,
fontB: () => T,
weight: () => number,
size: () => number,
) {
let lines = $state<LineData[]>([]);
let containerWidth = $state(0);
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
return font !== undefined;
}
/**
* Measures text width using a canvas context.
* @param ctx - Canvas rendering context
* @param text - Text string to measure
* @param fontFamily - Font family name
* @param fontSize - Font size in pixels
* @param fontWeight - Font weight
*/
function measureText(
ctx: CanvasRenderingContext2D,
text: string,
fontSize: number,
fontWeight: number,
fontFamily?: string,
): number {
if (!fontFamily) return 0;
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
return ctx.measureText(text).width;
}
/**
* Determines the appropriate font size based on window width.
* Matches the Tailwind breakpoints used in the component.
*/
function getFontSize() {
if (typeof window === 'undefined') {
return 64;
}
return window.innerWidth >= 1024
? 112
: window.innerWidth >= 768
? 96
: window.innerWidth >= 640
? 80
: 64;
}
/**
* Breaks the text into lines based on the container width and measure canvas.
* Populates the `lines` state.
*
* @param container - The container element to measure width from
* @param measureCanvas - The canvas element used for text measurement
*/
function breakIntoLines(
container: HTMLElement | undefined,
measureCanvas: HTMLCanvasElement | undefined,
) {
if (!container || !measureCanvas || !fontA() || !fontB()) {
return;
}
// Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues
// getBoundingClientRect() returns transformed dimensions, which causes incorrect line breaking
// when PerspectivePlan applies scale() transforms (e.g., scale(0.5) in settings mode)
const width = container.offsetWidth;
containerWidth = width;
// Padding considerations - matches the container padding
const padding = window.innerWidth < 640 ? 48 : 96;
const availableWidth = width - padding;
const ctx = measureCanvas.getContext('2d');
if (!ctx) {
return;
}
const controlledFontSize = size();
const fontSize = getFontSize();
const currentWeight = weight(); // Get current weight
const words = text().split(' ');
const newLines: LineData[] = [];
let currentLineWords: string[] = [];
function pushLine(words: string[]) {
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
return;
}
const lineText = words.join(' ');
// Measure both fonts at the CURRENT weight
const widthA = measureText(
ctx!,
lineText,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontA()?.name,
);
const widthB = measureText(
ctx!,
lineText,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontB()?.name,
);
const maxWidth = Math.max(widthA, widthB);
newLines.push({ text: lineText, width: maxWidth });
}
for (const word of words) {
const testLine = currentLineWords.length > 0
? currentLineWords.join(' ') + ' ' + word
: word;
// Measure with both fonts and use the wider one to prevent layout shifts
const widthA = measureText(
ctx,
testLine,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontA()?.name,
);
const widthB = measureText(
ctx,
testLine,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontB()?.name,
);
const maxWidth = Math.max(widthA, widthB);
const isContainerOverflown = maxWidth > availableWidth;
if (isContainerOverflown) {
if (currentLineWords.length > 0) {
pushLine(currentLineWords);
currentLineWords = [];
}
const wordWidthA = measureText(
ctx,
word,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontA()?.name,
);
const wordWidthB = measureText(
ctx,
word,
Math.min(fontSize, controlledFontSize),
currentWeight,
fontB()?.name,
);
const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
if (wordAloneWidth <= availableWidth) {
// If word fits start new line with it
currentLineWords = [word];
} else {
let remainingWord = word;
while (remainingWord.length > 0) {
let low = 1;
let high = remainingWord.length;
let bestBreak = 1;
// Binary Search to find the maximum characters that fit
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const testFragment = remainingWord.slice(0, mid);
const wA = measureText(
ctx,
testFragment,
fontSize,
currentWeight,
fontA()?.name,
);
const wB = measureText(
ctx,
testFragment,
fontSize,
currentWeight,
fontB()?.name,
);
if (Math.max(wA, wB) <= availableWidth) {
bestBreak = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
pushLine([remainingWord.slice(0, bestBreak)]);
remainingWord = remainingWord.slice(bestBreak);
}
}
} else if (maxWidth > availableWidth && currentLineWords.length > 0) {
pushLine(currentLineWords);
currentLineWords = [word];
} else {
currentLineWords.push(word);
}
}
if (currentLineWords.length > 0) {
pushLine(currentLineWords);
}
lines = newLines;
}
/**
* precise calculation of character state based on global slider position.
*
* @param charIndex - Index of the character in the line
* @param sliderPos - Current slider position (0-100)
* @param lineElement - The line element
* @param container - The container element
* @returns Object containing proximity (0-1) and isPast (boolean)
*/
function getCharState(
charIndex: number,
sliderPos: number,
lineElement?: HTMLElement,
container?: HTMLElement,
) {
if (!containerWidth || !container) {
return {
proximity: 0,
isPast: false,
};
}
const charElement = lineElement?.children[charIndex] as HTMLElement;
if (!charElement) {
return { proximity: 0, isPast: false };
}
// Get the actual bounding box of the character
const charRect = charElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
// Calculate character center relative to container
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
const charGlobalPercent = (charCenter / containerWidth) * 100;
const distance = Math.abs(sliderPos - charGlobalPercent);
const range = 5;
const proximity = Math.max(0, 1 - distance / range);
const isPast = sliderPos > charGlobalPercent;
return { proximity, isPast };
}
return {
get lines() {
return lines;
},
get containerWidth() {
return containerWidth;
},
breakIntoLines,
getCharState,
};
}
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;

View File

@@ -46,9 +46,6 @@ export class EntityStore<T extends Entity> {
updateOne(id: string, changes: Partial<T>) { updateOne(id: string, changes: Partial<T>) {
const entity = this.#entities.get(id); const entity = this.#entities.get(id);
if (entity) { if (entity) {
// In Svelte 5, updating the object property directly is reactive
// if the object itself was made reactive, but here we replace
// the reference to ensure top-level map triggers.
this.#entities.set(id, { ...entity, ...changes }); this.#entities.set(id, { ...entity, ...changes });
} }
} }

View File

@@ -0,0 +1,420 @@
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import {
type Entity,
EntityStore,
createEntityStore,
} from './createEntityStore.svelte';
interface TestEntity {
id: string;
name: string;
value: number;
}
describe('createEntityStore', () => {
describe('Construction and Initialization', () => {
it('should create an empty store when no initial entities are provided', () => {
const store = createEntityStore<TestEntity>();
expect(store.all).toEqual([]);
});
it('should create a store with initial entities', () => {
const initialEntities: TestEntity[] = [
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
];
const store = createEntityStore(initialEntities);
expect(store.all).toHaveLength(2);
expect(store.all).toEqual(initialEntities);
});
it('should create EntityStore instance', () => {
const store = createEntityStore<TestEntity>();
expect(store).toBeInstanceOf(EntityStore);
});
});
describe('Selectors', () => {
let store: EntityStore<TestEntity>;
let entities: TestEntity[];
beforeEach(() => {
entities = [
{ id: '1', name: 'First', value: 10 },
{ id: '2', name: 'Second', value: 20 },
{ id: '3', name: 'Third', value: 30 },
];
store = createEntityStore(entities);
});
it('should return all entities as an array', () => {
const all = store.all;
expect(all).toEqual(entities);
expect(all).toHaveLength(3);
});
it('should get a single entity by ID', () => {
const entity = store.getById('2');
expect(entity).toEqual({ id: '2', name: 'Second', value: 20 });
});
it('should return undefined for non-existent ID', () => {
const entity = store.getById('999');
expect(entity).toBeUndefined();
});
it('should get multiple entities by IDs', () => {
const entities = store.getByIds(['1', '3']);
expect(entities).toEqual([
{ id: '1', name: 'First', value: 10 },
{ id: '3', name: 'Third', value: 30 },
]);
});
it('should filter out undefined results when getting by IDs', () => {
const entities = store.getByIds(['1', '999', '3']);
expect(entities).toEqual([
{ id: '1', name: 'First', value: 10 },
{ id: '3', name: 'Third', value: 30 },
]);
expect(entities).toHaveLength(2);
});
it('should return empty array when no IDs match', () => {
const entities = store.getByIds(['999', '888']);
expect(entities).toEqual([]);
});
it('should check if entity exists by ID', () => {
expect(store.has('1')).toBe(true);
expect(store.has('999')).toBe(false);
});
});
describe('CRUD Operations - Create', () => {
it('should add a single entity', () => {
const store = createEntityStore<TestEntity>();
store.addOne({ id: '1', name: 'First', value: 1 });
expect(store.all).toHaveLength(1);
expect(store.getById('1')).toEqual({ id: '1', name: 'First', value: 1 });
});
it('should add multiple entities at once', () => {
const store = createEntityStore<TestEntity>();
store.addMany([
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
{ id: '3', name: 'Third', value: 3 },
]);
expect(store.all).toHaveLength(3);
});
it('should replace entity when adding with existing ID', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
store.addOne({ id: '1', name: 'Updated', value: 2 });
expect(store.all).toHaveLength(1);
expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 2 });
});
});
describe('CRUD Operations - Update', () => {
it('should update an existing entity', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
store.updateOne('1', { name: 'Updated' });
expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 1 });
});
it('should update multiple properties at once', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
store.updateOne('1', { name: 'Updated', value: 2 });
expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 2 });
});
it('should do nothing when updating non-existent entity', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
store.updateOne('999', { name: 'Updated' });
expect(store.all).toHaveLength(1);
expect(store.getById('1')).toEqual({ id: '1', name: 'Original', value: 1 });
});
it('should preserve entity when no changes are provided', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
store.updateOne('1', {});
expect(store.getById('1')).toEqual({ id: '1', name: 'Original', value: 1 });
});
});
describe('CRUD Operations - Delete', () => {
it('should remove a single entity', () => {
const store = createEntityStore<TestEntity>([
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
]);
store.removeOne('1');
expect(store.all).toHaveLength(1);
expect(store.getById('1')).toBeUndefined();
expect(store.getById('2')).toEqual({ id: '2', name: 'Second', value: 2 });
});
it('should remove multiple entities', () => {
const store = createEntityStore<TestEntity>([
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
{ id: '3', name: 'Third', value: 3 },
]);
store.removeMany(['1', '3']);
expect(store.all).toHaveLength(1);
expect(store.getById('2')).toEqual({ id: '2', name: 'Second', value: 2 });
});
it('should do nothing when removing non-existent entity', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
store.removeOne('999');
expect(store.all).toHaveLength(1);
});
it('should handle empty array when removing many', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
store.removeMany([]);
expect(store.all).toHaveLength(1);
});
});
describe('Bulk Operations', () => {
it('should set all entities, replacing existing', () => {
const store = createEntityStore<TestEntity>([
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
]);
store.setAll([{ id: '3', name: 'Third', value: 3 }]);
expect(store.all).toHaveLength(1);
expect(store.getById('1')).toBeUndefined();
expect(store.getById('3')).toEqual({ id: '3', name: 'Third', value: 3 });
});
it('should clear all entities', () => {
const store = createEntityStore<TestEntity>([
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
]);
store.clear();
expect(store.all).toEqual([]);
expect(store.all).toHaveLength(0);
});
});
describe('Reactivity with SvelteMap', () => {
it('should return reactive arrays', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
// The all getter should return a fresh array (or reactive state)
const first = store.all;
const second = store.all;
// Both should have the same content
expect(first).toEqual(second);
});
it('should reflect changes in subsequent calls', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
expect(store.all).toHaveLength(1);
store.addOne({ id: '2', name: 'Second', value: 2 });
expect(store.all).toHaveLength(2);
});
});
describe('Edge Cases', () => {
it('should handle empty initial array', () => {
const store = createEntityStore<TestEntity>([]);
expect(store.all).toEqual([]);
});
it('should handle single entity', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
expect(store.all).toHaveLength(1);
expect(store.getById('1')).toEqual({ id: '1', name: 'First', value: 1 });
});
it('should handle entities with complex objects', () => {
interface ComplexEntity extends Entity {
id: string;
data: {
nested: {
value: string;
};
};
tags: string[];
}
const entity: ComplexEntity = {
id: '1',
data: { nested: { value: 'test' } },
tags: ['a', 'b', 'c'],
};
const store = createEntityStore<ComplexEntity>([entity]);
expect(store.getById('1')).toEqual(entity);
});
it('should handle numeric string IDs', () => {
const store = createEntityStore<TestEntity>([
{ id: '123', name: 'First', value: 1 },
{ id: '456', name: 'Second', value: 2 },
]);
expect(store.getById('123')).toEqual({ id: '123', name: 'First', value: 1 });
expect(store.getById('456')).toEqual({ id: '456', name: 'Second', value: 2 });
});
it('should handle UUID-like IDs', () => {
const uuid1 = '550e8400-e29b-41d4-a716-446655440000';
const uuid2 = '550e8400-e29b-41d4-a716-446655440001';
const store = createEntityStore<TestEntity>([
{ id: uuid1, name: 'First', value: 1 },
{ id: uuid2, name: 'Second', value: 2 },
]);
expect(store.getById(uuid1)).toEqual({ id: uuid1, name: 'First', value: 1 });
});
});
describe('Type Safety', () => {
it('should enforce Entity type with id property', () => {
// This test verifies type checking at compile time
const validEntity: TestEntity = { id: '1', name: 'Test', value: 1 };
const store = createEntityStore<TestEntity>([validEntity]);
expect(store.getById('1')).toEqual(validEntity);
});
it('should work with different entity types', () => {
interface User extends Entity {
id: string;
name: string;
email: string;
}
interface Product extends Entity {
id: string;
title: string;
price: number;
}
const userStore = createEntityStore<User>([
{ id: 'u1', name: 'John', email: 'john@example.com' },
]);
const productStore = createEntityStore<Product>([
{ id: 'p1', title: 'Widget', price: 9.99 },
]);
expect(userStore.getById('u1')?.email).toBe('john@example.com');
expect(productStore.getById('p1')?.price).toBe(9.99);
});
});
describe('Large Datasets', () => {
it('should handle large number of entities efficiently', () => {
const entities: TestEntity[] = Array.from({ length: 1000 }, (_, i) => ({
id: `id-${i}`,
name: `Entity ${i}`,
value: i,
}));
const store = createEntityStore(entities);
expect(store.all).toHaveLength(1000);
expect(store.getById('id-500')).toEqual({
id: 'id-500',
name: 'Entity 500',
value: 500,
});
});
it('should efficiently check existence in large dataset', () => {
const entities: TestEntity[] = Array.from({ length: 1000 }, (_, i) => ({
id: `id-${i}`,
name: `Entity ${i}`,
value: i,
}));
const store = createEntityStore(entities);
expect(store.has('id-999')).toBe(true);
expect(store.has('id-1000')).toBe(false);
});
});
describe('Method Chaining', () => {
it('should support chaining add operations', () => {
const store = createEntityStore<TestEntity>();
store.addOne({ id: '1', name: 'First', value: 1 });
store.addOne({ id: '2', name: 'Second', value: 2 });
store.addOne({ id: '3', name: 'Third', value: 3 });
expect(store.all).toHaveLength(3);
});
it('should support chaining update operations', () => {
const store = createEntityStore<TestEntity>([
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
]);
store.updateOne('1', { value: 10 });
store.updateOne('2', { value: 20 });
expect(store.getById('1')?.value).toBe(10);
expect(store.getById('2')?.value).toBe(20);
});
});
});

View File

@@ -0,0 +1,53 @@
/**
* Reusable persistent storage utility using Svelte 5 runes
*
* Automatically syncs state with localStorage.
*/
export function createPersistentStore<T>(key: string, defaultValue: T) {
// Initialize from storage or default
const loadFromStorage = (): T => {
if (typeof window === 'undefined') {
return defaultValue;
}
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (error) {
console.warn(`[createPersistentStore] Error loading ${key}:`, error);
return defaultValue;
}
};
let value = $state<T>(loadFromStorage());
// Sync to storage whenever value changes
$effect.root(() => {
$effect(() => {
if (typeof window === 'undefined') {
return;
}
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.warn(`[createPersistentStore] Error saving ${key}:`, error);
}
});
});
return {
get value() {
return value;
},
set value(v: T) {
value = v;
},
clear() {
if (typeof window !== 'undefined') {
localStorage.removeItem(key);
}
value = defaultValue;
},
};
}
export type PersistentStore<T> = ReturnType<typeof createPersistentStore<T>>;

View File

@@ -0,0 +1,377 @@
/** @vitest-environment jsdom */
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { createPersistentStore } from './createPersistentStore.svelte';
describe('createPersistentStore', () => {
let mockLocalStorage: Storage;
const testKey = 'test-store-key';
beforeEach(() => {
// Mock localStorage
const storeMap = new Map<string, string>();
mockLocalStorage = {
get length() {
return storeMap.size;
},
clear() {
storeMap.clear();
},
getItem(key: string) {
return storeMap.get(key) ?? null;
},
setItem(key: string, value: string) {
storeMap.set(key, value);
},
removeItem(key: string) {
storeMap.delete(key);
},
key(index: number) {
return Array.from(storeMap.keys())[index] ?? null;
},
};
vi.stubGlobal('localStorage', mockLocalStorage);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('Initialization', () => {
it('should create store with default value when localStorage is empty', () => {
const store = createPersistentStore(testKey, 'default');
expect(store.value).toBe('default');
});
it('should create store with value from localStorage', () => {
mockLocalStorage.setItem(testKey, JSON.stringify('stored value'));
const store = createPersistentStore(testKey, 'default');
expect(store.value).toBe('stored value');
});
it('should parse JSON from localStorage', () => {
const storedValue = { name: 'Test', count: 42 };
mockLocalStorage.setItem(testKey, JSON.stringify(storedValue));
const store = createPersistentStore(testKey, { name: 'Default', count: 0 });
expect(store.value).toEqual(storedValue);
});
it('should use default value when localStorage has invalid JSON', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
mockLocalStorage.setItem(testKey, 'invalid json{');
const store = createPersistentStore(testKey, 'default');
expect(store.value).toBe('default');
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('Reading Values', () => {
it('should return current value via getter', () => {
const store = createPersistentStore(testKey, 'default');
expect(store.value).toBe('default');
});
it('should return updated value after setter', () => {
const store = createPersistentStore(testKey, 'default');
store.value = 'updated';
expect(store.value).toBe('updated');
});
it('should preserve type information', () => {
interface TestObject {
name: string;
count: number;
}
const defaultValue: TestObject = { name: 'Test', count: 0 };
const store = createPersistentStore<TestObject>(testKey, defaultValue);
expect(store.value.name).toBe('Test');
expect(store.value.count).toBe(0);
});
});
describe('Writing Values', () => {
it('should update value when set via setter', () => {
const store = createPersistentStore(testKey, 'default');
store.value = 'new value';
expect(store.value).toBe('new value');
});
it('should serialize objects to JSON', () => {
const store = createPersistentStore(testKey, { name: 'Default', count: 0 });
store.value = { name: 'Updated', count: 42 };
// The value is updated in the store
expect(store.value).toEqual({ name: 'Updated', count: 42 });
});
it('should handle arrays', () => {
const store = createPersistentStore<number[]>(testKey, []);
store.value = [1, 2, 3];
expect(store.value).toEqual([1, 2, 3]);
});
it('should handle booleans', () => {
const store = createPersistentStore<boolean>(testKey, false);
store.value = true;
expect(store.value).toBe(true);
});
it('should handle null values', () => {
const store = createPersistentStore<string | null>(testKey, null);
store.value = 'not null';
expect(store.value).toBe('not null');
});
});
describe('Clear Function', () => {
it('should reset value to default when clear is called', () => {
const store = createPersistentStore(testKey, 'default');
store.value = 'modified';
store.clear();
expect(store.value).toBe('default');
});
it('should work with object defaults', () => {
const defaultValue = { name: 'Default', count: 0 };
const store = createPersistentStore(testKey, defaultValue);
store.value = { name: 'Modified', count: 42 };
store.clear();
expect(store.value).toEqual(defaultValue);
});
it('should work with array defaults', () => {
const defaultValue = [1, 2, 3];
const store = createPersistentStore<number[]>(testKey, defaultValue);
store.value = [4, 5, 6];
store.clear();
expect(store.value).toEqual(defaultValue);
});
});
describe('Type Support', () => {
it('should work with string type', () => {
const store = createPersistentStore<string>(testKey, 'default');
store.value = 'test string';
expect(store.value).toBe('test string');
});
it('should work with number type', () => {
const store = createPersistentStore<number>(testKey, 0);
store.value = 42;
expect(store.value).toBe(42);
});
it('should work with boolean type', () => {
const store = createPersistentStore<boolean>(testKey, false);
store.value = true;
expect(store.value).toBe(true);
});
it('should work with object type', () => {
interface TestObject {
name: string;
value: number;
}
const defaultValue: TestObject = { name: 'Test', value: 0 };
const store = createPersistentStore<TestObject>(testKey, defaultValue);
store.value = { name: 'Updated', value: 42 };
expect(store.value.name).toBe('Updated');
expect(store.value.value).toBe(42);
});
it('should work with array type', () => {
const store = createPersistentStore<string[]>(testKey, []);
store.value = ['a', 'b', 'c'];
expect(store.value).toEqual(['a', 'b', 'c']);
});
it('should work with null type', () => {
const store = createPersistentStore<string | null>(testKey, null);
expect(store.value).toBeNull();
store.value = 'not null';
expect(store.value).toBe('not null');
});
});
describe('Edge Cases', () => {
it('should handle empty string', () => {
const store = createPersistentStore(testKey, 'default');
store.value = '';
expect(store.value).toBe('');
});
it('should handle zero number', () => {
const store = createPersistentStore<number>(testKey, 100);
store.value = 0;
expect(store.value).toBe(0);
});
it('should handle false boolean', () => {
const store = createPersistentStore<boolean>(testKey, true);
store.value = false;
expect(store.value).toBe(false);
});
it('should handle empty array', () => {
const store = createPersistentStore<number[]>(testKey, [1, 2, 3]);
store.value = [];
expect(store.value).toEqual([]);
});
it('should handle empty object', () => {
const store = createPersistentStore<Record<string, unknown>>(testKey, { a: 1 });
store.value = {};
expect(store.value).toEqual({});
});
it('should handle special characters in string', () => {
const store = createPersistentStore(testKey, '');
const specialString = 'Hello "world"\nNew line\tTab';
store.value = specialString;
expect(store.value).toBe(specialString);
});
it('should handle unicode characters', () => {
const store = createPersistentStore(testKey, '');
store.value = 'Hello 世界 🌍';
expect(store.value).toBe('Hello 世界 🌍');
});
});
describe('Multiple Instances', () => {
it('should handle multiple stores with different keys', () => {
const store1 = createPersistentStore('key1', 'value1');
const store2 = createPersistentStore('key2', 'value2');
store1.value = 'updated1';
store2.value = 'updated2';
expect(store1.value).toBe('updated1');
expect(store2.value).toBe('updated2');
});
it('should keep stores independent', () => {
const store1 = createPersistentStore('key1', 'default1');
const store2 = createPersistentStore('key2', 'default2');
store1.clear();
expect(store1.value).toBe('default1');
expect(store2.value).toBe('default2');
});
});
describe('Complex Scenarios', () => {
it('should handle nested objects', () => {
interface NestedObject {
user: {
name: string;
settings: {
theme: string;
notifications: boolean;
};
};
}
const defaultValue: NestedObject = {
user: {
name: 'Test',
settings: { theme: 'light', notifications: true },
},
};
const store = createPersistentStore<NestedObject>(testKey, defaultValue);
store.value = {
user: {
name: 'Updated',
settings: { theme: 'dark', notifications: false },
},
};
expect(store.value).toEqual({
user: {
name: 'Updated',
settings: { theme: 'dark', notifications: false },
},
});
});
it('should handle arrays of objects', () => {
interface Item {
id: number;
name: string;
}
const store = createPersistentStore<Item[]>(testKey, []);
store.value = [
{ id: 1, name: 'First' },
{ id: 2, name: 'Second' },
{ id: 3, name: 'Third' },
];
expect(store.value).toHaveLength(3);
expect(store.value[0].name).toBe('First');
});
});
});

View File

@@ -0,0 +1,130 @@
import { Spring } from 'svelte/motion';
export interface PerspectiveConfig {
/**
* How many px to move back per level
*/
depthStep?: number;
/**
* Scale reduction per level
*/
scaleStep?: number;
/**
* Blur amount per level
*/
blurStep?: number;
/**
* Opacity reduction per level
*/
opacityStep?: number;
/**
* Parallax intensity per level
*/
parallaxIntensity?: number;
/**
* Horizontal offset for each plan (x-axis positioning)
* Positive = right, Negative = left
*/
horizontalOffset?: number;
/**
* Layout mode: 'center' (default) or 'split' for Swiss-style side-by-side
*/
layoutMode?: 'center' | 'split';
}
/**
* Manages perspective state with a simple boolean flag.
*
* Drastically simplified from the complex camera/index system.
* Just manages whether content is in "back" or "front" state.
*
* @example
* ```typescript
* const perspective = createPerspectiveManager({
* depthStep: 100,
* scaleStep: 0.5,
* blurStep: 4,
* });
*
* // Toggle back/front
* perspective.toggle();
*
* // Check state
* const isBack = perspective.isBack; // reactive boolean
* ```
*/
export class PerspectiveManager {
/**
* Spring for smooth back/front transitions
*/
spring = new Spring(0, {
stiffness: 0.2,
damping: 0.8,
});
/**
* Reactive boolean: true when in back position (blurred, scaled down)
*/
isBack = $derived(this.spring.current > 0.5);
/**
* Reactive boolean: true when in front position (fully visible, interactive)
*/
isFront = $derived(this.spring.current < 0.5);
/**
* Configuration values for style computation
*/
private config: Required<PerspectiveConfig>;
constructor(config: PerspectiveConfig = {}) {
this.config = {
depthStep: config.depthStep ?? 100,
scaleStep: config.scaleStep ?? 0.5,
blurStep: config.blurStep ?? 4,
opacityStep: config.opacityStep ?? 0.5,
parallaxIntensity: config.parallaxIntensity ?? 0,
horizontalOffset: config.horizontalOffset ?? 0,
layoutMode: config.layoutMode ?? 'center',
};
}
/**
* Toggle between front (0) and back (1) positions.
* Smooth spring animation handles the transition.
*/
toggle = () => {
const target = this.spring.current < 0.5 ? 1 : 0;
this.spring.target = target;
};
/**
* Force to back position
*/
setBack = () => {
this.spring.target = 1;
};
/**
* Force to front position
*/
setFront = () => {
this.spring.target = 0;
};
/**
* Get configuration for style computation
* @internal
*/
getConfig = () => this.config;
}
/**
* Factory function to create a PerspectiveManager instance.
*
* @param config - Configuration options
* @returns Configured PerspectiveManager instance
*/
export function createPerspectiveManager(config: PerspectiveConfig = {}) {
return new PerspectiveManager(config);
}

View File

@@ -0,0 +1,231 @@
// $shared/lib/createResponsiveManager.svelte.ts
/**
* Breakpoint definitions following common device sizes
* Customize these values to match your design system
*/
export interface Breakpoints {
/** Mobile devices (portrait phones) */
mobile: number;
/** Tablet portrait */
tabletPortrait: number;
/** Tablet landscape */
tablet: number;
/** Desktop */
desktop: number;
/** Large desktop */
desktopLarge: number;
}
/**
* Default breakpoints (matches common Tailwind-like breakpoints)
*/
const DEFAULT_BREAKPOINTS: Breakpoints = {
mobile: 640, // sm
tabletPortrait: 768, // md
tablet: 1024, // lg
desktop: 1280, // xl
desktopLarge: 1536, // 2xl
};
/**
* Orientation type
*/
export type Orientation = 'portrait' | 'landscape';
/**
* Creates a reactive responsive manager that tracks viewport size and breakpoints.
*
* Provides reactive getters for:
* - Current breakpoint detection (isMobile, isTablet, etc.)
* - Viewport dimensions (width, height)
* - Device orientation (portrait/landscape)
* - Custom breakpoint matching
*
* @param customBreakpoints - Optional custom breakpoint values
* @returns Responsive manager instance with reactive properties
*
* @example
* ```svelte
* <script lang="ts">
* const responsive = createResponsiveManager();
* </script>
*
* {#if responsive.isMobile}
* <MobileNav />
* {:else if responsive.isTablet}
* <TabletNav />
* {:else}
* <DesktopNav />
* {/if}
*
* <p>Width: {responsive.width}px</p>
* <p>Orientation: {responsive.orientation}</p>
* ```
*/
export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) {
const breakpoints: Breakpoints = {
...DEFAULT_BREAKPOINTS,
...customBreakpoints,
};
// Reactive state
let width = $state(typeof window !== 'undefined' ? window.innerWidth : 0);
let height = $state(typeof window !== 'undefined' ? window.innerHeight : 0);
// Derived breakpoint states
const isMobile = $derived(width < breakpoints.mobile);
const isTabletPortrait = $derived(
width >= breakpoints.mobile && width < breakpoints.tabletPortrait,
);
const isTablet = $derived(
width >= breakpoints.tabletPortrait && width < breakpoints.desktop,
);
const isDesktop = $derived(
width >= breakpoints.desktop && width < breakpoints.desktopLarge,
);
const isDesktopLarge = $derived(width >= breakpoints.desktopLarge);
// Convenience groupings
const isMobileOrTablet = $derived(width < breakpoints.desktop);
const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait);
// Orientation
const orientation = $derived<Orientation>(height > width ? 'portrait' : 'landscape');
const isPortrait = $derived(orientation === 'portrait');
const isLandscape = $derived(orientation === 'landscape');
// Touch device detection (best effort)
const isTouchDevice = $derived(
typeof window !== 'undefined'
&& ('ontouchstart' in window || navigator.maxTouchPoints > 0),
);
/**
* Initialize responsive tracking
* Call this in an $effect or component mount
*/
function init() {
if (typeof window === 'undefined') return;
const handleResize = () => {
width = window.innerWidth;
height = window.innerHeight;
};
// Use ResizeObserver for more accurate tracking
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(document.documentElement);
// Fallback to window resize event
window.addEventListener('resize', handleResize, { passive: true });
// Initial measurement
handleResize();
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', handleResize);
};
}
/**
* Check if current width matches a custom breakpoint
* @param min - Minimum width (inclusive)
* @param max - Maximum width (exclusive)
*/
function matches(min: number, max?: number): boolean {
if (max !== undefined) {
return width >= min && width < max;
}
return width >= min;
}
/**
* Get the current breakpoint name
*/
const currentBreakpoint = $derived<keyof Breakpoints | 'xs'>(
(() => {
if (isMobile) return 'mobile';
if (isTabletPortrait) return 'tabletPortrait';
if (isTablet) return 'tablet';
if (isDesktop) return 'desktop';
if (isDesktopLarge) return 'desktopLarge';
return 'xs'; // Fallback for very small screens
})(),
);
return {
// Dimensions
get width() {
return width;
},
get height() {
return height;
},
// Standard breakpoints
get isMobile() {
return isMobile;
},
get isTabletPortrait() {
return isTabletPortrait;
},
get isTablet() {
return isTablet;
},
get isDesktop() {
return isDesktop;
},
get isDesktopLarge() {
return isDesktopLarge;
},
// Convenience groupings
get isMobileOrTablet() {
return isMobileOrTablet;
},
get isTabletOrDesktop() {
return isTabletOrDesktop;
},
// Orientation
get orientation() {
return orientation;
},
get isPortrait() {
return isPortrait;
},
get isLandscape() {
return isLandscape;
},
// Device capabilities
get isTouchDevice() {
return isTouchDevice;
},
// Current breakpoint
get currentBreakpoint() {
return currentBreakpoint;
},
// Methods
init,
matches,
// Breakpoint values (for custom logic)
breakpoints,
};
}
export const responsiveManager = createResponsiveManager();
if (typeof window !== 'undefined') {
responsiveManager.init();
}
/**
* Type for the responsive manager instance
*/
export type ResponsiveManager = ReturnType<typeof createResponsiveManager>;

View File

@@ -22,23 +22,23 @@ export interface ControlDataModel {
step: number; step: number;
} }
export interface ControlModel extends ControlDataModel { export interface ControlModel<T extends string = string> extends ControlDataModel {
/** /**
* Control identifier * Control identifier
*/ */
id: string; id: T;
/** /**
* Area label for increase button * Area label for increase button
*/ */
increaseLabel: string; increaseLabel?: string;
/** /**
* Area label for decrease button * Area label for decrease button
*/ */
decreaseLabel: string; decreaseLabel?: string;
/** /**
* Control area label * Control area label
*/ */
controlLabel: string; controlLabel?: string;
} }
export function createTypographyControl<T extends ControlDataModel>( export function createTypographyControl<T extends ControlDataModel>(
@@ -59,10 +59,10 @@ export function createTypographyControl<T extends ControlDataModel>(
return value; return value;
}, },
set value(newValue) { set value(newValue) {
value = roundToStepPrecision( const rounded = roundToStepPrecision(clampNumber(newValue, min, max), step);
clampNumber(newValue, min, max), if (value !== rounded) {
step, value = rounded;
); }
}, },
get max() { get max() {
return max; return max;

View File

@@ -3,17 +3,40 @@
* *
* Used to render visible items with absolute positioning based on computed offsets. * Used to render visible items with absolute positioning based on computed offsets.
*/ */
export interface VirtualItem { export interface VirtualItem {
/** Index of the item in the data array */ /**
* Index of the item in the data array
*/
index: number; index: number;
/** Offset from the top of the list in pixels */ /**
* Offset from the top of the list in pixels
*/
start: number; start: number;
/** Height/size of the item in pixels */ /**
* Height/size of the item in pixels
*/
size: number; size: number;
/** End position in pixels (start + size) */ /**
* End position in pixels (start + size)
*/
end: number; end: number;
/** Unique key for the item (for Svelte's {#each} keying) */ /**
* Unique key for the item (for Svelte's {#each} keying)
*/
key: string | number; key: string | number;
/**
* Whether the item is currently fully visible in the viewport
*/
isFullyVisible: boolean;
/**
* Whether the item is currently partially visible in the viewport
*/
isPartiallyVisible: boolean;
/**
* Proximity of the item to the center of the viewport
*/
proximity: number;
} }
/** /**
@@ -41,6 +64,11 @@ export interface VirtualizerOptions {
* Can be useful for handling sticky headers or other UI elements. * Can be useful for handling sticky headers or other UI elements.
*/ */
scrollMargin?: number; scrollMargin?: number;
/**
* Whether to use the window as the scroll container.
* @default false
*/
useWindowScroll?: boolean;
} }
/** /**
@@ -88,13 +116,16 @@ export function createVirtualizer<T>(
let containerHeight = $state(0); let containerHeight = $state(0);
let measuredSizes = $state<Record<number, number>>({}); let measuredSizes = $state<Record<number, number>>({});
let elementRef: HTMLElement | null = null; let elementRef: HTMLElement | null = null;
let elementOffsetTop = 0;
// By wrapping the getter in $derived, we track everything inside it // By wrapping the getter in $derived, we track everything inside it
const options = $derived(optionsGetter()); const options = $derived(optionsGetter());
// This derivation now tracks: count, measuredSizes, AND the data array itself // This derivation now tracks: count, _version (for measuredSizes updates), AND the data array itself
const offsets = $derived.by(() => { const offsets = $derived.by(() => {
const count = options.count; const count = options.count;
// Implicit dependency on version signal
const v = _version;
const result = new Float64Array(count); const result = new Float64Array(count);
let accumulated = 0; let accumulated = 0;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@@ -102,6 +133,7 @@ export function createVirtualizer<T>(
// Accessing measuredSizes here creates the subscription // Accessing measuredSizes here creates the subscription
accumulated += measuredSizes[i] ?? options.estimateSize(i); accumulated += measuredSizes[i] ?? options.estimateSize(i);
} }
return result; return result;
}); });
@@ -116,6 +148,8 @@ export function createVirtualizer<T>(
// We MUST read options.data here so Svelte knows to re-run // We MUST read options.data here so Svelte knows to re-run
// this derivation when the items array is replaced! // this derivation when the items array is replaced!
const { count, data } = options; const { count, data } = options;
// Implicit dependency
const v = _version;
if (count === 0 || containerHeight === 0 || !data) return []; if (count === 0 || containerHeight === 0 || !data) return [];
const overscan = options.overscan ?? 5; const overscan = options.overscan ?? 5;
@@ -136,6 +170,8 @@ export function createVirtualizer<T>(
let endIdx = startIdx; let endIdx = startIdx;
const viewportEnd = scrollOffset + containerHeight; const viewportEnd = scrollOffset + containerHeight;
const viewportCenter = scrollOffset + (containerHeight / 2);
while (endIdx < count && offsets[endIdx] < viewportEnd) { while (endIdx < count && offsets[endIdx] < viewportEnd) {
endIdx++; endIdx++;
} }
@@ -144,13 +180,34 @@ export function createVirtualizer<T>(
const end = Math.min(count, endIdx + overscan); const end = Math.min(count, endIdx + overscan);
const result: VirtualItem[] = []; const result: VirtualItem[] = [];
for (let i = start; i < end; i++) { for (let i = start; i < end; i++) {
const itemStart = offsets[i];
const itemSize = measuredSizes[i] ?? options.estimateSize(i);
const itemEnd = itemStart + itemSize;
// Visibility check: Does the item overlap the viewport?
const isPartiallyVisible = itemStart < viewportEnd && itemEnd > scrollOffset;
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
// Proximity calculation: 1.0 at center, 0.0 at edges
// Guard against division by zero (containerHeight can be 0 on initial render)
const itemCenter = itemStart + (itemSize / 2);
const distanceToCenter = Math.abs(viewportCenter - itemCenter);
const maxDistance = containerHeight / 2;
const proximity = maxDistance > 0
? Math.max(0, 1 - (distanceToCenter / maxDistance))
: 0;
result.push({ result.push({
index: i, index: i,
start: offsets[i], start: itemStart,
size: measuredSizes[i] ?? options.estimateSize(i), size: itemSize,
end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)), end: itemEnd,
key: options.getItemKey?.(i) ?? i, key: options.getItemKey?.(i) ?? i,
isPartiallyVisible,
isFullyVisible,
proximity,
}); });
} }
@@ -168,66 +225,163 @@ export function createVirtualizer<T>(
*/ */
function container(node: HTMLElement) { function container(node: HTMLElement) {
elementRef = node; elementRef = node;
containerHeight = node.offsetHeight; const { useWindowScroll } = optionsGetter();
const handleScroll = () => { if (useWindowScroll) {
scrollOffset = node.scrollTop; // Calculate initial offset ONCE
}; const getElementOffset = () => {
const rect = node.getBoundingClientRect();
return rect.top + window.scrollY;
};
const resizeObserver = new ResizeObserver(([entry]) => { let cachedOffsetTop = 0;
if (entry) containerHeight = entry.contentRect.height; let rafId: number | null = null;
}); containerHeight = window.innerHeight;
node.addEventListener('scroll', handleScroll, { passive: true }); const handleScroll = () => {
resizeObserver.observe(node); if (rafId !== null) return;
return { rafId = requestAnimationFrame(() => {
destroy() { // Get current position of element relative to viewport
node.removeEventListener('scroll', handleScroll); const rect = node.getBoundingClientRect();
resizeObserver.disconnect(); // Calculate how much of the element has scrolled past the top of viewport
elementRef = null; // When element.top is 0, element is at top of viewport
}, // When element.top is -100, element has scrolled up 100px past viewport top
}; const scrolledPastTop = Math.max(0, -rect.top);
scrollOffset = scrolledPastTop;
rafId = null;
});
};
const handleResize = () => {
containerHeight = window.innerHeight;
elementOffsetTop = getElementOffset();
cachedOffsetTop = elementOffsetTop;
handleScroll();
};
// Initial setup
requestAnimationFrame(() => {
elementOffsetTop = getElementOffset();
cachedOffsetTop = elementOffsetTop;
handleScroll();
});
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleResize);
return {
destroy() {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
if (frameId !== null) {
cancelAnimationFrame(frameId);
frameId = null;
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
// Disconnect shared ResizeObserver
if (sharedResizeObserver) {
sharedResizeObserver.disconnect();
sharedResizeObserver = null;
}
elementRef = null;
},
};
} else {
containerHeight = node.offsetHeight;
const handleScroll = () => {
scrollOffset = node.scrollTop;
};
const resizeObserver = new ResizeObserver(([entry]) => {
if (entry) containerHeight = entry.contentRect.height;
});
node.addEventListener('scroll', handleScroll, { passive: true });
resizeObserver.observe(node);
return {
destroy() {
node.removeEventListener('scroll', handleScroll);
resizeObserver.disconnect();
// Disconnect shared ResizeObserver
if (sharedResizeObserver) {
sharedResizeObserver.disconnect();
sharedResizeObserver = null;
}
elementRef = null;
},
};
}
} }
let measurementBuffer: Record<number, number> = {}; let measurementBuffer: Record<number, number> = {};
let frameId: number | null = null; let frameId: number | null = null;
// Signal to trigger updates when mutating measuredSizes in place
let _version = $state(0);
// Single shared ResizeObserver for all items (performance optimization)
let sharedResizeObserver: ResizeObserver | null = null;
/** /**
* Svelte action to measure individual item elements for dynamic height support. * Svelte action to measure individual item elements for dynamic height support.
* *
* Attaches a ResizeObserver to track actual element height and updates * Uses a single shared ResizeObserver for all items to track actual element heights.
* measured sizes when dimensions change. Requires `data-index` attribute on the element. * Requires `data-index` attribute on the element.
* *
* @param node - The DOM element to measure (should have `data-index` attribute) * @param node - The DOM element to measure (should have `data-index` attribute)
* @returns Object with destroy method for cleanup * @returns Object with destroy method for cleanup
*/ */
function measureElement(node: HTMLElement) { function measureElement(node: HTMLElement) {
const resizeObserver = new ResizeObserver(([entry]) => { // Initialize shared observer on first use
if (!entry) return; if (!sharedResizeObserver) {
const index = parseInt(node.dataset.index || '', 10); sharedResizeObserver = new ResizeObserver(entries => {
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; // Process all entries in a single batch
for (const entry of entries) {
const target = entry.target as HTMLElement;
const index = parseInt(target.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? target.offsetHeight;
if (!isNaN(index) && measuredSizes[index] !== height) { if (!isNaN(index)) {
// 1. Stuff the measurement into a temporary buffer const oldHeight = measuredSizes[index];
measurementBuffer[index] = height;
// 2. Schedule a single update for the next animation frame // Only update if the height difference is significant (> 0.5px)
if (frameId === null) { if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
measurementBuffer[index] = height;
}
}
}
// Schedule a single update for the next animation frame
if (frameId === null && Object.keys(measurementBuffer).length > 0) {
frameId = requestAnimationFrame(() => { frameId = requestAnimationFrame(() => {
// 3. Update the state once for all collected measurements // Mutation in place for performance
// We use spread to trigger a single fine-grained update Object.assign(measuredSizes, measurementBuffer);
measuredSizes = { ...measuredSizes, ...measurementBuffer };
// 4. Reset the buffer // Trigger reactivity
_version += 1;
// Reset buffer
measurementBuffer = {}; measurementBuffer = {};
frameId = null; frameId = null;
}); });
} }
} });
}); }
resizeObserver.observe(node); // Observe this element with the shared observer
return { destroy: () => resizeObserver.disconnect() }; sharedResizeObserver.observe(node);
// Return cleanup that only unobserves this specific element
return {
destroy: () => {
sharedResizeObserver?.unobserve(node);
},
};
} }
// Programmatic Scroll // Programmatic Scroll
@@ -249,14 +403,53 @@ export function createVirtualizer<T>(
const itemStart = offsets[index]; const itemStart = offsets[index];
const itemSize = measuredSizes[index] ?? options.estimateSize(index); const itemSize = measuredSizes[index] ?? options.estimateSize(index);
let target = itemStart; let target = itemStart;
const { useWindowScroll } = optionsGetter();
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2; if (useWindowScroll) {
if (align === 'end') target = itemStart - containerHeight + itemSize; if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2;
if (align === 'end') target = itemStart - window.innerHeight + itemSize;
elementRef.scrollTo({ top: target, behavior: 'smooth' }); // Add container offset to target to get absolute document position
const absoluteTarget = target + elementOffsetTop;
window.scrollTo({ top: absoluteTarget, behavior: 'smooth' });
} else {
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
if (align === 'end') target = itemStart - containerHeight + itemSize;
elementRef.scrollTo({ top: target, behavior: 'smooth' });
}
}
/**
* Scrolls the container to a specific pixel offset.
* Used for preserving scroll position during data updates.
*
* @param offset - The scroll offset in pixels
* @param behavior - Scroll behavior: 'auto' for instant, 'smooth' for animated
*
* @example
* ```ts
* virtualizer.scrollToOffset(1000, 'auto'); // Instant scroll to 1000px
* ```
*/
function scrollToOffset(offset: number, behavior: ScrollBehavior = 'auto') {
const { useWindowScroll } = optionsGetter();
if (useWindowScroll) {
window.scrollTo({ top: offset + elementOffsetTop, behavior });
} else if (elementRef) {
elementRef.scrollTo({ top: offset, behavior });
}
} }
return { return {
get scrollOffset() {
return scrollOffset;
},
get containerHeight() {
return containerHeight;
},
/** Computed array of visible items to render (reactive) */ /** Computed array of visible items to render (reactive) */
get items() { get items() {
return items; return items;
@@ -271,6 +464,8 @@ export function createVirtualizer<T>(
measureElement, measureElement,
/** Programmatic scroll method to scroll to a specific item */ /** Programmatic scroll method to scroll to a specific item */
scrollToIndex, scrollToIndex,
/** Programmatic scroll method to scroll to a specific pixel offset */
scrollToOffset,
}; };
} }

View File

@@ -0,0 +1,550 @@
/** @vitest-environment jsdom */
import {
afterEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { createVirtualizer } from './createVirtualizer.svelte';
/**
* NOTE: Svelte 5 Runes Testing Limitations
*
* The createVirtualizer helper uses Svelte 5 runes ($state, $derived, $derived.by)
* which require a full Svelte runtime environment to work correctly. In unit tests
* with jsdom, these runes are stubbed and don't provide actual reactivity.
*
* These tests focus on:
* 1. API surface verification (methods, getters exist)
* 2. Initial state calculation
* 3. DOM integration (event listeners are attached)
* 4. Edge case handling
*
* For full reactivity testing, use browser-based tests with @vitest/browser-playwright
*/
// Mock ResizeObserver globally since it's not available in jsdom
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
globalThis.ResizeObserver = MockResizeObserver as any;
// Mock requestAnimationFrame
globalThis.requestAnimationFrame =
((cb: FrameRequestCallback) =>
setTimeout(() => cb(performance.now()), 16) as unknown) as typeof requestAnimationFrame;
globalThis.cancelAnimationFrame = vi.fn();
/**
* Helper to create test data array
*/
function createTestData(count: number): string[] {
return Array.from({ length: count }, (_, i) => `Item ${i}`);
}
/**
* Helper to create a mock scrollable container element
*/
function createMockContainer(height = 500, scrollTop = 0): any {
const container = document.createElement('div');
Object.defineProperty(container, 'offsetHeight', {
value: height,
configurable: true,
writable: true,
});
Object.defineProperty(container, 'scrollTop', {
value: scrollTop,
writable: true,
configurable: true,
});
// Add scrollTo method for testing
container.scrollTo = vi.fn();
return container;
}
describe('createVirtualizer - Basic API and State', () => {
describe('Basic Initialization and API Surface', () => {
it('should initialize and return expected API surface', () => {
const virtualizer = createVirtualizer(() => ({
count: 0,
data: [],
estimateSize: () => 50,
}));
// Verify API surface exists
expect(virtualizer).toHaveProperty('items');
expect(virtualizer).toHaveProperty('totalSize');
expect(virtualizer).toHaveProperty('scrollOffset');
expect(virtualizer).toHaveProperty('containerHeight');
expect(virtualizer).toHaveProperty('container');
expect(virtualizer).toHaveProperty('measureElement');
expect(virtualizer).toHaveProperty('scrollToIndex');
expect(virtualizer).toHaveProperty('scrollToOffset');
// Verify initial values
expect(virtualizer.items).toEqual([]);
expect(virtualizer.totalSize).toBe(0);
expect(virtualizer.scrollOffset).toBe(0);
expect(virtualizer.containerHeight).toBe(0);
});
it('should calculate correct totalSize for uniform item sizes', () => {
const virtualizer = createVirtualizer(() => ({
count: 10,
data: createTestData(10),
estimateSize: () => 50,
}));
// 10 items * 50px each = 500px total
expect(virtualizer.totalSize).toBe(500);
});
it('should calculate correct totalSize for varying item sizes', () => {
const sizes = [50, 100, 150, 75, 125]; // Sum = 500
const virtualizer = createVirtualizer(() => ({
count: 5,
data: createTestData(5),
estimateSize: (i: number) => sizes[i],
}));
expect(virtualizer.totalSize).toBe(500);
});
it('should handle empty list (count = 0)', () => {
const virtualizer = createVirtualizer(() => ({
count: 0,
data: [],
estimateSize: () => 50,
}));
expect(virtualizer.totalSize).toBe(0);
expect(virtualizer.items).toEqual([]);
});
it('should handle very large lists', () => {
const virtualizer = createVirtualizer(() => ({
count: 100000,
data: createTestData(100000),
estimateSize: () => 50,
}));
expect(virtualizer.totalSize).toBe(5000000); // 100000 * 50
});
it('should handle zero estimated size', () => {
const virtualizer = createVirtualizer(() => ({
count: 10,
data: createTestData(10),
estimateSize: () => 0,
}));
expect(virtualizer.totalSize).toBe(0);
});
});
describe('Container Action', () => {
let cleanupHandlers: (() => void)[] = [];
afterEach(() => {
cleanupHandlers.forEach(cleanup => cleanup());
cleanupHandlers = [];
});
it('should attach container action and set up listeners', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
}));
const container = createMockContainer(500, 0);
const addEventListenerSpy = vi.spyOn(container, 'addEventListener');
const cleanup = virtualizer.container(container);
cleanupHandlers.push(() => cleanup?.destroy?.());
// Verify scroll listener was attached
expect(addEventListenerSpy).toHaveBeenCalledWith(
'scroll',
expect.any(Function),
{ passive: true },
);
});
it('should update containerHeight when container is attached', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
}));
const container = createMockContainer(500, 0);
const cleanup = virtualizer.container(container);
cleanupHandlers.push(() => cleanup?.destroy?.());
expect(virtualizer.containerHeight).toBe(500);
});
it('should clean up listeners on destroy', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
}));
const container = createMockContainer(500, 0);
const removeEventListenerSpy = vi.spyOn(container, 'removeEventListener');
const cleanup = virtualizer.container(container);
cleanup?.destroy?.();
expect(removeEventListenerSpy).toHaveBeenCalled();
});
it('should support window scrolling mode', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
useWindowScroll: true,
}));
const container = createMockContainer(500, 0);
const windowAddSpy = vi.spyOn(window, 'addEventListener');
const cleanup = virtualizer.container(container);
cleanupHandlers.push(() => cleanup?.destroy?.());
// Should attach to window scroll
expect(windowAddSpy).toHaveBeenCalledWith('scroll', expect.any(Function), expect.any(Object));
expect(windowAddSpy).toHaveBeenCalledWith('resize', expect.any(Function));
windowAddSpy.mockRestore();
});
});
describe('scrollToIndex Method', () => {
let cleanupHandlers: (() => void)[] = [];
afterEach(() => {
cleanupHandlers.forEach(cleanup => cleanup());
cleanupHandlers = [];
});
it('should have scrollToIndex method that does not throw without container', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
}));
// Should not throw when container is not attached
expect(() => virtualizer.scrollToIndex(50)).not.toThrow();
});
it('should scroll to specific index with container attached', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
}));
const container = createMockContainer(500, 0);
const scrollToSpy = vi.spyOn(container, 'scrollTo');
const cleanup = virtualizer.container(container);
cleanupHandlers.push(() => cleanup?.destroy?.());
virtualizer.scrollToIndex(10);
expect(scrollToSpy).toHaveBeenCalledWith({
top: expect.any(Number),
behavior: 'smooth',
});
});
it('should handle center alignment', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
}));
const container = createMockContainer(500, 0);
const scrollToSpy = vi.spyOn(container, 'scrollTo');
const cleanup = virtualizer.container(container);
cleanupHandlers.push(() => cleanup?.destroy?.());
virtualizer.scrollToIndex(10, 'center');
expect(scrollToSpy).toHaveBeenCalled();
});
it('should handle end alignment', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
}));
const container = createMockContainer(500, 0);
const scrollToSpy = vi.spyOn(container, 'scrollTo');
const cleanup = virtualizer.container(container);
cleanupHandlers.push(() => cleanup?.destroy?.());
virtualizer.scrollToIndex(10, 'end');
expect(scrollToSpy).toHaveBeenCalled();
});
it('should not scroll for out of bounds indices', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
}));
const container = createMockContainer(500, 0);
const scrollToSpy = vi.spyOn(container, 'scrollTo');
const cleanup = virtualizer.container(container);
cleanupHandlers.push(() => cleanup?.destroy?.());
// Negative index
virtualizer.scrollToIndex(-1);
// Index >= count
virtualizer.scrollToIndex(100);
// Should not have been called
expect(scrollToSpy).not.toHaveBeenCalled();
});
});
describe('scrollToOffset Method', () => {
let cleanupHandlers: (() => void)[] = [];
afterEach(() => {
cleanupHandlers.forEach(cleanup => cleanup());
cleanupHandlers = [];
});
it('should scroll to specific pixel offset', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
}));
const container = createMockContainer(500, 0);
const scrollToSpy = vi.spyOn(container, 'scrollTo');
const cleanup = virtualizer.container(container);
cleanupHandlers.push(() => cleanup?.destroy?.());
virtualizer.scrollToOffset(1000);
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: 'auto' });
});
it('should support smooth behavior', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
}));
const container = createMockContainer(500, 0);
const scrollToSpy = vi.spyOn(container, 'scrollTo');
const cleanup = virtualizer.container(container);
cleanupHandlers.push(() => cleanup?.destroy?.());
virtualizer.scrollToOffset(1000, 'smooth');
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' });
});
});
describe('measureElement Action', () => {
it('should attach measureElement action to DOM element', () => {
const virtualizer = createVirtualizer(() => ({
count: 10,
data: createTestData(10),
estimateSize: () => 50,
}));
const element = document.createElement('div');
element.dataset.index = '0';
// Should not throw when attaching measureElement
expect(() => {
const cleanup = virtualizer.measureElement(element);
cleanup?.destroy?.();
}).not.toThrow();
});
it('should clean up observer on destroy', () => {
const virtualizer = createVirtualizer(() => ({
count: 10,
data: createTestData(10),
estimateSize: () => 50,
}));
const element = document.createElement('div');
element.dataset.index = '0';
const cleanup = virtualizer.measureElement(element);
// Should not throw when destroying
expect(() => cleanup?.destroy?.()).not.toThrow();
});
it('should handle multiple elements being measured', () => {
const virtualizer = createVirtualizer(() => ({
count: 10,
data: createTestData(10),
estimateSize: () => 50,
}));
const elements = Array.from({ length: 5 }, (_, i) => {
const el = document.createElement('div');
el.dataset.index = String(i);
return el;
});
const cleanups = elements.map(el => virtualizer.measureElement(el));
// Should not throw when measuring multiple elements
expect(() => {
cleanups.forEach(cleanup => cleanup?.destroy?.());
}).not.toThrow();
});
});
describe('Options Handling', () => {
it('should use default overscan of 5', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
}));
// Options with default overscan should work
expect(virtualizer).toHaveProperty('items');
});
it('should use custom overscan value', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
overscan: 10,
}));
expect(virtualizer).toHaveProperty('items');
});
it('should use index as default key when getItemKey is not provided', () => {
const virtualizer = createVirtualizer(() => ({
count: 10,
data: createTestData(10),
estimateSize: () => 50,
}));
expect(virtualizer).toHaveProperty('items');
});
it('should use custom getItemKey when provided', () => {
const virtualizer = createVirtualizer(() => ({
count: 10,
data: createTestData(10),
estimateSize: () => 50,
getItemKey: (i: number) => `custom-key-${i}`,
}));
expect(virtualizer).toHaveProperty('items');
});
it('should use custom scrollMargin when provided', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
scrollMargin: 100,
}));
expect(virtualizer).toHaveProperty('items');
});
});
describe('Edge Cases', () => {
it('should handle single item list', () => {
const virtualizer = createVirtualizer(() => ({
count: 1,
data: ['Item 0'],
estimateSize: () => 100,
}));
expect(virtualizer.totalSize).toBe(100);
});
it('should handle items larger than viewport', () => {
const virtualizer = createVirtualizer(() => ({
count: 5,
data: createTestData(5),
estimateSize: () => 200, // Each item is 200px
}));
// Total size should still be calculated correctly
expect(virtualizer.totalSize).toBe(1000); // 5 * 200
});
it('should handle overscan larger than viewport', () => {
const virtualizer = createVirtualizer(() => ({
count: 10,
data: createTestData(10),
estimateSize: () => 50,
overscan: 100, // Very large overscan
}));
expect(virtualizer).toHaveProperty('items');
});
it('should handle negative estimated size (graceful degradation)', () => {
const virtualizer = createVirtualizer(() => ({
count: 10,
data: createTestData(10),
estimateSize: () => -10,
}));
// Should calculate total size (may be negative, but shouldn't crash)
expect(virtualizer.totalSize).toBeLessThanOrEqual(0);
});
});
describe('Virtual Item Structure', () => {
it('should return items with correct structure when container is attached', () => {
const virtualizer = createVirtualizer(() => ({
count: 100,
data: createTestData(100),
estimateSize: () => 50,
}));
const container = createMockContainer(500, 0);
const cleanup = virtualizer.container(container);
// Items may be empty in test environment due to reactivity limitations
// but we verify the structure exists
expect(Array.isArray(virtualizer.items)).toBe(true);
cleanup?.destroy?.();
});
});
});

View File

@@ -26,3 +26,25 @@ export {
type Entity, type Entity,
type EntityStore, type EntityStore,
} from './createEntityStore/createEntityStore.svelte'; } from './createEntityStore/createEntityStore.svelte';
export {
type CharacterComparison,
createCharacterComparison,
type LineData,
} from './createCharacterComparison/createCharacterComparison.svelte';
export {
createPersistentStore,
type PersistentStore,
} from './createPersistentStore/createPersistentStore.svelte';
export {
createResponsiveManager,
type ResponsiveManager,
responsiveManager,
} from './createResponsiveManager/createResponsiveManager.svelte';
export {
createPerspectiveManager,
type PerspectiveManager,
} from './createPerspectiveManager/createPerspectiveManager.svelte';

View File

@@ -1,21 +1,43 @@
export { export {
type CharacterComparison,
type ControlDataModel, type ControlDataModel,
type ControlModel, type ControlModel,
createCharacterComparison,
createDebouncedState, createDebouncedState,
createEntityStore, createEntityStore,
createFilter, createFilter,
createPersistentStore,
createPerspectiveManager,
createResponsiveManager,
createTypographyControl, createTypographyControl,
createVirtualizer, createVirtualizer,
type Entity, type Entity,
type EntityStore, type EntityStore,
type Filter, type Filter,
type FilterModel, type FilterModel,
type LineData,
type PersistentStore,
type PerspectiveManager,
type Property, type Property,
type ResponsiveManager,
responsiveManager,
type TypographyControl, type TypographyControl,
type VirtualItem, type VirtualItem,
type Virtualizer, type Virtualizer,
type VirtualizerOptions, type VirtualizerOptions,
} from './helpers'; } from './helpers';
export { motion } from './accessibility/motion.svelte'; export {
export { splitArray } from './utils'; buildQueryString,
clampNumber,
debounce,
getDecimalPlaces,
roundToStepPrecision,
smoothScroll,
splitArray,
throttle,
} from './utils';
export { springySlideFade } from './transitions';
export { ResponsiveProvider } from './providers';

View File

@@ -0,0 +1,30 @@
<!--
Component: ResponsiveProvider
Provides a responsive manager to all children
-->
<script lang="ts">
import {
type ResponsiveManager,
createResponsiveManager,
} from '$shared/lib/helpers';
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
}
let { children }: Props = $props();
const responsive = createResponsiveManager();
// Initialize and cleanup
$effect(() => {
return responsive.init();
});
// Provide to all children
setContext('responsive', responsive);
</script>
{@render children()}

View File

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

View File

@@ -0,0 +1,41 @@
<!--
Component: MockIcon
Wrapper component for Lucide icons to properly handle className in Storybook.
Lucide Svelte icons from @lucide/svelte/icons/* don't properly handle
the className prop directly. This wrapper ensures the class is applied
correctly via the HTML element's class attribute.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type {
Component,
Snippet,
} from 'svelte';
interface Props {
/**
* The Lucide icon component
*/
icon: Component;
/**
* CSS classes to apply to the icon
*/
class?: string;
/**
* Additional icon-specific attributes
*/
attrs?: Record<string, unknown>;
}
let { icon: Icon, class: className, attrs = {} }: Props = $props();
</script>
{#if Icon}
{@const __iconClass__ = cn('size-4', className)}
<!-- Render icon component dynamically with class prop -->
<Icon
class={__iconClass__}
{...attrs}
/>
{/if}

View File

@@ -0,0 +1,64 @@
<!--
Component: Providers
Storybook wrapper that provides required contexts for components.
Provides:
- responsive: ResponsiveManager context for breakpoint tracking
- tooltip: Tooltip.Provider context for shadcn Tooltip components
- Additional Radix UI providers can be added here as needed
-->
<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';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
/**
* Initial viewport width for the responsive context (default: 1280)
*/
initialWidth?: number;
/**
* Initial viewport height for the responsive context (default: 720)
*/
initialHeight?: number;
/**
* Tooltip provider options
*/
tooltipDelayDuration?: number;
/**
* Tooltip skip delay duration
*/
tooltipSkipDelayDuration?: number;
}
let {
children,
initialWidth = 1280,
initialHeight = 720,
tooltipDelayDuration = 200,
tooltipSkipDelayDuration = 300,
}: Props = $props();
// Create a responsive manager with default breakpoints
const responsiveManager = createResponsiveManager();
// Initialize the responsive manager to set up resize listeners
$effect(() => {
return responsiveManager.init();
});
// Provide the responsive context
setContext<ResponsiveManager>('responsive', responsiveManager);
</script>
<div class="storybook-providers" style:width="100%" style:height="100%">
<TooltipProvider
delayDuration={tooltipDelayDuration}
skipDelayDuration={tooltipSkipDelayDuration}
>
{@render children()}
</TooltipProvider>
</div>

View File

@@ -0,0 +1,24 @@
/**
* ============================================================================
* STORYBOOK HELPERS
* ============================================================================
*
* Helper components and utilities for Storybook stories.
*
* ## Usage
*
* ```svelte
* <script lang="ts">
* import { Providers, MockIcon } from '$shared/lib/storybook';
* </script>
*
* <Providers>
* <YourComponent />
* </Providers>
* ```
*
* @module
*/
export { default as MockIcon } from './MockIcon.svelte';
export { default as Providers } from './Providers.svelte';

View File

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

View File

@@ -0,0 +1,60 @@
import type {
SlideParams,
TransitionConfig,
} from 'svelte/transition';
function elasticOut(t: number) {
return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1;
}
function gentleSpring(t: number) {
return 1 - Math.pow(1 - t, 3) * Math.cos(t * Math.PI * 2);
}
/**
* Svelte slide transition function for custom slide+fade
* @param node - The element to apply the transition to
* @param params - Transition parameters
* @returns Transition configuration
*/
export function springySlideFade(
node: HTMLElement,
params: SlideParams = {},
): TransitionConfig {
const { duration = 400 } = params;
const height = node.scrollHeight;
// Check if the browser is Firefox to work around specific rendering issues
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');
return {
duration,
// We use 'tick' for the most precise control over the
// coordination with the elements below.
css: t => {
// Use elastic easing
const eased = gentleSpring(t);
return `
height: ${eased * height}px;
opacity: ${t};
transform: translateY(${(1 - t) * -10}px);
transform-origin: top;
overflow: hidden;
contain: size layout style;
will-change: max-height, opacity, transform;
backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
${
isFirefox
? `
perspective: 1000px;
isolation: isolate;
`
: ''
}
`;
},
};
}

View File

@@ -11,4 +11,6 @@ export { clampNumber } from './clampNumber/clampNumber';
export { debounce } from './debounce/debounce'; export { debounce } from './debounce/debounce';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces'; export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision'; export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
export { smoothScroll } from './smoothScroll/smoothScroll';
export { splitArray } from './splitArray/splitArray'; export { splitArray } from './splitArray/splitArray';
export { throttle } from './throttle/throttle';

View File

@@ -0,0 +1,368 @@
/** @vitest-environment jsdom */
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { smoothScroll } from './smoothScroll';
describe('smoothScroll', () => {
let mockAnchor: HTMLAnchorElement;
let mockTarget: HTMLElement;
let mockScrollIntoView: ReturnType<typeof vi.fn>;
let mockPushState: ReturnType<typeof vi.fn>;
beforeEach(() => {
// Mock scrollIntoView
mockScrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = mockScrollIntoView as (arg?: boolean | ScrollIntoViewOptions) => void;
// Mock history.pushState
mockPushState = vi.fn();
vi.stubGlobal('history', {
pushState: mockPushState,
});
// Create mock elements
mockAnchor = document.createElement('a');
mockAnchor.setAttribute('href', '#section-1');
mockTarget = document.createElement('div');
mockTarget.id = 'section-1';
document.body.appendChild(mockTarget);
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
document.body.innerHTML = '';
});
describe('Basic Functionality', () => {
it('should be a function that returns an object with destroy method', () => {
const action = smoothScroll(mockAnchor);
expect(typeof action).toBe('object');
expect(typeof action.destroy).toBe('function');
});
it('should add click event listener to the anchor element', () => {
const addEventListenerSpy = vi.spyOn(mockAnchor, 'addEventListener');
smoothScroll(mockAnchor);
expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
addEventListenerSpy.mockRestore();
});
it('should remove click event listener when destroy is called', () => {
const action = smoothScroll(mockAnchor);
const removeEventListenerSpy = vi.spyOn(mockAnchor, 'removeEventListener');
action.destroy();
expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
removeEventListenerSpy.mockRestore();
});
});
describe('Click Handling', () => {
it('should prevent default behavior on click', () => {
const mockEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
const preventDefaultSpy = vi.spyOn(mockEvent, 'preventDefault');
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
preventDefaultSpy.mockRestore();
});
it('should scroll to target element when clicked', () => {
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
});
});
it('should update URL hash without jumping when clicked', () => {
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1');
});
});
describe('Edge Cases', () => {
it('should do nothing when href attribute is missing', () => {
mockAnchor.removeAttribute('href');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).not.toHaveBeenCalled();
expect(mockPushState).not.toHaveBeenCalled();
});
it('should do nothing when href is just "#"', () => {
mockAnchor.setAttribute('href', '#');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).not.toHaveBeenCalled();
expect(mockPushState).not.toHaveBeenCalled();
});
it('should do nothing when target element does not exist', () => {
mockAnchor.setAttribute('href', '#non-existent');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).not.toHaveBeenCalled();
expect(mockPushState).not.toHaveBeenCalled();
});
it('should handle empty href attribute', () => {
mockAnchor.setAttribute('href', '');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).not.toHaveBeenCalled();
});
});
describe('Multiple Anchors', () => {
it('should work correctly with multiple anchor elements', () => {
const anchor1 = document.createElement('a');
anchor1.setAttribute('href', '#section-1');
const target1 = document.createElement('div');
target1.id = 'section-1';
document.body.appendChild(target1);
const anchor2 = document.createElement('a');
anchor2.setAttribute('href', '#section-2');
const target2 = document.createElement('div');
target2.id = 'section-2';
document.body.appendChild(target2);
const action1 = smoothScroll(anchor1);
const action2 = smoothScroll(anchor2);
const event1 = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor1.dispatchEvent(event1);
expect(mockScrollIntoView).toHaveBeenCalledTimes(1);
const event2 = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor2.dispatchEvent(event2);
expect(mockScrollIntoView).toHaveBeenCalledTimes(2);
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-2');
// Cleanup
action1.destroy();
action2.destroy();
});
});
describe('Cleanup', () => {
it('should not trigger clicks after destroy is called', () => {
const action = smoothScroll(mockAnchor);
action.destroy();
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).not.toHaveBeenCalled();
expect(mockPushState).not.toHaveBeenCalled();
});
it('should allow multiple destroy calls without errors', () => {
const action = smoothScroll(mockAnchor);
expect(() => {
action.destroy();
action.destroy();
action.destroy();
}).not.toThrow();
});
});
describe('Scroll Options', () => {
it('should always use smooth behavior', () => {
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).toHaveBeenCalledWith(
expect.objectContaining({
behavior: 'smooth',
}),
);
});
it('should always use block: start', () => {
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).toHaveBeenCalledWith(
expect.objectContaining({
block: 'start',
}),
);
});
});
describe('Different Hash Formats', () => {
it('should handle simple hash like "#section"', () => {
const target = document.createElement('div');
target.id = 'section';
document.body.appendChild(target);
mockAnchor.setAttribute('href', '#section');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).toHaveBeenCalled();
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section');
});
it('should handle hash with multiple words like "#my-section"', () => {
const target = document.createElement('div');
target.id = 'my-section';
document.body.appendChild(target);
mockAnchor.setAttribute('href', '#my-section');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).toHaveBeenCalled();
expect(mockPushState).toHaveBeenCalledWith(null, '', '#my-section');
});
it('should handle hash with numbers like "#section-1-2"', () => {
const target = document.createElement('div');
target.id = 'section-1-2';
document.body.appendChild(target);
mockAnchor.setAttribute('href', '#section-1-2');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).toHaveBeenCalled();
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1-2');
});
});
describe('Special Cases', () => {
it('should gracefully handle missing history.pushState', () => {
// Create a fresh test environment
const testAnchor = document.createElement('a');
testAnchor.href = '#test';
const testTarget = document.createElement('div');
testTarget.id = 'test';
document.body.appendChild(testTarget);
// Don't stub history - the action should still work without it
const action = smoothScroll(testAnchor);
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
// Should not throw even if history.pushState might not exist
expect(() => testAnchor.dispatchEvent(mockEvent)).not.toThrow();
action.destroy();
testTarget.remove();
});
});
describe('Return Value', () => {
it('should return an action object compatible with Svelte use directive', () => {
const action = smoothScroll(mockAnchor);
expect(action).toHaveProperty('destroy');
expect(typeof action.destroy).toBe('function');
});
it('should allow chaining destroy calls', () => {
const action = smoothScroll(mockAnchor);
const result = action.destroy();
expect(result).toBeUndefined();
});
});
describe('Real-World Scenarios', () => {
it('should handle table of contents navigation', () => {
const sections = ['intro', 'features', 'pricing', 'contact'];
sections.forEach(id => {
const section = document.createElement('section');
section.id = id;
document.body.appendChild(section);
const link = document.createElement('a');
link.href = `#${id}`;
document.body.appendChild(link);
const action = smoothScroll(link);
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
link.dispatchEvent(event);
expect(mockScrollIntoView).toHaveBeenCalled();
action.destroy();
});
expect(mockScrollIntoView).toHaveBeenCalledTimes(sections.length);
});
it('should work with back-to-top button', () => {
const topAnchor = document.createElement('a');
topAnchor.href = '#top';
document.body.appendChild(topAnchor);
const topElement = document.createElement('div');
topElement.id = 'top';
document.body.prepend(topElement);
const action = smoothScroll(topAnchor);
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
topAnchor.dispatchEvent(event);
expect(mockScrollIntoView).toHaveBeenCalled();
action.destroy();
});
});
});

View File

@@ -0,0 +1,32 @@
/**
* Smoothly scrolls to the target element when an anchor element is clicked.
* @param node - The anchor element to listen for clicks on.
*/
export function smoothScroll(node: HTMLAnchorElement) {
const handleClick = (event: MouseEvent) => {
event.preventDefault();
const hash = node.getAttribute('href');
if (!hash || hash === '#') return;
const targetElement = document.querySelector(hash);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
// Update URL hash without jumping
history.pushState(null, '', hash);
}
};
node.addEventListener('click', handleClick);
return {
destroy() {
node.removeEventListener('click', handleClick);
},
};
}

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