Compare commits

..

98 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
82 changed files with 7808 additions and 1181 deletions

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

@@ -7,9 +7,9 @@ interface Props {
let { children, width = 'max-w-3xl' }: Props = $props(); let { children, width = 'max-w-3xl' }: Props = $props();
</script> </script>
<div class="flex min-h-screen w-full items-center justify-center bg-slate-50 p-8"> <div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
<div class="w-full bg-white shadow-xl ring-1 ring-slate-200 rounded-xl p-12 {width}"> <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"> <div class="relative flex justify-center items-center text-foreground">
{@render children()} {@render children()}
</div> </div>
</div> </div>

View File

@@ -21,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',

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,4 +1,5 @@
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 StoryStage from './StoryStage.svelte';
import '../src/app/styles/app.css'; import '../src/app/styles/app.css';
@@ -23,25 +24,41 @@ const preview: Preview = {
story: { story: {
// This sets the default height for the iframe in Autodocs // This sets the default height for the iframe in Autodocs
iframeHeight: '400px', iframeHeight: '400px',
// Ensure the story isn't forced into a tiny inline box
// inline: true,
}, },
}, },
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: [ decorators: [
(storyFn, { parameters }) => { // Wrap with providers (TooltipProvider, ResponsiveManager)
const { Component, props } = storyFn(); story => ({
return { Component: Decorator,
Component: StoryStage,
// We pass the actual story component into the Stage via a snippet/slot
// Svelte 5 Storybook handles this mapping internally when you return this structure
props: { props: {
children: Component, children: story(),
width: parameters.stageWidth || 'max-w-3xl',
...props,
}, },
}; }),
// Wrap with StoryStage for presentation styling
story => ({
Component: StoryStage,
props: {
children: story(),
}, },
}),
], ],
}; };

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 {
@@ -166,3 +226,82 @@
.barlow { .barlow {
font-family: "Barlow", system-ui, Inter, Roboto, "Segoe UI", Arial, sans-serif; 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

@@ -49,38 +49,42 @@ onMount(async () => {
} }
fontsReady = true; fontsReady = true;
}); });
$inspect(fontsReady);
</script> </script>
<svelte:head> <svelte:head>
<link rel="icon" href={GD} /> <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.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous"> <link
rel="preconnect"
href="https://fonts.gstatic.com"
crossorigin="anonymous"
/>
<link <link
rel="preload" rel="preload"
as="style" 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&display=swap" 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 <link
rel="stylesheet" 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&display=swap" 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" media="print"
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))} onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
> />
<noscript> <noscript>
<link <link
rel="stylesheet" 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&display=swap" 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> </noscript>
<title> <title>Compare Typography & Typefaces | GlyphDiff</title>
Compare Typography & Typefaces | GlyphDiff
</title>
</svelte:head> </svelte:head>
<ResponsiveProvider> <ResponsiveProvider>
@@ -90,7 +94,7 @@ $inspect(fontsReady);
</header> </header>
<!-- <ScrollArea class="h-screen w-screen"> --> <!-- <ScrollArea class="h-screen w-screen"> -->
<main class="flex-1 h-full w-full max-w-6xl mx-auto px-0 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 overflow-x-hidden"> <main class="flex-1 w-full mx-auto px-4 pt-0 pb-10 sm:px-6 sm:pt-8 sm:pb-12 md:px-8 md:pt-10 md:pb-16 lg:px-10 lg:pt-12 lg:pb-20 xl:px-16 relative">
<TooltipProvider> <TooltipProvider>
{#if fontsReady} {#if fontsReady}
{@render children?.()} {@render children?.()}

View File

@@ -1,7 +1,17 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
export interface BreadcrumbItem { export interface BreadcrumbItem {
/**
* Index of the item to display
*/
index: number; index: number;
/**
* ID of the item to navigate to
*/
id?: string;
/**
* Title snippet to render
*/
title: Snippet<[{ className?: string }]>; title: Snippet<[{ className?: string }]>;
} }

View File

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

View File

@@ -78,6 +78,56 @@ export {
unifiedFontStore, unifiedFontStore,
} from './model'; } from './model';
// Mock data helpers for Storybook and testing
export {
createCategoriesFilter,
createErrorState,
createGenericFilter,
createLoadingState,
createMockComparisonStore,
// Filter mocks
createMockFilter,
createMockFontApiResponse,
createMockFontStoreState,
// Store mocks
createMockQueryState,
createMockReactiveState,
createMockStore,
createProvidersFilter,
createSubsetsFilter,
createSuccessState,
FONTHARE_FONTS,
generateMixedCategoryFonts,
generateMockFonts,
generatePaginatedFonts,
generateSequentialFilter,
GENERIC_FILTERS,
getAllMockFonts,
getFontsByCategory,
getFontsByProvider,
GOOGLE_FONTS,
MOCK_FILTERS,
MOCK_FILTERS_ALL_SELECTED,
MOCK_FILTERS_EMPTY,
MOCK_FILTERS_SELECTED,
MOCK_FONT_STORE_STATES,
MOCK_STORES,
type MockFilterOptions,
type MockFilters,
mockFontshareFont,
type MockFontshareFontOptions,
type MockFontStoreState,
// Font mocks
mockGoogleFont,
// Types
type MockGoogleFontOptions,
type MockQueryObserverResult,
type MockQueryState,
mockUnifiedFont,
type MockUnifiedFontOptions,
UNIFIED_FONTS,
} from './lib/mocks';
// UI elements // UI elements
export { export {
FontApplicator, FontApplicator,

View File

@@ -6,3 +6,53 @@ export {
} from './normalize/normalize'; } from './normalize/normalize';
export { getFontUrl } from './getFontUrl/getFontUrl'; 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

@@ -12,9 +12,12 @@ import { AppliedFontsManager } from './appliedFontsStore.svelte';
describe('AppliedFontsManager', () => { describe('AppliedFontsManager', () => {
let manager: AppliedFontsManager; let manager: AppliedFontsManager;
let mockFontFaceSet: any; let mockFontFaceSet: any;
let mockFetch: any;
let failUrls: Set<string>;
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
failUrls = new Set();
mockFontFaceSet = { mockFontFaceSet = {
add: vi.fn(), add: vi.fn(),
@@ -22,11 +25,13 @@ describe('AppliedFontsManager', () => {
}; };
// 1. Properly mock FontFace as a constructor function // 1. Properly mock FontFace as a constructor function
const MockFontFace = vi.fn(function(this: any, name: string, url: string) { // 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.name = name;
this.url = url; this.bufferOrUrl = bufferOrUrl;
this.load = vi.fn().mockImplementation(() => { this.load = vi.fn().mockImplementation(() => {
if (url.includes('fail')) return Promise.reject(new Error('Load failed')); // 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); return Promise.resolve(this);
}); });
}); });
@@ -44,18 +49,37 @@ describe('AppliedFontsManager', () => {
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any, 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(); manager = new AppliedFontsManager();
}); });
afterEach(() => { afterEach(() => {
vi.clearAllTimers(); vi.clearAllTimers();
vi.useRealTimers(); vi.useRealTimers();
vi.unstubAllGlobals();
}); });
it('should batch multiple font requests into a single process', async () => { it('should batch multiple font requests into a single process', async () => {
const configs = [ const configs = [
{ id: 'lato-400', name: 'Lato', url: 'lato.ttf', weight: 400 }, { id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 },
{ id: 'lato-700', name: 'Lato', url: 'lato-bold.ttf', weight: 700 }, { id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 },
]; ];
manager.touch(configs); manager.touch(configs);
@@ -71,7 +95,10 @@ describe('AppliedFontsManager', () => {
// Suppress expected console error for clean test logs // Suppress expected console error for clean test logs
const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const config = { id: 'broken', name: 'Broken', url: 'fail.ttf', weight: 400 }; const failUrl = 'https://example.com/fail.ttf';
failUrls.add(failUrl);
const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 };
manager.touch([config]); manager.touch([config]);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);
@@ -81,7 +108,7 @@ describe('AppliedFontsManager', () => {
}); });
it('should purge fonts after TTL expires', async () => { it('should purge fonts after TTL expires', async () => {
const config = { id: 'ephemeral', name: 'Temp', url: 'temp.ttf', weight: 400 }; const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
manager.touch([config]); manager.touch([config]);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);
@@ -96,7 +123,7 @@ describe('AppliedFontsManager', () => {
}); });
it('should NOT purge fonts that are still being "touched"', async () => { it('should NOT purge fonts that are still being "touched"', async () => {
const config = { id: 'active', name: 'Active', url: 'active.ttf', weight: 400 }; const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 };
manager.touch([config]); manager.touch([config]);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);

View File

@@ -1,67 +1,109 @@
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 { export interface FontConfigRequest {
/** /**
* Font id * Unique identifier for the font (e.g., "lato", "roboto").
*/ */
id: string; id: string;
/** /**
* Real font name (e.g. "Lato") * Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
*/ */
name: string; name: string;
/** /**
* The .ttf URL * URL pointing to the font file (typically .ttf or .woff2).
*/ */
url: string; url: string;
/** /**
* Font weight * Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
*/ */
weight: number; weight: number;
/** /**
* Flag of the variable weight * Variable fonts load once per ID; static fonts load per weight.
*/ */
isVariable?: boolean; isVariable?: boolean;
} }
/** /**
* Manager that handles loading of fonts. * Manages web font loading with caching, adaptive concurrency, and automatic cleanup.
* Logic: *
* - Variable fonts: Loaded once per id (covers all weights). * **Two-Phase Loading Strategy:**
* - Static fonts: Loaded per id + weight combination. * 1. *Concurrent Fetching*: Font files fetched in parallel (network I/O is non-blocking)
* 2. *Sequential Parsing*: Buffers parsed into FontFace objects one at a time with periodic yields
*
* **Yielding Strategy:**
* - Chromium: Yields only when user input is pending (via `scheduler.yield()` + `isInputPending()`)
* - Others: Time-based fallback, yields every 8ms
*
* **Network Adaptation:**
* - 2G: 1 concurrent request, 3G: 2, 4G+: 4 (via Network Information API)
* - Respects `saveData` mode to defer non-critical weights
*
* **Cache Integration:** Cache API with best-effort fallback (handles private browsing, quota limits)
*
* **Cleanup:** LRU-style eviction after 5 minutes of inactivity; cleanup runs every 60 seconds
*
* **Font Identity:** Variable fonts use `{id}@vf`, static fonts use `{id}@{weight}`
*
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
*/ */
export class AppliedFontsManager { export class AppliedFontsManager {
// Stores the actual FontFace objects for cleanup // Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf`
#loadedFonts = new Map<string, FontFace>(); #loadedFonts = new Map<string, FontFace>();
// Optimization: Map<batchId, Set<fontKeys>> to avoid O(N^2) scans
#batchToKeys = new Map<string, Set<string>>();
// Optimization: Map<fontKey, batchId> for reverse lookup
#keyToBatch = new Map<string, string>();
// 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>();
// Fonts queued for loading by `touch()`, processed by `#processQueue()`
#queue = new Map<string, FontConfigRequest>(); #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;
readonly #PURGE_INTERVAL = 60000; // Interval handle for periodic cleanup (runs every PURGE_INTERVAL)
readonly #TTL = 5 * 60 * 1000; #intervalId: ReturnType<typeof setInterval> | null = null;
readonly #CHUNK_SIZE = 5;
// 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') {
// Using a weak reference style approach isn't possible for DOM, this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
// so we stick to the interval but make it highly efficient.
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 { #getFontKey(id: string, weight: number, isVariable: boolean): string {
return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`; 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[]) { touch(configs: FontConfigRequest[]) {
if (this.#abortController.signal.aborted) return;
const now = Date.now(); const now = Date.now();
let hasNewItems = false; let hasNewItems = false;
@@ -69,105 +111,244 @@ export class AppliedFontsManager {
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable); const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
this.#usageTracker.set(key, now); this.#usageTracker.set(key, now);
if (this.statuses.get(key) === 'loaded' || this.statuses.get(key) === 'loading' || this.#queue.has(key)) { const status = this.statuses.get(key);
continue; 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); this.#queue.set(key, config);
hasNewItems = true; hasNewItems = true;
} }
// IMPROVEMENT: Only trigger timer if not already pending
if (hasNewItems && !this.#timeoutId) { if (hasNewItems && !this.#timeoutId) {
this.#timeoutId = setTimeout(() => this.#processQueue(), 16); // ~1 frame delay 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';
}
} }
} }
#processQueue() { /** 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.#timeoutId = null;
const entries = Array.from(this.#queue.entries()); this.#pendingType = null;
if (entries.length === 0) return;
let entries = Array.from(this.#queue.entries());
if (!entries.length) return;
this.#queue.clear(); this.#queue.clear();
// Process in chunks to keep the UI responsive if (this.#shouldDeferNonCritical()) {
for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) { entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
this.#applyBatch(entries.slice(i, i + this.#CHUNK_SIZE));
}
} }
async #applyBatch(batchEntries: [string, FontConfigRequest][]) { // Phase 1: Concurrent fetching (I/O bound, non-blocking)
if (typeof document === 'undefined') return; const concurrency = this.#getEffectiveConcurrency();
const buffers = new Map<string, ArrayBuffer>();
const batchId = crypto.randomUUID(); for (let i = 0; i < entries.length; i += concurrency) {
const keysInBatch = new Set<string>(); const chunk = entries.slice(i, i + concurrency);
const results = await Promise.allSettled(
const loadPromises = batchEntries.map(([key, config]) => { chunk.map(async ([key, config]) => {
this.statuses.set(key, 'loading'); this.statuses.set(key, 'loading');
this.#keyToBatch.set(key, batchId); const buffer = await this.#fetchFontBuffer(
keysInBatch.add(key); config.url,
this.#abortController.signal,
);
buffers.set(key, buffer);
}),
);
// Use a unique internal family name to prevent collisions for (let j = 0; j < results.length; j++) {
// while keeping the "real" name for the browser to resolve weight/style. if (results[j].status === 'rejected') {
const internalName = `f_${config.id}`; 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 weightRange = config.isVariable ? '100 900' : `${config.weight}`;
const font = new FontFace(config.name, buffer, {
const font = new FontFace(config.name, `url(${config.url})`, {
weight: weightRange, weight: weightRange,
style: 'normal', style: 'normal',
display: 'swap', display: 'swap',
}); });
await font.load();
document.fonts.add(font);
this.#loadedFonts.set(key, font); this.#loadedFonts.set(key, font);
return font.load()
.then(loadedFace => {
document.fonts.add(loadedFace);
this.statuses.set(key, 'loaded'); this.statuses.set(key, 'loaded');
}) } catch (e) {
.catch(e => { if (e instanceof Error && e.name === 'AbortError') continue;
console.error(`Font load failed: ${config.name}`, e); console.error(`Font parse failed: ${config.name}`, e);
this.statuses.set(key, 'error'); this.statuses.set(key, 'error');
}); this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
});
this.#batchToKeys.set(batchId, keysInBatch);
await Promise.allSettled(loadPromises);
} }
const shouldYield = hasInputPending
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
: (performance.now() - lastYield > YIELD_INTERVAL);
if (shouldYield) {
await this.#yieldToMain();
lastYield = performance.now();
}
}
}
/**
* Fetches font with cache-aside pattern: checks Cache API first, falls back to network.
* Cache failures (private browsing, quota limits) are silently ignored.
*/
async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
try {
if (typeof caches !== 'undefined') {
const cache = await caches.open(this.#CACHE_NAME);
const cached = await cache.match(url);
if (cached) return cached.arrayBuffer();
}
} catch {
// Cache unavailable (private browsing, security restrictions) — fall through to network
}
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
try {
if (typeof caches !== 'undefined') {
const cache = await caches.open(this.#CACHE_NAME);
await cache.put(url, response.clone());
}
} catch {
// Cache write failed (quota, storage pressure) — return font anyway
}
return response.arrayBuffer();
}
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. */
#purgeUnused() { #purgeUnused() {
const now = Date.now(); const now = Date.now();
for (const [key, lastUsed] of this.#usageTracker) {
if (now - lastUsed < this.#TTL) continue;
// We iterate over batches, not individual fonts, to reduce loops
for (const [batchId, keys] of this.#batchToKeys.entries()) {
let canPurgeBatch = true;
for (const key of keys) {
const lastUsed = this.#usageTracker.get(key) || 0;
if (now - lastUsed < this.#TTL) {
canPurgeBatch = false;
break;
}
}
if (canPurgeBatch) {
keys.forEach(key => {
const font = this.#loadedFonts.get(key); const font = this.#loadedFonts.get(key);
if (font) document.fonts.delete(font); if (font) document.fonts.delete(font);
this.#loadedFonts.delete(key); this.#loadedFonts.delete(key);
this.#keyToBatch.delete(key);
this.#usageTracker.delete(key); this.#usageTracker.delete(key);
this.statuses.delete(key); this.statuses.delete(key);
}); this.#retryCounts.delete(key);
this.#batchToKeys.delete(batchId);
}
} }
} }
/** Returns current loading status for a font, or undefined if never requested. */
getFontStatus(id: string, weight: number, isVariable = false) { getFontStatus(id: string, weight: number, isVariable = false) {
return this.statuses.get(this.#getFontKey(id, weight, isVariable)); return this.statuses.get(this.#getFontKey(id, weight, isVariable));
} }
/** Waits for all fonts to finish loading using document.fonts.ready. */
async ready(): Promise<void> {
if (typeof document === 'undefined') return;
try {
await document.fonts.ready;
} catch {
// document.fonts.ready can reject in some edge cases
// (e.g., document unloaded). Silently resolve.
}
}
/** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */
destroy() {
this.#abortController.abort();
if (this.#timeoutId !== null) {
if (this.#pendingType === 'idle' && typeof cancelIdleCallback !== 'undefined') {
cancelIdleCallback(this.#timeoutId as unknown as number);
} else {
clearTimeout(this.#timeoutId);
}
this.#timeoutId = null;
this.#pendingType = null;
}
if (this.#intervalId) {
clearInterval(this.#intervalId);
this.#intervalId = null;
}
if (typeof document !== 'undefined') {
for (const font of this.#loadedFonts.values()) {
document.fonts.delete(font);
}
}
this.#loadedFonts.clear();
this.#usageTracker.clear();
this.#retryCounts.clear();
this.statuses.clear();
this.#queue.clear();
}
} }
/** Singleton instance — use throughout the application for unified font loading state. */
export const appliedFontsManager = new AppliedFontsManager(); export const appliedFontsManager = new AppliedFontsManager();

View File

@@ -215,7 +215,6 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
// Note: For offset === 0, we rely on the $effect above to handle the reset/init // Note: For offset === 0, we rely on the $effect above to handle the reset/init
// This prevents race conditions and double-setting. // This prevents race conditions and double-setting.
if (params.offset !== 0) { if (params.offset !== 0) {
// Append new fonts to existing ones only for pagination
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts]; this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
} }

View File

@@ -2,34 +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 appears - 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 { prefersReducedMotion } from 'svelte/motion'; import { prefersReducedMotion } from 'svelte/motion';
import { appliedFontsManager } from '../../model'; import {
type UnifiedFont,
appliedFontsManager,
} from '../../model';
interface Props { interface Props {
/** /**
* Font name to set * Applied font
*/ */
name: string; font: UnifiedFont;
/**
* Font id to load
*/
id: string;
/** */
url: string;
/** /**
* Font weight * Font weight
*/ */
weight?: number; weight?: number;
/**
* Variable font flag
*/
isVariable?: boolean;
/** /**
* Additional classes * Additional classes
*/ */
@@ -40,44 +33,23 @@ interface Props {
children?: Snippet; children?: Snippet;
} }
let { name, id, url, weight = 400, isVariable = false, className, children }: Props = $props(); let {
let element: Element; font,
weight = 400,
className,
children,
}: Props = $props();
// Track if the user has actually scrolled this into view const status = $derived(
let hasEnteredViewport = $state(false); appliedFontsManager.getFontStatus(
const status = $derived(appliedFontsManager.getFontStatus(id, weight, isVariable)); font.id,
$effect(() => {
if (status === 'loaded' || status === 'error') {
hasEnteredViewport = true;
return;
}
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
hasEnteredViewport = true;
// Touch ensures it's in the queue.
// It's safe to call this even if VirtualList called it
// (Manager dedupes based on key)
appliedFontsManager.touch([{
id,
weight, weight,
name, font.features.isVariable,
url, ),
isVariable, );
}]);
observer.unobserve(element); // 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');
});
if (element) observer.observe(element);
return () => observer.disconnect();
});
// The "Show" condition: Element is in view AND (Font is ready OR it errored out)
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded'));
const transitionClasses = $derived( const transitionClasses = $derived(
prefersReducedMotion.current prefersReducedMotion.current
@@ -87,12 +59,14 @@ const transitionClasses = $derived(
</script> </script>
<div <div
bind:this={element} style:font-family={shouldReveal
style:font-family={shouldReveal ? `'${name}'` : 'system-ui, -apple-system, sans-serif'} ? `'${font.name}'`
: 'system-ui, -apple-system, sans-serif'}
class={cn( class={cn(
transitionClasses, transitionClasses,
// If reduced motion is on, we skip the transform/blur entirely // If reduced motion is on, we skip the transform/blur entirely
!shouldReveal && !prefersReducedMotion.current !shouldReveal
&& !prefersReducedMotion.current
&& 'opacity-50 scale-[0.95] blur-sm', && 'opacity-50 scale-[0.95] blur-sm',
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement !shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
shouldReveal && 'opacity-100 scale-100 blur-0', shouldReveal && 'opacity-100 scale-100 blur-0',

View File

@@ -1,11 +1,6 @@
<!--
Component: FontListItem
Displays a font item and manages its animations
-->
<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 { Spring } from 'svelte/motion';
import { type UnifiedFont } from '../../model'; import { type UnifiedFont } from '../../model';
interface Props { interface Props {
@@ -31,51 +26,14 @@ interface Props {
children: Snippet<[font: UnifiedFont]>; children: Snippet<[font: UnifiedFont]>;
} }
const { font, isFullyVisible, isPartiallyVisible, proximity, children }: Props = $props(); const { font, children }: Props = $props();
let timeoutId = $state<NodeJS.Timeout | null>(null);
// Create a spring for smooth scale animation
const scale = new Spring(1, {
stiffness: 0.3,
damping: 0.7,
});
// Springs react to the virtualizer's computed state
const bloom = new Spring(0, {
stiffness: 0.15,
damping: 0.6,
});
// Sync spring to proximity for a "Lens" effect
$effect(() => {
bloom.target = isPartiallyVisible ? 1 : 0;
});
$effect(() => {
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
function animateSelection() {
scale.target = 0.98;
timeoutId = setTimeout(() => {
scale.target = 1;
}, 150);
}
</script> </script>
<div <div
class={cn('pb-1 will-change-transform')} class={cn(
style:opacity={bloom.current} 'pb-1 will-change-transform transition-transform duration-200 ease-out',
style:transform=" 'hover:scale-[0.98]', // Simple CSS hover effect
scale({0.92 + (bloom.current * 0.08)}) )}
translateY({(1 - bloom.current) * 10}px)
"
> >
{@render children?.(font)} {@render children?.(font)}
</div> </div>

View File

@@ -3,58 +3,57 @@
- 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 UnifiedFont"> <script lang="ts">
import { import {
Skeleton, Skeleton,
VirtualList, VirtualList,
} from '$shared/ui'; } from '$shared/ui';
import type { ComponentProps } from 'svelte'; import type {
ComponentProps,
Snippet,
} from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { getFontUrl } from '../../lib'; import { getFontUrl } from '../../lib';
import type { FontConfigRequest } from '../../model';
import { import {
type FontConfigRequest,
type UnifiedFont, type UnifiedFont,
appliedFontsManager, appliedFontsManager,
unifiedFontStore,
} from '../../model'; } from '../../model';
interface Props extends interface Props extends
Omit< Omit<
ComponentProps<typeof VirtualList<T>>, ComponentProps<typeof VirtualList<UnifiedFont>>,
'onVisibleItemsChange' 'items' | 'total' | 'isLoading' | 'onVisibleItemsChange' | 'onNearBottom'
> >
{ {
/** /**
* Callback for when visible items change * Callback for when visible items change
*/ */
onVisibleItemsChange?: (items: T[]) => void; onVisibleItemsChange?: (items: UnifiedFont[]) => void;
/**
* Callback for when near bottom is reached
*/
onNearBottom?: (lastVisibleIndex: number) => void;
/**
* Weight of the font
*/
/** /**
* Weight of the font * Weight of the font
*/ */
weight: number; weight: number;
/** /**
* Whether the list is in a loading state * Skeleton snippet
*/ */
isLoading?: boolean; skeleton?: Snippet;
} }
let { let {
items,
children, children,
onVisibleItemsChange, onVisibleItemsChange,
onNearBottom,
weight, weight,
isLoading = false, skeleton,
...rest ...rest
}: Props = $props(); }: Props = $props();
function handleInternalVisibleChange(visibleItems: T[]) { const isLoading = $derived(
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
);
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
const configs: FontConfigRequest[] = []; const configs: FontConfigRequest[] = [];
visibleItems.forEach(item => { visibleItems.forEach(item => {
@@ -77,37 +76,54 @@ function handleInternalVisibleChange(visibleItems: T[]) {
// onVisibleItemsChange?.(visibleItems); // onVisibleItemsChange?.(visibleItems);
} }
function handleNearBottom(lastVisibleIndex: number) { /**
// Forward the call to any external listener * Load more fonts by moving to the next page
onNearBottom?.(lastVisibleIndex); */
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>
{#key isLoading} <div class="relative w-full h-full">
<div class="relative w-full h-full" transition:fade={{ duration: 300 }}> {#if skeleton && isLoading && unifiedFontStore.fonts.length === 0}
{#if isLoading} <!-- Show skeleton only on initial load when no fonts are loaded yet -->
<div class="flex flex-col gap-3 sm:gap-4 p-3 sm:p-4"> <div transition:fade={{ duration: 300 }}>
{#each Array(5) as _, i} {@render skeleton()}
<div class="flex flex-col gap-1.5 sm:gap-2 p-3 sm:p-4 border rounded-lg sm:rounded-xl border-gray-200/50 bg-white/40">
<div class="flex items-center justify-between mb-3 sm:mb-4">
<Skeleton class="h-6 sm:h-8 w-1/3" />
<Skeleton class="h-6 sm:h-8 w-6 sm:w-8 rounded-full" />
</div>
<Skeleton class="h-24 sm:h-32 w-full" />
</div>
{/each}
</div> </div>
{:else} {:else}
<!-- VirtualList persists during pagination - no destruction/recreation -->
<VirtualList <VirtualList
{items} items={unifiedFontStore.fonts}
{...rest} total={unifiedFontStore.pagination.total}
isLoading={isLoading}
onVisibleItemsChange={handleInternalVisibleChange} onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom} onNearBottom={handleNearBottom}
{...rest}
> >
{#snippet children(scope)} {#snippet children(scope)}
{@render children(scope)} {@render children(scope)}
{/snippet} {/snippet}
</VirtualList> </VirtualList>
{/if} {/if}
</div> </div>
{/key}

View File

@@ -36,12 +36,7 @@ interface Props {
letterSpacing?: number; letterSpacing?: number;
} }
let { let { font, text = $bindable(), index = 0, ...restProps }: Props = $props();
font,
text = $bindable(),
index = 0,
...restProps
}: Props = $props();
const fontWeight = $derived(controlManager.weight); const fontWeight = $derived(controlManager.weight);
const fontSize = $derived(controlManager.renderedSize); const fontSize = $derived(controlManager.renderedSize);
@@ -53,22 +48,22 @@ const letterSpacing = $derived(controlManager.spacing);
class=" class="
w-full h-full rounded-xl sm:rounded-2xl w-full h-full rounded-xl sm:rounded-2xl
flex flex-col flex flex-col
backdrop-blur-md bg-white/80 bg-background-80
border border-gray-300/50 border border-border-muted
shadow-[0_1px_3px_rgba(0,0,0,0.04)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]
relative overflow-hidden relative overflow-hidden
" "
style:font-weight={fontWeight} style:font-weight={fontWeight}
> >
<div class="px-4 sm:px-5 md:px-6 py-2.5 sm:py-3 border-b border-gray-200/60 flex items-center justify-between"> <div class="px-4 sm:px-5 md:px-6 py-2.5 sm:py-3 border-b border-border-subtle flex items-center justify-between">
<div class="flex items-center gap-2 sm:gap-2.5"> <div class="flex items-center gap-2 sm:gap-2.5">
<Footnote> <Footnote>
typeface_{String(index).padStart(3, '0')} typeface_{String(index).padStart(3, '0')}
</Footnote> </Footnote>
<div class="w-px h-2 sm:h-2.5 bg-gray-300/60"></div> <div class="w-px h-2 sm:h-2.5 bg-border-subtle"></div>
<Footnote class="tracking-[0.15em] font-bold text-gray-900"> <div class="font-bold text-foreground">
{font.name} {font.name}
</Footnote> </div>
</div> </div>
<!-- <!--
@@ -84,31 +79,30 @@ const letterSpacing = $derived(controlManager.spacing);
</div> </div>
<div class="p-4 sm:p-5 md:p-8 relative z-10"> <div class="p-4 sm:p-5 md:p-8 relative z-10">
<!-- TODO: Fix this ! --> <FontApplicator {font} weight={fontWeight}>
<FontApplicator id={font.id} name={font.name} url={font.styles.regular!}>
<ContentEditable <ContentEditable
bind:text={text} bind:text
{...restProps} {...restProps}
fontSize={fontSize} {fontSize}
lineHeight={lineHeight} {lineHeight}
letterSpacing={letterSpacing} {letterSpacing}
/> />
</FontApplicator> </FontApplicator>
</div> </div>
<div class="px-4 sm:px-5 md:px-6 py-1.5 sm:py-2 border-t border-gray-200/40 w-full flex flex-row gap-2 sm:gap-4 bg-gray-50/30 mt-auto"> <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"> <Footnote class="text-[7px] sm:text-[8px] tracking-wider ml-auto">
SZ:{fontSize}PX SZ:{fontSize}PX
</Footnote> </Footnote>
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div> <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"> <Footnote class="text-[7px] sm:text-[8px] tracking-wider">
WGT:{fontWeight} WGT:{fontWeight}
</Footnote> </Footnote>
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div> <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"> <Footnote class="text-[7px] sm:text-[8px] tracking-wider">
LH:{lineHeight?.toFixed(2)} LH:{lineHeight?.toFixed(2)}
</Footnote> </Footnote>
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div> <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"> <Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider">
LTR:{letterSpacing} LTR:{letterSpacing}
</Footnote> </Footnote>

View File

@@ -15,6 +15,7 @@ import {
Drawer, Drawer,
IconButton, IconButton,
} from '$shared/ui'; } from '$shared/ui';
import { Label } from '$shared/ui';
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical'; import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
@@ -72,7 +73,11 @@ $effect(() => {
</script> </script>
<div <div
class={cn('w-auto max-screen z-10 flex justify-center', hidden && 'hidden', className)} class={cn(
'w-auto max-screen z-10 flex justify-center',
hidden && 'hidden',
className,
)}
in:receive={{ key: 'panel' }} in:receive={{ key: 'panel' }}
out:send={{ key: 'panel' }} out:send={{ key: 'panel' }}
> >
@@ -86,11 +91,17 @@ $effect(() => {
</IconButton> </IconButton>
{/snippet} {/snippet}
{#snippet content({ className })} {#snippet content({ className })}
<div class={cn(className, 'flex flex-col gap-6')}> <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)} {#each controlManager.controls as control (control.id)}
<ComboControlV2 <ComboControlV2
control={control.instance} control={control.instance}
orientation="horizontal" orientation="horizontal"
label={control.controlLabel}
reduced reduced
/> />
{/each} {/each}
@@ -112,6 +123,7 @@ $effect(() => {
decreaseLabel={control.decreaseLabel} decreaseLabel={control.decreaseLabel}
controlLabel={control.controlLabel} controlLabel={control.controlLabel}
orientation="vertical" orientation="vertical"
showScale={false}
/> />
{/each} {/each}
</div> </div>

View File

@@ -4,6 +4,8 @@
--> -->
<script lang="ts"> <script lang="ts">
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb'; import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { import {
Logo, Logo,
Section, Section,
@@ -15,17 +17,26 @@ import CodeIcon from '@lucide/svelte/icons/code';
import EyeIcon from '@lucide/svelte/icons/eye'; import EyeIcon from '@lucide/svelte/icons/eye';
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle'; import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
import ScanSearchIcon from '@lucide/svelte/icons/search'; import ScanSearchIcon from '@lucide/svelte/icons/search';
import type { Snippet } from 'svelte'; import {
type Snippet,
getContext,
} from 'svelte';
import { cubicIn } from 'svelte/easing'; import { cubicIn } from 'svelte/easing';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
let searchContainer: HTMLElement; let searchContainer: HTMLElement;
let isExpanded = $state(false); let isExpanded = $state(true);
const responsive = getContext<ResponsiveManager>('responsive');
function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippet<[{ className?: string }]>) { function handleTitleStatusChanged(
index: number,
isPast: boolean,
title?: Snippet<[{ className?: string }]>,
id?: string,
) {
if (isPast && title) { if (isPast && title) {
scrollBreadcrumbsStore.add({ index, title }); scrollBreadcrumbsStore.add({ index, title, id });
} else { } else {
scrollBreadcrumbsStore.remove(index); scrollBreadcrumbsStore.remove(index);
} }
@@ -34,35 +45,38 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
scrollBreadcrumbsStore.remove(index); scrollBreadcrumbsStore.remove(index);
}; };
} }
// $effect(() => {
// appliedFontsManager.touch(
// selectedFontsStore.all.map(font => ({
// slug: font.id,
// weight: controlManager.weight,
// })),
// );
// });
</script> </script>
<!-- Font List --> <!-- Font List -->
<div <div
class="p-2 sm:p-3 md:p-4 h-full flex flex-col gap-3 sm:gap-4" 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 }} in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
> >
<Section class="my-4 sm:my-10 md:my-12 gap-6 sm:gap-8" onTitleStatusChange={handleTitleStatusChanged}> <Section
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-8"
onTitleStatusChange={handleTitleStatusChanged}
>
{#snippet icon({ className })} {#snippet icon({ className })}
<CodeIcon class={className} /> <CodeIcon class={className} />
{/snippet} {/snippet}
{#snippet description({ className })} {#snippet description({ className })}
<span class={className}> <span class={className}> Project_Codename </span>
Project_Codename
</span>
{/snippet} {/snippet}
{#snippet content({ className })}
<div class={cn(className, 'col-start-0 col-span-2')}>
<Logo /> <Logo />
</div>
{/snippet}
</Section> </Section>
<Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}> <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 })} {#snippet icon({ className })}
<EyeIcon class={className} /> <EyeIcon class={className} />
{/snippet} {/snippet}
@@ -71,10 +85,21 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
Optical<br />Comparator Optical<br />Comparator
</h1> </h1>
{/snippet} {/snippet}
{#snippet content({ className })}
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
<ComparisonSlider /> <ComparisonSlider />
</div>
{/snippet}
</Section> </Section>
<Section class="my-4 sm:my-10 md:my-12 gap-6 sm:gap-8" index={2} onTitleStatusChange={handleTitleStatusChanged}> <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 })} {#snippet icon({ className })}
<ScanSearchIcon class={className} /> <ScanSearchIcon class={className} />
{/snippet} {/snippet}
@@ -83,10 +108,21 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
Query<br />Module Query<br />Module
</h2> </h2>
{/snippet} {/snippet}
{#snippet content({ className })}
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
<FontSearch bind:showFilters={isExpanded} /> <FontSearch bind:showFilters={isExpanded} />
</div>
{/snippet}
</Section> </Section>
<Section class="my-4 sm:my-10 md:my-12 gap-6 sm:gap-8" index={3} onTitleStatusChange={handleTitleStatusChanged}> <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 })} {#snippet icon({ className })}
<LineSquiggleIcon class={className} /> <LineSquiggleIcon class={className} />
{/snippet} {/snippet}
@@ -95,7 +131,11 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
Sample<br />Set Sample<br />Set
</h2> </h2>
{/snippet} {/snippet}
{#snippet content({ className })}
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
<SampleList /> <SampleList />
</div>
{/snippet}
</Section> </Section>
</div> </div>

View File

@@ -2,7 +2,13 @@
* Interface representing a line of text with its measured width. * Interface representing a line of text with its measured width.
*/ */
export interface LineData { export interface LineData {
/**
* Line's text
*/
text: string; text: string;
/**
* It's width
*/
width: number; width: number;
} }
@@ -80,16 +86,23 @@ export function createCharacterComparison<
container: HTMLElement | undefined, container: HTMLElement | undefined,
measureCanvas: HTMLCanvasElement | undefined, measureCanvas: HTMLCanvasElement | undefined,
) { ) {
if (!container || !measureCanvas || !fontA() || !fontB()) return; if (!container || !measureCanvas || !fontA() || !fontB()) {
return;
}
const rect = container.getBoundingClientRect(); // Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues
containerWidth = rect.width; // 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 // Padding considerations - matches the container padding
const padding = window.innerWidth < 640 ? 48 : 96; const padding = window.innerWidth < 640 ? 48 : 96;
const availableWidth = rect.width - padding; const availableWidth = width - padding;
const ctx = measureCanvas.getContext('2d'); const ctx = measureCanvas.getContext('2d');
if (!ctx) return; if (!ctx) {
return;
}
const controlledFontSize = size(); const controlledFontSize = size();
const fontSize = getFontSize(); const fontSize = getFontSize();
@@ -276,3 +289,5 @@ export function createCharacterComparison<
getCharState, getCharState,
}; };
} }
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;

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

@@ -3,6 +3,7 @@
* *
* 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
@@ -120,9 +121,11 @@ export function createVirtualizer<T>(
// 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++) {
@@ -130,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;
}); });
@@ -144,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;
@@ -185,10 +191,13 @@ export function createVirtualizer<T>(
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd; const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
// Proximity calculation: 1.0 at center, 0.0 at edges // 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 itemCenter = itemStart + (itemSize / 2);
const distanceToCenter = Math.abs(viewportCenter - itemCenter); const distanceToCenter = Math.abs(viewportCenter - itemCenter);
const maxDistance = containerHeight / 2; const maxDistance = containerHeight / 2;
const proximity = Math.max(0, 1 - (distanceToCenter / maxDistance)); const proximity = maxDistance > 0
? Math.max(0, 1 - (distanceToCenter / maxDistance))
: 0;
result.push({ result.push({
index: i, index: i,
@@ -202,16 +211,6 @@ export function createVirtualizer<T>(
}); });
} }
// console.log('🎯 Virtual Items Calculation:', {
// scrollOffset,
// containerHeight,
// viewportEnd,
// startIdx,
// endIdx,
// withOverscan: { start, end },
// itemCount: end - start,
// });
return result; return result;
}); });
// Svelte Actions (The DOM Interface) // Svelte Actions (The DOM Interface)
@@ -252,25 +251,19 @@ export function createVirtualizer<T>(
scrollOffset = scrolledPastTop; scrollOffset = scrolledPastTop;
rafId = null; rafId = null;
}); });
// 🔍 DIAGNOSTIC
// console.log('📜 Scroll Event:', {
// windowScrollY: window.scrollY,
// elementRectTop: rect.top,
// scrolledPastTop,
// containerHeight
// });
}; };
const handleResize = () => { const handleResize = () => {
containerHeight = window.innerHeight; containerHeight = window.innerHeight;
cachedOffsetTop = getElementOffset(); elementOffsetTop = getElementOffset();
cachedOffsetTop = elementOffsetTop;
handleScroll(); handleScroll();
}; };
// Initial setup // Initial setup
requestAnimationFrame(() => { requestAnimationFrame(() => {
cachedOffsetTop = getElementOffset(); elementOffsetTop = getElementOffset();
cachedOffsetTop = elementOffsetTop;
handleScroll(); handleScroll();
}); });
@@ -289,6 +282,11 @@ export function createVirtualizer<T>(
cancelAnimationFrame(rafId); cancelAnimationFrame(rafId);
rafId = null; rafId = null;
} }
// Disconnect shared ResizeObserver
if (sharedResizeObserver) {
sharedResizeObserver.disconnect();
sharedResizeObserver = null;
}
elementRef = null; elementRef = null;
}, },
}; };
@@ -310,6 +308,11 @@ export function createVirtualizer<T>(
destroy() { destroy() {
node.removeEventListener('scroll', handleScroll); node.removeEventListener('scroll', handleScroll);
resizeObserver.disconnect(); resizeObserver.disconnect();
// Disconnect shared ResizeObserver
if (sharedResizeObserver) {
sharedResizeObserver.disconnect();
sharedResizeObserver = null;
}
elementRef = null; elementRef = null;
}, },
}; };
@@ -318,44 +321,67 @@ export function createVirtualizer<T>(
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)) { if (!isNaN(index)) {
const oldHeight = measuredSizes[index]; const oldHeight = measuredSizes[index];
// Only update if the height difference is significant (> 0.5px) // Only update if the height difference is significant (> 0.5px)
// This prevents "jitter" from focus rings or sub-pixel border changes
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) { if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
// Stuff the measurement into a temporary buffer
measurementBuffer[index] = height; measurementBuffer[index] = height;
}
}
}
// Schedule a single update for the next animation frame // Schedule a single update for the next animation frame
if (frameId === null) { if (frameId === null && Object.keys(measurementBuffer).length > 0) {
frameId = requestAnimationFrame(() => { frameId = requestAnimationFrame(() => {
measuredSizes = { ...measuredSizes, ...measurementBuffer }; // Mutation in place for performance
// Reset the buffer Object.assign(measuredSizes, measurementBuffer);
// 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
@@ -395,6 +421,28 @@ export function createVirtualizer<T>(
} }
} }
/**
* 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() { get scrollOffset() {
return scrollOffset; return scrollOffset;
@@ -416,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

@@ -28,6 +28,7 @@ export {
} from './createEntityStore/createEntityStore.svelte'; } from './createEntityStore/createEntityStore.svelte';
export { export {
type CharacterComparison,
createCharacterComparison, createCharacterComparison,
type LineData, type LineData,
} from './createCharacterComparison/createCharacterComparison.svelte'; } from './createCharacterComparison/createCharacterComparison.svelte';
@@ -42,3 +43,8 @@ export {
type ResponsiveManager, type ResponsiveManager,
responsiveManager, responsiveManager,
} from './createResponsiveManager/createResponsiveManager.svelte'; } from './createResponsiveManager/createResponsiveManager.svelte';
export {
createPerspectiveManager,
type PerspectiveManager,
} from './createPerspectiveManager/createPerspectiveManager.svelte';

View File

@@ -1,4 +1,5 @@
export { export {
type CharacterComparison,
type ControlDataModel, type ControlDataModel,
type ControlModel, type ControlModel,
createCharacterComparison, createCharacterComparison,
@@ -6,6 +7,7 @@ export {
createEntityStore, createEntityStore,
createFilter, createFilter,
createPersistentStore, createPersistentStore,
createPerspectiveManager,
createResponsiveManager, createResponsiveManager,
createTypographyControl, createTypographyControl,
createVirtualizer, createVirtualizer,
@@ -15,6 +17,7 @@ export {
type FilterModel, type FilterModel,
type LineData, type LineData,
type PersistentStore, type PersistentStore,
type PerspectiveManager,
type Property, type Property,
type ResponsiveManager, type ResponsiveManager,
responsiveManager, responsiveManager,
@@ -24,7 +27,16 @@ export {
type VirtualizerOptions, type VirtualizerOptions,
} from './helpers'; } from './helpers';
export { splitArray } from './utils'; export {
buildQueryString,
clampNumber,
debounce,
getDecimalPlaces,
roundToStepPrecision,
smoothScroll,
splitArray,
throttle,
} from './utils';
export { springySlideFade } from './transitions'; export { springySlideFade } from './transitions';

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

@@ -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);
},
};
}

View File

@@ -0,0 +1,405 @@
import {
describe,
expect,
it,
} from 'vitest';
import { splitArray } from './splitArray';
describe('splitArray', () => {
describe('Basic Functionality', () => {
it('should split an array into two arrays based on callback', () => {
const input = [1, 2, 3, 4, 5];
const [pass, fail] = splitArray(input, n => n > 2);
expect(pass).toEqual([3, 4, 5]);
expect(fail).toEqual([1, 2]);
});
it('should return two arrays', () => {
const result = splitArray([1, 2, 3], () => true);
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect(Array.isArray(result[0])).toBe(true);
expect(Array.isArray(result[1])).toBe(true);
});
it('should preserve original array', () => {
const input = [1, 2, 3, 4, 5];
const original = [...input];
splitArray(input, n => n % 2 === 0);
expect(input).toEqual(original);
});
});
describe('Empty Array', () => {
it('should return two empty arrays for empty input', () => {
const [pass, fail] = splitArray([], () => true);
expect(pass).toEqual([]);
expect(fail).toEqual([]);
});
it('should handle empty array with falsy callback', () => {
const [pass, fail] = splitArray([], () => false);
expect(pass).toEqual([]);
expect(fail).toEqual([]);
});
});
describe('All Pass', () => {
it('should put all elements in pass array when callback returns true for all', () => {
const input = [1, 2, 3, 4, 5];
const [pass, fail] = splitArray(input, () => true);
expect(pass).toEqual([1, 2, 3, 4, 5]);
expect(fail).toEqual([]);
});
it('should put all elements in pass array using always-true condition', () => {
const input = ['a', 'b', 'c'];
const [pass, fail] = splitArray(input, s => s.length > 0);
expect(pass).toEqual(['a', 'b', 'c']);
expect(fail).toEqual([]);
});
});
describe('All Fail', () => {
it('should put all elements in fail array when callback returns false for all', () => {
const input = [1, 2, 3, 4, 5];
const [pass, fail] = splitArray(input, () => false);
expect(pass).toEqual([]);
expect(fail).toEqual([1, 2, 3, 4, 5]);
});
it('should put all elements in fail array using always-false condition', () => {
const input = ['a', 'b', 'c'];
const [pass, fail] = splitArray(input, s => s.length > 10);
expect(pass).toEqual([]);
expect(fail).toEqual(['a', 'b', 'c']);
});
});
describe('Mixed Results', () => {
it('should split even and odd numbers', () => {
const input = [1, 2, 3, 4, 5, 6];
const [even, odd] = splitArray(input, n => n % 2 === 0);
expect(even).toEqual([2, 4, 6]);
expect(odd).toEqual([1, 3, 5]);
});
it('should split positive and negative numbers', () => {
const input = [-3, -2, -1, 0, 1, 2, 3];
const [positive, negative] = splitArray(input, n => n >= 0);
expect(positive).toEqual([0, 1, 2, 3]);
expect(negative).toEqual([-3, -2, -1]);
});
it('should split strings by length', () => {
const input = ['a', 'ab', 'abc', 'abcd'];
const [long, short] = splitArray(input, s => s.length >= 3);
expect(long).toEqual(['abc', 'abcd']);
expect(short).toEqual(['a', 'ab']);
});
it('should split objects by property', () => {
interface Item {
id: number;
active: boolean;
}
const input: Item[] = [
{ id: 1, active: true },
{ id: 2, active: false },
{ id: 3, active: true },
{ id: 4, active: false },
];
const [active, inactive] = splitArray(input, item => item.active);
expect(active).toEqual([
{ id: 1, active: true },
{ id: 3, active: true },
]);
expect(inactive).toEqual([
{ id: 2, active: false },
{ id: 4, active: false },
]);
});
});
describe('Type Safety', () => {
it('should work with number arrays', () => {
const [pass, fail] = splitArray([1, 2, 3], n => n > 1);
expect(pass).toEqual([2, 3]);
expect(fail).toEqual([1]);
// Type check - should be numbers
const sum = pass[0] + pass[1];
expect(sum).toBe(5);
});
it('should work with string arrays', () => {
const [pass, fail] = splitArray(['a', 'bb', 'ccc'], s => s.length > 1);
expect(pass).toEqual(['bb', 'ccc']);
expect(fail).toEqual(['a']);
// Type check - should be strings
const concatenated = pass.join('');
expect(concatenated).toBe('bbccc');
});
it('should work with boolean arrays', () => {
const [pass, fail] = splitArray([true, false, true], b => b);
expect(pass).toEqual([true, true]);
expect(fail).toEqual([false]);
});
it('should work with generic objects', () => {
interface Person {
name: string;
age: number;
}
const people: Person[] = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 },
{ name: 'Charlie', age: 20 },
];
const [adults, minors] = splitArray(people, p => p.age >= 21);
expect(adults).toEqual([
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 },
]);
expect(minors).toEqual([{ name: 'Charlie', age: 20 }]);
});
it('should work with null and undefined', () => {
const input = [null, undefined, 1, 0, ''];
const [truthy, falsy] = splitArray(input, item => !!item);
expect(truthy).toEqual([1]);
expect(falsy).toEqual([null, undefined, 0, '']);
});
});
describe('Callback Functions', () => {
it('should support arrow function syntax', () => {
const [pass, fail] = splitArray([1, 2, 3, 4], x => x % 2 === 0);
expect(pass).toEqual([2, 4]);
expect(fail).toEqual([1, 3]);
});
it('should support regular function syntax', () => {
const [pass, fail] = splitArray([1, 2, 3, 4], function(x) {
return x % 2 === 0;
});
expect(pass).toEqual([2, 4]);
expect(fail).toEqual([1, 3]);
});
it('should support inline conditions', () => {
const input = [1, 2, 3, 4, 5];
const [greaterThan3, others] = splitArray(input, x => x > 3);
expect(greaterThan3).toEqual([4, 5]);
expect(others).toEqual([1, 2, 3]);
});
});
describe('Order Preservation', () => {
it('should maintain order within each resulting array', () => {
const input = [5, 1, 4, 2, 3];
const [greaterThan2, lessOrEqual] = splitArray(input, n => n > 2);
expect(greaterThan2).toEqual([5, 4, 3]);
expect(lessOrEqual).toEqual([1, 2]);
});
it('should preserve relative order for complex objects', () => {
interface Item {
id: number;
value: string;
}
const input: Item[] = [
{ id: 1, value: 'a' },
{ id: 2, value: 'b' },
{ id: 3, value: 'c' },
{ id: 4, value: 'd' },
];
const [evenIds, oddIds] = splitArray(input, item => item.id % 2 === 0);
expect(evenIds).toEqual([
{ id: 2, value: 'b' },
{ id: 4, value: 'd' },
]);
expect(oddIds).toEqual([
{ id: 1, value: 'a' },
{ id: 3, value: 'c' },
]);
});
});
describe('Edge Cases', () => {
it('should handle single element array (truthy)', () => {
const [pass, fail] = splitArray([1], () => true);
expect(pass).toEqual([1]);
expect(fail).toEqual([]);
});
it('should handle single element array (falsy)', () => {
const [pass, fail] = splitArray([1], () => false);
expect(pass).toEqual([]);
expect(fail).toEqual([1]);
});
it('should handle two element array', () => {
const [pass, fail] = splitArray([1, 2], n => n === 1);
expect(pass).toEqual([1]);
expect(fail).toEqual([2]);
});
it('should handle array with duplicate values', () => {
const [pass, fail] = splitArray([1, 1, 2, 2, 1, 1], n => n === 1);
expect(pass).toEqual([1, 1, 1, 1]);
expect(fail).toEqual([2, 2]);
});
it('should handle zero values', () => {
const [truthy, falsy] = splitArray([0, 1, 0, 2], Boolean);
expect(truthy).toEqual([1, 2]);
expect(falsy).toEqual([0, 0]);
});
it('should handle NaN values', () => {
const input = [1, NaN, 2, NaN, 3];
const [numbers, nans] = splitArray(input, n => !Number.isNaN(n));
expect(numbers).toEqual([1, 2, 3]);
expect(nans).toEqual([NaN, NaN]);
});
});
describe('Large Arrays', () => {
it('should handle large arrays efficiently', () => {
const largeArray = Array.from({ length: 10000 }, (_, i) => i);
const [even, odd] = splitArray(largeArray, n => n % 2 === 0);
expect(even).toHaveLength(5000);
expect(odd).toHaveLength(5000);
expect(even[0]).toBe(0);
expect(even[9999]).toBeUndefined();
expect(even[4999]).toBe(9998);
});
it('should maintain correct results for all elements in large array', () => {
const input = Array.from({ length: 1000 }, (_, i) => i);
const [multiplesOf3, others] = splitArray(input, n => n % 3 === 0);
// Verify counts
expect(multiplesOf3).toHaveLength(334); // 0, 3, 6, ..., 999
expect(others).toHaveLength(666);
// Verify all multiples of 3 are in correct array
multiplesOf3.forEach(n => {
expect(n % 3).toBe(0);
});
// Verify no multiples of 3 are in others
others.forEach(n => {
expect(n % 3).not.toBe(0);
});
});
});
describe('Real-World Use Cases', () => {
it('should separate valid from invalid emails', () => {
const emails = [
'valid@example.com',
'invalid',
'another@test.org',
'not-an-email',
'user@domain.co.uk',
];
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const [valid, invalid] = splitArray(emails, email => emailRegex.test(email));
expect(valid).toEqual([
'valid@example.com',
'another@test.org',
'user@domain.co.uk',
]);
expect(invalid).toEqual(['invalid', 'not-an-email']);
});
it('should separate completed from pending tasks', () => {
interface Task {
id: number;
title: string;
completed: boolean;
}
const tasks: Task[] = [
{ id: 1, title: 'Task 1', completed: true },
{ id: 2, title: 'Task 2', completed: false },
{ id: 3, title: 'Task 3', completed: true },
{ id: 4, title: 'Task 4', completed: false },
];
const [completed, pending] = splitArray(tasks, task => task.completed);
expect(completed).toHaveLength(2);
expect(pending).toHaveLength(2);
expect(completed.every(t => t.completed)).toBe(true);
expect(pending.every(t => !t.completed)).toBe(true);
});
it('should separate adults from minors by age', () => {
interface Person {
name: string;
age: number;
}
const people: Person[] = [
{ name: 'Alice', age: 17 },
{ name: 'Bob', age: 25 },
{ name: 'Charlie', age: 16 },
{ name: 'Diana', age: 30 },
{ name: 'Eve', age: 18 },
];
const [adults, minors] = splitArray(people, person => person.age >= 18);
expect(adults).toEqual([
{ name: 'Bob', age: 25 },
{ name: 'Diana', age: 30 },
{ name: 'Eve', age: 18 },
]);
expect(minors).toEqual([
{ name: 'Alice', age: 17 },
{ name: 'Charlie', age: 16 },
]);
});
it('should separate truthy from falsy values', () => {
const mixed = [0, 1, false, true, '', 'hello', null, undefined, [], [0]];
const [truthy, falsy] = splitArray(mixed, Boolean);
expect(truthy).toEqual([1, true, 'hello', [], [0]]);
expect(falsy).toEqual([0, false, '', null, undefined]);
});
});
});

View File

@@ -0,0 +1,319 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { throttle } from './throttle';
describe('throttle', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('Basic Functionality', () => {
it('should execute function immediately on first call', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 300);
throttled('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
});
it('should throttle subsequent calls within wait period', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 300);
throttled('first');
expect(mockFn).toHaveBeenCalledTimes(1);
// Call again within wait period - should not execute
throttled('second');
expect(mockFn).toHaveBeenCalledTimes(1);
// Advance time past wait period
vi.advanceTimersByTime(300);
// Now trailing call executes
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenLastCalledWith('second');
});
it('should allow execution after wait period expires', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled('first');
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(100);
throttled('second');
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
describe('Trailing Edge Execution', () => {
it('should execute throttled call after wait period', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 300);
throttled('first');
expect(mockFn).toHaveBeenCalledTimes(1);
throttled('second');
throttled('third');
// Still 1 because these are throttled
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(300);
// Trailing call executes
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenLastCalledWith('third');
});
it('should cancel previous trailing call on new invocation', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled('first');
vi.advanceTimersByTime(50);
throttled('second');
vi.advanceTimersByTime(30);
throttled('third');
// At this point only first call executed
expect(mockFn).toHaveBeenCalledTimes(1);
// Advance to trigger trailing call
vi.advanceTimersByTime(70);
// First call + trailing (third)
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenLastCalledWith('third');
});
});
describe('Arguments and Context', () => {
it('should pass the correct arguments from the last throttled call', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled('arg1', 'arg2');
vi.advanceTimersByTime(50);
throttled('arg3', 'arg4');
vi.advanceTimersByTime(100);
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenLastCalledWith('arg3', 'arg4');
});
it('should handle no arguments', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled();
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('should handle single argument', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled('single');
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('single');
});
it('should handle multiple arguments', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled(1, 2, 3, 'four', { five: 5 });
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(1, 2, 3, 'four', { five: 5 });
});
});
describe('Timing', () => {
it('should handle very short wait times (1ms)', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 1);
throttled('first');
vi.advanceTimersByTime(1);
throttled('second');
expect(mockFn).toHaveBeenCalledTimes(2);
});
it('should handle longer wait times (1000ms)', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 1000);
throttled('first');
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(500);
throttled('second');
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(500);
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
describe('Rapid Calls', () => {
it('should handle rapid successive calls correctly', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled('call1');
vi.advanceTimersByTime(10);
throttled('call2');
vi.advanceTimersByTime(10);
throttled('call3');
vi.advanceTimersByTime(10);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('call1');
vi.advanceTimersByTime(100);
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenLastCalledWith('call3');
});
it('should execute function at most once per wait period plus trailing', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
// Make many rapid calls
for (let i = 0; i < 10; i++) {
vi.advanceTimersByTime(5);
throttled(`call${i}`);
}
// Should execute immediately
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(100);
// Plus trailing call
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
describe('Edge Cases', () => {
it('should handle zero wait time', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 0);
throttled('first');
// With zero wait time, function may execute synchronously
// but the internal timing may still prevent immediate re-execution
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('should handle being called at exactly wait boundary', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled('first');
vi.advanceTimersByTime(100);
throttled('second');
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
describe('Return Value', () => {
it('should not return anything (void)', () => {
const mockFn = vi.fn().mockReturnValue('result');
const throttled = throttle(mockFn, 100);
const result = throttled('arg');
expect(result).toBeUndefined();
});
});
describe('Real-World Scenarios', () => {
it('should throttle scroll-like events', () => {
const mockFn = vi.fn();
const throttledScroll = throttle(mockFn, 100);
throttledScroll();
vi.advanceTimersByTime(10);
throttledScroll();
vi.advanceTimersByTime(10);
throttledScroll();
vi.advanceTimersByTime(10);
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(100);
expect(mockFn).toHaveBeenCalledTimes(2);
});
it('should throttle resize-like events', () => {
const mockFn = vi.fn();
const throttledResize = throttle(mockFn, 200);
throttledResize();
for (let i = 1; i <= 10; i++) {
vi.advanceTimersByTime(10);
throttledResize();
}
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(200);
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
describe('Comparison Characteristics', () => {
it('should execute immediately on first call', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 300);
throttled('first');
// Throttle executes immediately (unlike debounce)
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('should allow execution during continuous calls at intervals', () => {
const mockFn = vi.fn();
const waitTime = 100;
const throttled = throttle(mockFn, waitTime);
throttled('call1');
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(waitTime);
throttled('call2');
expect(mockFn).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(waitTime);
throttled('call3');
expect(mockFn).toHaveBeenCalledTimes(3);
});
});
});

View File

@@ -0,0 +1,32 @@
/**
* Throttle function execution to a maximum frequency.
*
* @param fn Function to throttle.
* @param wait Maximum time between function calls.
* @returns Throttled function.
*/
export function throttle<T extends (...args: any[]) => any>(
fn: T,
wait: number,
): (...args: Parameters<T>) => void {
let lastCall = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
const now = Date.now();
const timeSinceLastCall = now - lastCall;
if (timeSinceLastCall >= wait) {
lastCall = now;
fn(...args);
} else {
// Schedule for end of wait period
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
lastCall = Date.now();
fn(...args);
timeoutId = null;
}, wait - timeSinceLastCall);
}
};
}

View File

@@ -44,7 +44,7 @@ let {
<SliderPrimitive.Thumb <SliderPrimitive.Thumb
data-slot="slider-thumb" data-slot="slider-thumb"
index={thumb} index={thumb}
class="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" class="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-background shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/> />
{/each} {/each}
{/snippet} {/snippet}

View File

@@ -103,7 +103,7 @@ const handleSliderChange = (newValue: number) => {
<Button <Button
{...props} {...props}
variant="ghost" variant="ghost"
class="hover:bg-white/50 hover:font-bold bg-white/20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer" class="hover:bg-background-50 hover:font-bold bg-background-20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
size="icon" size="icon"
aria-label={controlLabel} aria-label={controlLabel}
> >

View File

@@ -10,7 +10,8 @@ const { Story } = defineMeta({
parameters: { parameters: {
docs: { docs: {
description: { description: {
component: 'ComboControl with input field and slider', component:
'ComboControl with input field and slider. Simplified version without increase/decrease buttons.',
}, },
story: { inline: false }, // Render stories in iframe for state isolation story: { inline: false }, // Render stories in iframe for state isolation
}, },
@@ -26,18 +27,85 @@ const { Story } = defineMeta({
control: 'text', control: 'text',
description: 'Label for the ComboControl', description: 'Label for the ComboControl',
}, },
control: {
control: 'object',
description: 'TypographyControl instance managing the value and bounds',
},
}, },
}); });
</script> </script>
<script lang="ts"> <script lang="ts">
const control = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 }); const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
const verticalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
const floatControl = createTypographyControl({ min: 0, max: 1, step: 0.01, value: 0.5 });
const atMinControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 0 });
const atMaxControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 100 });
const largeRangeControl = createTypographyControl({ min: 0, max: 1000, step: 10, value: 500 });
</script> </script>
<Story name="Horizontal" args={{ orientation: 'horizontal', control }}> <Story
<ComboControlV2 control={control} orientation="horizontal" /> name="Horizontal"
args={{
control: horizontalControl,
orientation: 'horizontal',
label: 'Size',
}}
>
<ComboControlV2 control={horizontalControl} orientation="horizontal" label="Size" />
</Story> </Story>
<Story name="Vertical" args={{ orientation: 'vertical', control, class: 'h-48' }}> <Story
<ComboControlV2 control={control} orientation="vertical" /> name="Vertical"
args={{
control: verticalControl,
orientation: 'vertical',
label: 'Size',
}}
>
<ComboControlV2 control={verticalControl} orientation="vertical" class="h-48" label="Size" />
</Story>
<Story
name="With Float Values"
args={{
control: floatControl,
orientation: 'vertical',
label: 'Opacity',
}}
>
<ComboControlV2 control={floatControl} orientation="vertical" class="h-48" label="Opacity" />
</Story>
<Story
name="At Minimum"
args={{
control: atMinControl,
orientation: 'horizontal',
label: 'Size',
}}
>
<ComboControlV2 control={atMinControl} orientation="horizontal" label="Size" />
</Story>
<Story
name="At Maximum"
args={{
control: atMaxControl,
orientation: 'horizontal',
label: 'Size',
}}
>
<ComboControlV2 control={atMaxControl} orientation="horizontal" label="Size" />
</Story>
<Story
name="Large Range"
args={{
control: largeRangeControl,
orientation: 'horizontal',
label: 'Scale',
}}
>
<ComboControlV2 control={largeRangeControl} orientation="horizontal" label="Scale" />
</Story> </Story>

View File

@@ -98,20 +98,22 @@ const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
function calculateScale(index: number): number | string { function calculateScale(index: number): number | string {
const calculate = () => const calculate = () =>
orientation === 'horizontal' orientation === 'horizontal'
? (control.min + (index * (control.max - control.min) / 4)) ? control.min + (index * (control.max - control.min)) / 4
: (control.max - (index * (control.max - control.min) / 4)); : control.max - (index * (control.max - control.min)) / 4;
return Number.isInteger(control.step) return Number.isInteger(control.step)
? Math.round(calculate()) ? Math.round(calculate())
: (calculate()).toFixed(2); : calculate().toFixed(2);
} }
</script> </script>
{#snippet ComboControl()} {#snippet ComboControl()}
<div <div
class={cn( class={cn(
'flex gap-4 sm:p-4 rounded-xl transition-all duration-300', 'flex gap-4 sm:py-4 sm:px-1 rounded-xl transition-all duration-300',
'backdrop-blur-md', '',
orientation === 'horizontal' ? 'flex-row items-end w-full' : 'flex-col items-center h-full', orientation === 'horizontal'
? 'flex-row items-end w-full'
: 'flex-col items-center h-full',
className, className,
)} )}
> >
@@ -120,7 +122,9 @@ function calculateScale(index: number): number | string {
<div <div
class={cn( class={cn(
'absolute flex justify-between', 'absolute flex justify-between',
orientation === 'horizontal' ? 'flex-row w-full -top-5 px-0.5' : 'flex-col h-full -left-5 py-0.5', orientation === 'horizontal'
? 'flex-row w-full -top-8 px-0.5'
: 'flex-col h-full -left-5 py-0.5',
)} )}
> >
{#each Array(5) as _, i} {#each Array(5) as _, i}
@@ -130,10 +134,15 @@ function calculateScale(index: number): number | string {
orientation === 'horizontal' ? 'flex-col' : 'flex-row', orientation === 'horizontal' ? 'flex-col' : 'flex-row',
)} )}
> >
<span class="font-mono text-[0.375rem] text-gray-400 tabular-nums"> <span class="font-mono text-[0.375rem] text-text-muted tabular-nums">
{calculateScale(i)} {calculateScale(i)}
</span> </span>
<div class={cn('bg-gray-300', orientation === 'horizontal' ? 'w-px h-1' : 'h-px w-1')}> <div
class={cn(
'bg-border-muted',
orientation === 'horizontal' ? 'w-px h-1' : 'h-px w-1',
)}
>
</div> </div>
</div> </div>
{/each} {/each}
@@ -146,10 +155,12 @@ function calculateScale(index: number): number | string {
min={control.min} min={control.min}
max={control.max} max={control.max}
step={control.step} step={control.step}
{label}
{orientation} {orientation}
/> />
</div> </div>
{#if !reduced}
<Input <Input
class="h-10 rounded-lg w-12 pl-1 pr-1 sm:pr-1 md:pr-1 sm:pl-1 md:pl-1 text-center" class="h-10 rounded-lg w-12 pl-1 pr-1 sm:pr-1 md:pr-1 sm:pl-1 md:pl-1 text-center"
value={inputValue} value={inputValue}
@@ -160,15 +171,6 @@ function calculateScale(index: number): number | string {
pattern={REGEXP_ONLY_DIGITS} pattern={REGEXP_ONLY_DIGITS}
variant="ghost" variant="ghost"
/> />
{#if label}
<div class="flex items-center gap-2 opacity-70">
<div class="w-1 h-1 rounded-full bg-gray-900"></div>
<div class="w-px h-2 bg-gray-400/50"></div>
<span class="font-mono text-[8px] uppercase tracking-[0.2em] text-gray-500 font-medium">
{label}
</span>
</div>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
@@ -195,7 +197,7 @@ function calculateScale(index: number): number | string {
<Button <Button
{...props} {...props}
variant="ghost" variant="ghost"
class="hover:bg-white/50 hover:font-bold bg-white/20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer" class="hover:bg-background-50 hover:font-bold bg-background-20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
size="icon" size="icon"
aria-label={controlLabel} aria-label={controlLabel}
> >

View File

@@ -3,6 +3,7 @@
Animated wrapper for content that can be expanded and collapsed. Animated wrapper for content that can be expanded and collapsed.
--> -->
<script lang="ts"> <script lang="ts">
import { debounce } from '$shared/lib/utils';
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 { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
@@ -38,6 +39,10 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
* Optional badge to render * Optional badge to render
*/ */
badge?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>; badge?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
/**
* Callback for when the element's size changes
*/
onResize?: (rect: DOMRectReadOnly) => void;
/** /**
* Rotation animation direction * Rotation animation direction
* @default 'clockwise' * @default 'clockwise'
@@ -56,6 +61,7 @@ let {
visibleContent, visibleContent,
hiddenContent, hiddenContent,
badge, badge,
onResize,
rotation = 'clockwise', rotation = 'clockwise',
class: className = '', class: className = '',
containerClassName = '', containerClassName = '',
@@ -64,7 +70,7 @@ let {
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null); let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
export const xSpring = new Spring(0, { const xSpring = new Spring(0, {
stiffness: 0.14, // Lower is slower stiffness: 0.14, // Lower is slower
damping: 0.5, // Settle damping: 0.5, // Settle
}); });
@@ -79,7 +85,7 @@ const scaleSpring = new Spring(1, {
damping: 0.65, damping: 0.65,
}); });
export const rotateSpring = new Spring(0, { const rotateSpring = new Spring(0, {
stiffness: 0.12, stiffness: 0.12,
damping: 0.55, damping: 0.55,
}); });
@@ -107,6 +113,9 @@ function handleKeyDown(e: KeyboardEvent) {
} }
} }
// Create debounced recize callback
const debouncedResize = debounce((entry: ResizeObserverEntry) => onResize?.(entry.contentRect), 50);
// Elevation and scale on activation // Elevation and scale on activation
$effect(() => { $effect(() => {
if (expanded && !disabled) { if (expanded && !disabled) {
@@ -149,6 +158,21 @@ $effect(() => {
expanded = false; expanded = false;
} }
}); });
// Use an effect to watch the element's actual physical size
$effect(() => {
if (!element) return;
const observer = new ResizeObserver(entries => {
const entry = entries[0];
if (entry) {
debouncedResize(entry);
}
});
observer.observe(element);
return () => observer.disconnect();
});
</script> </script>
<div <div
@@ -158,7 +182,7 @@ $effect(() => {
role="button" role="button"
tabindex={0} tabindex={0}
class={cn( class={cn(
'will-change-transform duration-300', 'will-change-[transform, width, height] duration-300',
disabled ? 'pointer-events-none' : 'pointer-events-auto', disabled ? 'pointer-events-none' : 'pointer-events-auto',
className, className,
)} )}
@@ -175,8 +199,8 @@ $effect(() => {
class={cn( class={cn(
'relative p-0.5 sm:p-2 rounded-lg sm:rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg', 'relative p-0.5 sm:p-2 rounded-lg sm:rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
expanded expanded
? 'bg-white/5 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]' ? 'bg-background-20 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
: ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]', : 'bg-background-40 border-background-40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
disabled && 'opacity-80 grayscale-[0.2]', disabled && 'opacity-80 grayscale-[0.2]',
containerClassName, containerClassName,
)} )}

View File

@@ -16,15 +16,22 @@ interface Props {
} }
const { children, class: className, render }: Props = $props(); const { children, class: className, render }: Props = $props();
const baseClasses = 'font-mono text-[0.5625rem] sm:text-[0.625rem] uppercase tracking-[0.2em] text-gray-500 opacity-60';
const combinedClasses = cn(baseClasses, className);
</script> </script>
{#if render} {#if render}
{@render render({ class: combinedClasses })} {@render render({
class: cn(
'font-mono text-[0.5625rem] sm:text-[0.625rem] lowercase tracking-[0.2em] text-text-soft',
className,
),
})}
{:else if children} {:else if children}
<span class={combinedClasses}> <span
class={cn(
'font-mono text-[0.5625rem] sm:text-[0.625rem] lowercase tracking-[0.2em] text-text-soft',
className,
)}
>
{@render children()} {@render children()}
</span> </span>
{/if} {/if}

View File

@@ -0,0 +1,101 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import IconButton from './IconButton.svelte';
const { Story } = defineMeta({
title: 'Shared/IconButton',
component: IconButton,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Icon button with rotation animation on click. Features clockwise/counterclockwise rotation options and icon snippet support for flexible icon rendering.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
rotation: {
control: 'select',
options: ['clockwise', 'counterclockwise'],
description: 'Direction of rotation animation on click',
},
icon: {
control: 'object',
description: 'Icon snippet to render (required)',
},
disabled: {
control: 'boolean',
description: 'Disable the button',
},
onclick: {
action: 'clicked',
description: 'Click handler',
},
},
});
</script>
<script lang="ts">
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus';
import SettingsIcon from '@lucide/svelte/icons/settings';
import XIcon from '@lucide/svelte/icons/x';
</script>
{#snippet chevronRightIcon({ className }: { className: string })}
<ChevronRight class={className} />
{/snippet}
{#snippet chevronLeftIcon({ className }: { className: string })}
<ChevronLeft class={className} />
{/snippet}
{#snippet plusIcon({ className }: { className: string })}
<PlusIcon class={className} />
{/snippet}
{#snippet minusIcon({ className }: { className: string })}
<MinusIcon class={className} />
{/snippet}
{#snippet settingsIcon({ className }: { className: string })}
<SettingsIcon class={className} />
{/snippet}
{#snippet xIcon({ className }: { className: string })}
<XIcon class={className} />
{/snippet}
<Story
name="Default"
args={{
icon: chevronRightIcon,
}}
>
<IconButton onclick={() => console.log('Default clicked')}>
{#snippet icon({ className })}
<ChevronRight class={className} />
{/snippet}
</IconButton>
</Story>
<Story
name="Disabled"
args={{
icon: chevronRightIcon,
disabled: true,
}}
>
<div class="flex flex-col gap-4 items-center">
<IconButton disabled>
{#snippet icon({ className })}
<ChevronRight class={className} />
{/snippet}
</IconButton>
</div>
</Story>

View File

@@ -29,7 +29,7 @@ let { rotation = 'clockwise', icon, ...rest }: Props = $props();
variant="ghost" variant="ghost"
class=" class="
group relative border-none size-9 group relative border-none size-9
bg-white/20 hover:bg-white/60 bg-background-20 hover:bg-background-60
backdrop-blur-3xl backdrop-blur-3xl
transition-all duration-200 ease-out transition-all duration-200 ease-out
will-change-transform will-change-transform
@@ -41,10 +41,12 @@ let { rotation = 'clockwise', icon, ...rest }: Props = $props();
size="icon" size="icon"
{...rest} {...rest}
> >
{@render icon({ {@render icon?.({
className: cn( className: cn(
'size-4 transition-all duration-200 stroke-[1.5] stroke-gray-500 group-hover:stroke-gray-900 group-hover:scale-110 group-hover:stroke-3 group-active:scale-90 group-disabled:stroke-transparent', 'size-4 transition-all duration-200 stroke-[1.5] stroke-text-muted group-hover:stroke-foreground group-hover:scale-110 group-hover:stroke-2 group-active:scale-90 group-disabled:stroke-transparent',
rotation === 'clockwise' ? 'group-active:rotate-6' : 'group-active:-rotate-6', rotation === 'clockwise'
? 'group-active:rotate-6'
: 'group-active:-rotate-6',
), ),
})} })}
</Button> </Button>

View File

@@ -8,10 +8,11 @@ const { Story } = defineMeta({
parameters: { parameters: {
docs: { docs: {
description: { description: {
component: 'Styles Input component', component: 'Styled input component with size and variant options',
}, },
story: { inline: false }, // Render stories in iframe for state isolation story: { inline: false }, // Render stories in iframe for state isolation
}, },
layout: 'centered',
}, },
argTypes: { argTypes: {
placeholder: { placeholder: {
@@ -22,21 +23,76 @@ const { Story } = defineMeta({
control: 'text', control: 'text',
description: "input's value", description: "input's value",
}, },
variant: {
control: 'select',
options: ['default', 'ghost'],
description: 'Visual style variant',
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
description: 'Size variant',
},
}, },
}); });
</script> </script>
<script lang="ts"> <script lang="ts">
let value = $state('Initial value'); let valueDefault = $state('Initial value');
let valueSm = $state('');
let valueMd = $state('');
let valueLg = $state('');
let valueGhostSm = $state('');
let valueGhostMd = $state('');
let valueGhostLg = $state('');
const placeholder = 'Enter text'; const placeholder = 'Enter text';
</script> </script>
<Story <!-- Default Story -->
name="Default" <Story name="Default" args={{ placeholder }}>
args={{ <Input bind:value={valueDefault} {placeholder} />
placeholder, </Story>
value,
}} <!-- Size Variants -->
> <Story name="Small" args={{ placeholder }}>
<Input value={value} placeholder={placeholder} /> <Input bind:value={valueSm} {placeholder} size="sm" />
</Story>
<Story name="Medium" args={{ placeholder }}>
<Input bind:value={valueMd} {placeholder} size="md" />
</Story>
<Story name="Large" args={{ placeholder }}>
<Input bind:value={valueLg} {placeholder} size="lg" />
</Story>
<!-- Ghost Variant with Sizes -->
<Story name="Ghost Small" args={{ placeholder }}>
<Input bind:value={valueGhostSm} {placeholder} variant="ghost" size="sm" />
</Story>
<Story name="Ghost Medium" args={{ placeholder }}>
<Input bind:value={valueGhostMd} {placeholder} variant="ghost" size="md" />
</Story>
<Story name="Ghost Large" args={{ placeholder }}>
<Input bind:value={valueGhostLg} {placeholder} variant="ghost" size="lg" />
</Story>
<!-- Size Comparison -->
<Story name="All Sizes" tags={['!autodocs']}>
<div class="flex flex-col gap-4 w-full max-w-md p-8">
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-text-muted">Small</span>
<Input placeholder="Small input" size="sm" />
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-text-muted">Medium</span>
<Input placeholder="Medium input" size="md" />
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-text-muted">Large</span>
<Input placeholder="Large input" size="lg" />
</div>
</div>
</Story> </Story>

View File

@@ -2,60 +2,89 @@
Component: Input Component: Input
Provides styled input component with all the shadcn input props Provides styled input component with all the shadcn input props
--> -->
<script lang="ts"> <script lang="ts" module>
import { Input } from '$shared/shadcn/ui/input'; import { Input as BaseInput } from '$shared/shadcn/ui/input';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { ComponentProps } from 'svelte'; import {
type VariantProps,
tv,
} from 'tailwind-variants';
type Props = ComponentProps<typeof Input> & { export const inputVariants = tv({
base: [
'w-full backdrop-blur-md border font-medium transition-all duration-200',
'focus-visible:border-border-soft focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-muted/30 focus-visible:bg-background-95',
'hover:bg-background-95 hover:border-border-soft',
'text-foreground placeholder:text-text-muted placeholder:font-mono placeholder:tracking-wide',
],
variants: {
variant: {
default: 'bg-background-80 border-border-muted shadow-[0_1px_3px_rgba(0,0,0,0.04)]',
ghost: 'bg-transparent border-transparent shadow-none',
},
size: {
sm: [
'h-9 sm:h-10 md:h-11 rounded-lg',
'px-3 sm:px-3.5 md:px-4',
'text-xs sm:text-sm md:text-base',
'placeholder:text-[0.75rem] sm:placeholder:text-sm md:placeholder:text-base',
],
md: [
'h-10 sm:h-12 md:h-14 rounded-xl',
'px-3.5 sm:px-4 md:px-5',
'text-sm sm:text-base md:text-lg',
'placeholder:text-[0.75rem] sm:placeholder:text-sm md:placeholder:text-base',
],
lg: [
'h-12 sm:h-14 md:h-16 rounded-2xl',
'px-4 sm:px-5 md:px-6',
'text-sm sm:text-base md:text-lg',
'placeholder:text-[0.75rem] sm:placeholder:text-sm md:placeholder:text-base',
],
},
},
defaultVariants: {
variant: 'default',
size: 'lg',
},
});
type InputVariant = VariantProps<typeof inputVariants>['variant'];
type InputSize = VariantProps<typeof inputVariants>['size'];
export type InputProps = {
/** /**
* Current search value (bindable) * Current search value (bindable)
*/ */
value: string; value?: string;
/** /**
* Additional CSS classes for the container * Additional CSS classes for the container
*/ */
class?: string; class?: string;
/**
variant?: 'default' | 'ghost'; * Visual style variant
*/
variant?: InputVariant;
/**
* Size variant
*/
size?: InputSize;
[key: string]: any;
}; };
</script>
<script lang="ts">
let { let {
value = $bindable(''), value = $bindable(''),
class: className, class: className,
variant = 'default', variant = 'default',
size = 'lg',
...rest ...rest
}: Props = $props(); }: InputProps = $props();
const isGhost = $derived(variant === 'ghost');
</script> </script>
<Input <BaseInput
bind:value={value} bind:value
class={cn( class={cn(inputVariants({ variant, size }), className)}
'h-12 sm:h-14 md:h-16 w-full text-sm sm:text-base',
'backdrop-blur-md',
isGhost ? 'bg-transparent' : 'bg-white/80',
'border border-gray-300/50',
isGhost ? 'border-transparent' : 'border-gray-300/50',
isGhost ? 'shadow-none' : 'shadow-[0_1px_3px_rgba(0,0,0,0.04)]',
'focus-visible:border-gray-400/60',
'focus-visible:outline-none',
'focus-visible:ring-1',
'focus-visible:ring-gray-400/30',
'focus-visible:bg-white/90',
'hover:bg-white/90',
'hover:border-gray-400/60',
'text-gray-900',
'placeholder:text-gray-400',
'placeholder:font-mono',
'placeholder:text-xs sm:placeholder:text-sm',
'placeholder:tracking-wide',
'pl-4 sm:pl-6 pr-4 sm:pr-6',
'rounded-xl',
'transition-all duration-200',
'font-medium',
className,
)}
{...rest} {...rest}
/> />

View File

@@ -0,0 +1,13 @@
import type { ComponentProps } from 'svelte';
import Input from './Input.svelte';
type InputProps = ComponentProps<typeof Input>;
type InputSize = InputProps['size'];
type InputVariant = InputProps['variant'];
export {
Input,
type InputProps,
type InputSize,
type InputVariant,
};

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props {
text?: string;
align?: 'left' | 'right' | 'center';
size?: 'sm' | 'md' | 'lg';
onlyText?: boolean;
class?: string;
}
const {
text,
align = 'left',
size = 'md',
onlyText = false,
class: className,
}: Props = $props();
</script>
<div
class={cn(
'grid grid-rows-1 gap-2 items-center w-auto',
align === 'left' && 'grid-cols-[max-content_1fr]',
align === 'center' && 'grid-cols-[1fr_max-content_1fr]',
align === 'right' && 'grig-cols-[1fr_max-content]',
className,
)}
>
{#if align !== 'left'}
<div class={cn('h-px w-full bg-gray-400/50', onlyText && 'bg-transparent')}></div>
{/if}
<div
class={cn(
'text-gray-400 uppercase',
size === 'sm' && 'text-[0.5rem]',
size === 'md' && 'text-[0.625rem]',
size === 'lg' && 'text-[0.75rem]',
)}
>
{text}
</div>
{#if align !== 'right'}
<div class={cn('h-px w-full bg-gray-400/50', onlyText && 'bg-transparent')}></div>
{/if}
</div>

View File

@@ -31,7 +31,7 @@ let { size = 20, class: className = '', message = 'analyzing_data' }: Props = $p
out:fade={{ duration: 300 }} out:fade={{ duration: 300 }}
> >
<div style:width="{size}px" style:height="{size}px"> <div style:width="{size}px" style:height="{size}px">
<svg class="stroke-gray-900 stroke-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="stroke-foreground stroke-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(12, 12)"> <g transform="translate(12, 12)">
<!-- Four corner brackets rotating --> <!-- Four corner brackets rotating -->
<g> <g>
@@ -68,10 +68,10 @@ let { size = 20, class: className = '', message = 'analyzing_data' }: Props = $p
</svg> </svg>
</div> </div>
<!-- Divider --> <!-- Divider -->
<div class="w-px h-3 bg-gray-400/50"></div> <div class="w-px h-3 bg-text-muted/50"></div>
<!-- Message --> <!-- Message -->
<span class="font-mono text-[10px] uppercase tracking-[0.2em] text-gray-600 font-medium"> <span class="font-mono text-[10px] uppercase tracking-[0.2em] text-text-subtle font-medium">
{message} {message}
</span> </span>
</div> </div>

View File

@@ -0,0 +1,83 @@
<!--
Component: PerspectivePlan
Wrapper that applies perspective transformations based on back/front state.
Style computation moved from manager to component for simpler architecture.
-->
<script lang="ts">
import type { PerspectiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { type Snippet } from 'svelte';
interface Props {
/**
* Perspective manager
*/
manager: PerspectiveManager;
/**
* Additional classes
*/
class?: string;
/**
* Children
*/
children: Snippet<[{ className?: string }]>;
/**
* Constrain plan to a horizontal region
* 'left' | 'right' | 'full' (default)
*/
region?: 'left' | 'right' | 'full';
/**
* Width percentage when using left/right region (default 50)
*/
regionWidth?: number;
}
let { manager, children, class: className = '', region = 'full', regionWidth = 50 }: Props = $props();
const config = $derived(manager.getConfig());
// Computed style based on spring position (0 = front, 1 = back)
const style = $derived.by(() => {
const distance = manager.spring.current;
const baseX = config.horizontalOffset ?? 0;
// Back state: blurred, scaled down, pushed back
// Front state: fully visible, in focus
const scale = 1 - distance * (config.scaleStep ?? 0.5);
const blur = distance * (config.blurStep ?? 4);
const opacity = Math.max(0, 1 - distance * (config.opacityStep ?? 0.5));
const zIndex = 10;
const pointerEvents = distance < 0.4 ? 'auto' : ('none' as const);
return {
transform: `translate3d(${baseX}px, 0px, ${-distance * (config.depthStep ?? 100)}px) scale(${scale})`,
filter: `blur(${blur}px)`,
opacity,
pointerEvents,
zIndex,
};
});
// Calculate horizontal constraints based on region
const regionStyleStr = $derived(() => {
if (region === 'full') return '';
const side = region === 'left' ? 'left' : 'right';
return `position: absolute; ${side}: 0; width: ${regionWidth}%; top: 0; bottom: 0;`;
});
// Visibility: front = visible, back = hidden
const isVisible = $derived(manager.isFront);
</script>
<div
class={cn('will-change-transform', className)}
style:transform-style="preserve-3d"
style:transform={style?.transform}
style:filter={style?.filter}
style:opacity={style?.opacity}
style:pointer-events={style?.pointerEvents}
style:z-index={style?.zIndex}
style:custom={regionStyleStr()}
>
{@render children({ className: isVisible ? 'visible' : 'hidden' })}
</div>

View File

@@ -35,5 +35,10 @@ let {
<div class="absolute left-4 sm:left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10"> <div class="absolute left-4 sm:left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
<AsteriskIcon class="size-3 sm:size-4 stroke-gray-400 stroke-[1.5]" /> <AsteriskIcon class="size-3 sm:size-4 stroke-gray-400 stroke-[1.5]" />
</div> </div>
<Input {id} class={cn('pl-11 sm:pl-14', className)} bind:value={value} placeholder={placeholder} /> <Input
{id}
class={cn('pl-11 sm:pl-14 md:pl-14 lg:pl-14', className)}
bind:value
{placeholder}
/>
</div> </div>

View File

@@ -0,0 +1,475 @@
<script module>
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { defineMeta } from '@storybook/addon-svelte-csf';
import Section from './Section.svelte';
const { Story } = defineMeta({
title: 'Shared/Section',
component: Section,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Page layout component with optional sticky title feature. Provides a container for page widgets with title, icon, description snippets. The title can remain fixed while scrolling through content.',
},
story: { inline: false },
},
layout: 'fullscreen',
},
argTypes: {
id: {
control: 'text',
description: 'ID of the section',
},
index: {
control: 'number',
description: 'Index of the section (used for default description)',
},
stickyTitle: {
control: 'boolean',
description: 'When true, title stays fixed while scrolling through content',
},
stickyOffset: {
control: 'text',
description: 'Top offset for sticky title (e.g. "60px")',
},
class: {
control: 'text',
description: 'Additional CSS classes',
},
onTitleStatusChange: {
action: 'titleStatusChanged',
description: 'Callback when title visibility status changes',
},
},
});
</script>
<script lang="ts">
import ListIcon from '@lucide/svelte/icons/list';
import SearchIcon from '@lucide/svelte/icons/search';
import SettingsIcon from '@lucide/svelte/icons/settings';
</script>
{#snippet searchIcon({ className }: { className?: string })}
<SearchIcon class={className} />
{/snippet}
{#snippet welcomeTitle({ className }: { className?: string })}
<h2 class={className}>Welcome</h2>
{/snippet}
{#snippet welcomeContent({ className }: { className?: string })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
This is the default section layout with a title and content area. The section uses a 2-column grid layout
with the title on the left and content on the right.
</p>
</div>
{/snippet}
{#snippet stickyTitle({ className }: { className?: string })}
<h2 class={className}>Sticky Title</h2>
{/snippet}
{#snippet stickyContent({ className }: { className?: string })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted mb-4">
This section has a sticky title that stays fixed while you scroll through the content. Try scrolling down to
see the effect.
</p>
<div class="space-y-4">
<p class="text-text-muted">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua.
</p>
<p class="text-text-muted">
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</p>
<p class="text-text-muted">
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
</p>
<p class="text-text-muted">
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollim anim id est
laborum.
</p>
<p class="text-text-muted">
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.
</p>
</div>
</div>
{/snippet}
{#snippet searchFontsTitle({ className }: { className?: string })}
<h2 class={className}>Search Fonts</h2>
{/snippet}
{#snippet searchFontsDescription({ className }: { className?: string })}
<span class={className}>Find your perfect typeface</span>
{/snippet}
{#snippet searchFontsContent({ className }: { className?: string })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
Use the search bar to find fonts by name, or use the filters to browse by category, subset, or provider.
</p>
</div>
{/snippet}
{#snippet longContentTitle({ className }: { className?: string })}
<h2 class={className}>Long Content</h2>
{/snippet}
{#snippet longContent({ className }: { className?: string })}
<div class={cn(className, 'min-w-128')}>
<div class="space-y-6">
<p class="text-lg text-text-muted">
This section demonstrates how the sticky title behaves with longer content. As you scroll through this
content, the title remains visible at the top of the viewport.
</p>
<div class="h-64 bg-background-40 rounded-lg flex items-center justify-center">
<span class="text-text-muted">Content block 1</span>
</div>
<p class="text-text-muted">
The sticky position is achieved using CSS position: sticky with a configurable top offset. This is
useful for long sections where you want to maintain context while scrolling.
</p>
<div class="h-64 bg-background-40 rounded-lg flex items-center justify-center">
<span class="text-text-muted">Content block 2</span>
</div>
<p class="text-text-muted">
The Intersection Observer API is used to detect when the section title scrolls out of view, triggering
the optional onTitleStatusChange callback.
</p>
<div class="h-64 bg-background-40 rounded-lg flex items-center justify-center">
<span class="text-text-muted">Content block 3</span>
</div>
</div>
</div>
{/snippet}
{#snippet minimalTitle({ className }: { className?: string })}
<h2 class={className}>Minimal Section</h2>
{/snippet}
{#snippet minimalContent({ className }: { className?: string })}
<div class={cn(className, 'min-w-128')}>
<p class="text-text-muted">
A minimal section without index, icon, or description. Just the essentials.
</p>
</div>
{/snippet}
{#snippet customTitle({ className }: { className?: string })}
<h2 class={className}>Custom Content</h2>
{/snippet}
{#snippet customDescription({ className }: { className?: string })}
<span class={className}>With interactive elements</span>
{/snippet}
{#snippet customContent({ className }: { className?: string })}
<div class={cn(className, 'min-w-128')}>
<div class="grid grid-cols-2 gap-4">
<div class="p-4 bg-background-40 rounded-lg">
<h3 class="font-semibold mb-2">Card 1</h3>
<p class="text-sm text-text-muted">Some content here</p>
</div>
<div class="p-4 bg-background-40 rounded-lg">
<h3 class="font-semibold mb-2">Card 2</h3>
<p class="text-sm text-text-muted">More content here</p>
</div>
</div>
</div>
{/snippet}
<Story
name="Default"
args={{
title: welcomeTitle,
content: welcomeContent,
}}
>
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section index={1}>
{#snippet title({ className })}
<h2 class={className}>Welcome</h2>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
This is the default section layout with a title and content area. The section uses a 2-column
grid layout with the title on the left and content on the right.
</p>
</div>
{/snippet}
</Section>
</div>
</Story>
<Story
name="With Sticky Title"
args={{
title: stickyTitle,
content: stickyContent,
}}
>
<div class="h-[200vh]">
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section
id="sticky-section"
index={1}
stickyTitle={true}
stickyOffset="20px"
>
{#snippet title({ className })}
<h2 class={className}>Sticky Title</h2>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted mb-4">
This section has a sticky title that stays fixed while you scroll through the content. Try
scrolling down to see the effect.
</p>
<div class="space-y-4">
<p class="text-text-muted">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua.
</p>
<p class="text-text-muted">
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat.
</p>
<p class="text-text-muted">
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur.
</p>
<p class="text-text-muted">
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt
mollim anim id est laborum.
</p>
<p class="text-text-muted">
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque
laudantium.
</p>
</div>
</div>
{/snippet}
</Section>
</div>
</div>
</Story>
<Story
name="With Icon and Description"
args={{
icon: searchIcon,
title: searchFontsTitle,
description: searchFontsDescription,
content: searchFontsContent,
}}
>
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section index={1}>
{#snippet title({ className })}
<h2 class={className}>Search Fonts</h2>
{/snippet}
{#snippet icon({ className })}
<SearchIcon class={className} />
{/snippet}
{#snippet description({ className })}
<span class={className}>Find your perfect typeface</span>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
Use the search bar to find fonts by name, or use the filters to browse by category, subset, or
provider.
</p>
</div>
{/snippet}
</Section>
</div>
</Story>
<Story name="Multiple Sections" tags={['!autodocs']}>
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section
id="section-1"
index={1}
stickyTitle={true}
>
{#snippet title({ className })}
<h2 class={className}>Typography</h2>
{/snippet}
{#snippet icon({ className })}
<SettingsIcon class={className} />
{/snippet}
{#snippet description({ className })}
<span class={className}>Adjust text appearance</span>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
Control the size, weight, and line height of your text. These settings apply across the
comparison view.
</p>
</div>
{/snippet}
</Section>
<Section
id="section-2"
index={2}
stickyTitle={true}
>
{#snippet title({ className })}
<h2 class={className}>Font Search</h2>
{/snippet}
{#snippet icon({ className })}
<SearchIcon class={className} />
{/snippet}
{#snippet description({ className })}
<span class={className}>Browse available typefaces</span>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
Search through our collection of fonts from Google Fonts and Fontshare. Use filters to narrow
down your selection.
</p>
</div>
{/snippet}
</Section>
<Section
id="section-3"
index={3}
stickyTitle={true}
>
{#snippet title({ className })}
<h2 class={className}>Sample List</h2>
{/snippet}
{#snippet icon({ className })}
<ListIcon class={className} />
{/snippet}
{#snippet description({ className })}
<span class={className}>Preview font samples</span>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-lg text-text-muted">
Browse through font samples with your custom text. The list is virtualized for optimal
performance.
</p>
</div>
{/snippet}
</Section>
</div>
</Story>
<Story
name="With Long Content"
args={{
title: longContentTitle,
content: longContent,
}}
>
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section
index={1}
stickyTitle={true}
stickyOffset="0px"
>
{#snippet title({ className })}
<h2 class={className}>Long Content</h2>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<div class="space-y-6">
<p class="text-lg text-text-muted">
This section demonstrates how the sticky title behaves with longer content. As you scroll
through this content, the title remains visible at the top of the viewport.
</p>
<div class="h-64 bg-background-40 rounded-lg flex items-center justify-center">
<span class="text-text-muted">Content block 1</span>
</div>
<p class="text-text-muted">
The sticky position is achieved using CSS position: sticky with a configurable top offset.
This is useful for long sections where you want to maintain context while scrolling.
</p>
<div class="h-64 bg-background-40 rounded-lg flex items-center justify-center">
<span class="text-text-muted">Content block 2</span>
</div>
<p class="text-text-muted">
The Intersection Observer API is used to detect when the section title scrolls out of view,
triggering the optional onTitleStatusChange callback.
</p>
<div class="h-64 bg-background-40 rounded-lg flex items-center justify-center">
<span class="text-text-muted">Content block 3</span>
</div>
</div>
</div>
{/snippet}
</Section>
</div>
</Story>
<Story
name="Minimal"
args={{
title: minimalTitle,
content: minimalContent,
}}
>
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section>
{#snippet title({ className })}
<h2 class={className}>Minimal Section</h2>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<p class="text-text-muted">
A minimal section without index, icon, or description. Just the essentials.
</p>
</div>
{/snippet}
</Section>
</div>
</Story>
<Story
name="Custom Content"
args={{
title: customTitle,
description: customDescription,
content: customContent,
}}
>
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12 p-8 max-w-6xl mx-auto">
<Section index={42}>
{#snippet title({ className })}
<h2 class={className}>Custom Content</h2>
{/snippet}
{#snippet description({ className })}
<span class={className}>With interactive elements</span>
{/snippet}
{#snippet content({ className })}
<div class={cn(className, 'min-w-128')}>
<div class="grid grid-cols-2 gap-4">
<div class="p-4 bg-background-40 rounded-lg">
<h3 class="font-semibold mb-2">Card 1</h3>
<p class="text-sm text-text-muted">Some content here</p>
</div>
<div class="p-4 bg-background-40 rounded-lg">
<h3 class="font-semibold mb-2">Card 2</h3>
<p class="text-sm text-text-muted">More content here</p>
</div>
</div>
</div>
{/snippet}
</Section>
</div>
</Story>

View File

@@ -14,6 +14,10 @@ import {
import { Footnote } from '..'; import { Footnote } from '..';
interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> { interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
/**
* ID of the section
*/
id?: string;
/** /**
* Additional CSS classes to apply to the section container. * Additional CSS classes to apply to the section container.
*/ */
@@ -40,19 +44,52 @@ interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
* @param index - Index of the section * @param index - Index of the section
* @param isPast - Whether the section is past the current scroll position * @param isPast - Whether the section is past the current scroll position
* @param title - Snippet for a title itself * @param title - Snippet for a title itself
* @param id - ID of the section
* @returns Cleanup callback * @returns Cleanup callback
*/ */
onTitleStatusChange?: (index: number, isPast: boolean, title?: Snippet<[{ className?: string }]>) => () => void; onTitleStatusChange?: (
index: number,
isPast: boolean,
title?: Snippet<[{ className?: string }]>,
id?: string,
) => () => void;
/** /**
* Snippet for the section content * Snippet for the section content
*/ */
children?: Snippet; content?: Snippet<[{ className?: string }]>;
/**
* When true, the title stays fixed in view while
* scrolling through the section content.
*/
stickyTitle?: boolean;
/**
* Top offset for sticky title (e.g. header height).
* @default '0px'
*/
stickyOffset?: string;
} }
const { class: className, title, icon, description, index = 0, onTitleStatusChange, children }: Props = $props(); const {
class: className,
title,
icon,
description,
index = 0,
onTitleStatusChange,
id,
content,
stickyTitle = false,
stickyOffset = '0px',
}: Props = $props();
let titleContainer = $state<HTMLElement>(); let titleContainer = $state<HTMLElement>();
const flyParams: FlyParams = { y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 }; const flyParams: FlyParams = {
y: 0,
x: -50,
duration: 300,
easing: cubicOut,
opacity: 0.2,
};
// Track if the user has actually scrolled away from view // Track if the user has actually scrolled away from view
let isScrolledPast = $state(false); let isScrolledPast = $state(false);
@@ -62,18 +99,21 @@ $effect(() => {
return; return;
} }
let cleanup: ((index: number) => void) | undefined; let cleanup: ((index: number) => void) | undefined;
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(
entries => {
const entry = entries[0]; const entry = entries[0];
const isPast = !entry.isIntersecting && entry.boundingClientRect.top < 0; const isPast = !entry.isIntersecting && entry.boundingClientRect.top < 0;
if (isPast !== isScrolledPast) { if (isPast !== isScrolledPast) {
isScrolledPast = isPast; isScrolledPast = isPast;
cleanup = onTitleStatusChange?.(index, isPast, title); cleanup = onTitleStatusChange?.(index, isPast, title, id);
} }
}, { },
{
// Set threshold to 0 to trigger exactly when the last pixel leaves // Set threshold to 0 to trigger exactly when the last pixel leaves
threshold: 0, threshold: 0,
}); },
);
observer.observe(titleContainer); observer.observe(titleContainer);
return () => { return () => {
@@ -84,19 +124,32 @@ $effect(() => {
</script> </script>
<section <section
{id}
class={cn( class={cn(
'flex flex-col', 'col-span-2 grid grid-cols-subgrid',
stickyTitle ? 'gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12' : 'grid-rows-[max-content_1fr]',
className, className,
)} )}
in:fly={flyParams} in:fly={flyParams}
out:fly={flyParams} out:fly={flyParams}
> >
<div class="flex flex-col gap-2 sm:gap-3" bind:this={titleContainer}> <div
bind:this={titleContainer}
class={cn(
'flex flex-col gap-2 sm:gap-3',
stickyTitle && 'self-start',
)}
style:position={stickyTitle ? 'sticky' : undefined}
style:top={stickyTitle ? stickyOffset : undefined}
>
<div class="flex items-center gap-2 sm:gap-3"> <div class="flex items-center gap-2 sm:gap-3">
{#if icon} {#if icon}
{@render icon({ className: 'size-3 sm:size-4 stroke-gray-900 stroke-1 opacity-60' })} {@render icon({
<div class="w-px h-2.5 sm:h-3 bg-gray-300/60"></div> className: 'size-3 sm:size-4 stroke-foreground stroke-1 opacity-60',
})}
<div class="w-px h-2.5 sm:h-3 bg-border-subtle"></div>
{/if} {/if}
{#if description} {#if description}
<Footnote> <Footnote>
{#snippet render({ class: className })} {#snippet render({ class: className })}
@@ -113,10 +166,14 @@ $effect(() => {
{#if title} {#if title}
{@render title({ {@render title({
className: className:
'text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tighter text-gray-900 leading-[0.9]', 'text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tighter text-foreground leading-[0.9]',
})} })}
{/if} {/if}
</div> </div>
{@render children?.()} {@render content?.({
className: stickyTitle
? 'row-start-2 col-start-2'
: 'row-start-2 col-start-2',
})}
</section> </section>

View File

@@ -0,0 +1,99 @@
<!--
Component: SidebarMenu
Slides out from the right, closes on click outside
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing';
import {
fade,
slide,
} from 'svelte/transition';
interface Props {
/**
* Children to render conditionally
*/
children?: Snippet;
/**
* Action (always visible) to render
*/
action?: Snippet;
/**
* Wrapper reference to bind
*/
wrapper?: HTMLElement | null;
/**
* Class to add to the wrapper
*/
class?: string;
/**
* Bindable visibility flag
*/
visible?: boolean;
/**
* Handler for click outside
*/
onClickOutside?: () => void;
}
let {
children,
action,
wrapper = $bindable<HTMLElement | null>(null),
class: className,
visible = $bindable(false),
onClickOutside,
}: Props = $props();
/**
* Closes menu on click outside
*/
function handleClick(event: MouseEvent) {
if (!wrapper || !visible) {
return;
}
if (!wrapper.contains(event.target as Node)) {
visible = false;
onClickOutside?.();
}
}
</script>
<svelte:window on:click={handleClick} />
<div
class={cn(
'transition-all duration-300 delay-200 cubic-bezier-out',
className,
)}
bind:this={wrapper}
>
{@render action?.()}
{#if visible}
<div
class="relative z-20 h-full w-auto flex flex-col"
in:fade={{ duration: 300, delay: 400, easing: cubicOut }}
out:fade={{ duration: 150, easing: cubicOut }}
>
{@render children?.()}
</div>
<!-- Background Gradient -->
<div
class="
absolute inset-0 z-10 h-full transition-all duration-700
bg-linear-to-r from-white/75 via-white/45 to-white/10
bg-[radial-gradient(ellipse_at_left,_rgba(255,252,245,0.4)_0%,_transparent_70%)]
shadow-[_inset_-1px_0_0_rgba(0,0,0,0.04)]
border-r border-white/90
after:absolute after:right-[-1px] after:top-0 after:h-full after:w-[1px] after:bg-black/[0.05]
backdrop-blur-md
"
in:slide={{ axis: 'x', duration: 250, delay: 100, easing: cubicOut }}
out:slide={{ axis: 'x', duration: 150, easing: cubicOut }}
>
</div>
{/if}
</div>

View File

@@ -30,7 +30,7 @@ const { Story } = defineMeta({
}} }}
> >
<div class="flex flex-col gap-4 p-4 w-full"> <div class="flex flex-col gap-4 p-4 w-full">
<div class="flex flex-col gap-2 p-4 border rounded-xl border-gray-200/50 bg-white/40"> <div class="flex flex-col gap-2 p-4 border rounded-xl border-border-subtle bg-background-40">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<Skeleton class="h-8 w-1/3" /> <Skeleton class="h-8 w-1/3" />
<Skeleton class="h-8 w-8 rounded-full" /> <Skeleton class="h-8 w-8 rounded-full" />

View File

@@ -18,7 +18,7 @@ let { class: className, animate = true, ...rest }: Props = $props();
<div <div
class={cn( class={cn(
'rounded-md bg-gray-100/50 backdrop-blur-sm', 'rounded-md bg-background-subtle/50 backdrop-blur-sm',
animate && 'animate-pulse', animate && 'animate-pulse',
className, className,
)} )}

View File

@@ -31,21 +31,26 @@ const { Story } = defineMeta({
control: 'number', control: 'number',
description: 'Step size for value increments', description: 'Step size for value increments',
}, },
label: {
control: 'text',
description: 'Optional label displayed inline on the track',
},
}, },
}); });
</script> </script>
<script lang="ts"> <script lang="ts">
let minValue = 0;
let maxValue = 100;
let stepValue = 1;
let value = $state(50); let value = $state(50);
</script> </script>
<Story name="Horizontal" args={{ orientation: 'horizontal', min: minValue, max: maxValue, step: stepValue, value }}> <Story name="Horizontal" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}>
<Slider bind:value min={minValue} max={maxValue} step={stepValue} /> <Slider bind:value />
</Story> </Story>
<Story name="Vertical" args={{ orientation: 'vertical', min: minValue, max: maxValue, step: stepValue, value }}> <Story name="Vertical" args={{ orientation: 'vertical', min: 0, max: 100, step: 1, value }}>
<Slider bind:value min={minValue} max={maxValue} step={stepValue} orientation="vertical" /> <Slider bind:value />
</Story>
<Story name="With Label" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value, label: 'SIZE' }}>
<Slider bind:value />
</Story> </Story>

View File

@@ -9,11 +9,20 @@ import {
type SliderRootProps, type SliderRootProps,
} from 'bits-ui'; } from 'bits-ui';
type Props = Omit<SliderRootProps, 'type' | 'onValueChange' | 'onValueCommit'> & { type Props =
& Omit<
SliderRootProps,
'type' | 'onValueChange' | 'onValueCommit'
>
& {
/** /**
* Slider value, numeric. * Slider value, numeric.
*/ */
value: number; value: number;
/**
* Optional label displayed inline on the track before the filled range.
*/
label?: string;
/** /**
* A callback function called when the value changes. * A callback function called when the value changes.
* @param newValue - number * @param newValue - number
@@ -24,13 +33,19 @@ type Props = Omit<SliderRootProps, 'type' | 'onValueChange' | 'onValueCommit'> &
* @param newValue - number * @param newValue - number
*/ */
onValueCommit?: (newValue: number) => void; onValueCommit?: (newValue: number) => void;
}; };
let { value = $bindable(), orientation = 'horizontal', class: className, ...rest }: Props = $props(); let {
value = $bindable(),
orientation = 'horizontal',
class: className,
label,
...rest
}: Props = $props();
</script> </script>
<Slider.Root <Slider.Root
bind:value={value} bind:value
class={cn( class={cn(
'relative flex h-full w-6 touch-none select-none items-center justify-center', 'relative flex h-full w-6 touch-none select-none items-center justify-center',
orientation === 'horizontal' ? 'w-48 h-6' : 'w-6 h-48', orientation === 'horizontal' ? 'w-48 h-6' : 'w-6 h-48',
@@ -41,56 +56,73 @@ let { value = $bindable(), orientation = 'horizontal', class: className, ...rest
{...rest} {...rest}
> >
{#snippet children(props)} {#snippet children(props)}
{#if label && orientation === 'horizontal'}
<span class="absolute top-0 left-0 -translate-y-1/2 text-[0.5rem] uppercase text-gray-400">
{label}
</span>
{/if}
<span <span
{...props} {...props}
class={cn('relative bg-gray-200 rounded-full', orientation === 'horizontal' ? 'w-full h-px' : 'h-full w-px')} class={cn(
'relative bg-background-muted rounded-full',
orientation === 'horizontal' ? 'w-full h-px' : 'h-full w-px',
)}
> >
<!-- Filled range with NO transition -->
<Slider.Range <Slider.Range
class={cn('absolute bg-gray-900 rounded-full', orientation === 'horizontal' ? 'h-full' : 'w-full')} class={cn(
'absolute bg-foreground rounded-full',
orientation === 'horizontal' ? 'h-full' : 'w-full',
)}
/> />
<Slider.Thumb <Slider.Thumb
index={0} index={0}
class={cn( class={cn(
'group/thumb relative block', 'group/thumb relative block',
orientation === 'horizontal' ? '-top-1 w-2 h-2.25' : '-left-1 h-2 w-2.25', 'size-2',
'rounded-sm', orientation === 'horizontal' ? '-top-1' : '-left-1',
'bg-gray-900', 'rounded-full',
'bg-foreground',
// Glow shadow // Glow shadow
'shadow-[0_0_6px_rgba(0,0,0,0.4)]', 'shadow-[0_0_6px_rgba(0,0,0,0.4)]',
// Smooth transitions only for size/position // Smooth transitions only for size/position
'duration-200 ease-out', 'duration-200 ease-out',
orientation === 'horizontal' ? 'transition-[height,top,left,box-shadow]' : 'transition-[width,top,left,box-shadow]', orientation === 'horizontal'
? 'transition-[height,top,left,box-shadow]'
: 'transition-[width,top,left,box-shadow]',
// Hover: bigger glow // Hover: bigger glow
'hover:shadow-[0_0_10px_rgba(0,0,0,0.5)]', 'hover:shadow-[0_0_10px_rgba(0,0,0,0.5)]',
orientation === 'horizontal' ? 'hover:h-3 hover:-top-[5.5px]' : 'hover:w-3 hover:-left-[5.5px]', orientation === 'horizontal'
? 'hover:size-3 hover:-top-[5.5px]'
: 'hover:size-3 hover:-left-[5.5px]',
// Active: smaller glow // Active: smaller glow
'active:shadow-[0_0_4px_rgba(0,0,0,0.3)]', 'active:shadow-[0_0_4px_rgba(0,0,0,0.3)]',
orientation === 'horizontal' ? 'active:h-2.5 active:-top-[4.5px]' : 'active:w-2.5 active:-left-[4.5px]', orientation === 'horizontal'
? 'active:h-2.5 active:-top-[4.5px]'
: 'active:w-2.5 active:-left-[4.5px]',
'focus:outline-none', 'focus:outline-none',
'cursor-grab active:cursor-grabbing', 'cursor-grab active:cursor-grabbing',
)} )}
> >
<!-- Soft glow on hover -->
<div <div
class=" class="
absolute inset-0 rounded-sm absolute inset-0 rounded-full
bg-white/20 bg-background-20
opacity-0 group-hover/thumb:opacity-100 opacity-0 group-hover/thumb:opacity-100
transition-opacity duration-200 transition-opacity duration-200
" "
> >
</div> </div>
<!-- Value label -->
<span <span
class={cn( class={cn(
'absolute', 'absolute',
orientation === 'horizontal' ? '-top-8 left-1/2 -translate-x-1/2' : 'left-5 top-1/2 -translate-y-1/2', orientation === 'horizontal'
? '-top-8 left-1/2 -translate-x-1/2'
: 'left-5 top-1/2 -translate-y-1/2',
'px-1.5 py-0.5 rounded-md', 'px-1.5 py-0.5 rounded-md',
'bg-gray-900/90 backdrop-blur-sm', 'bg-foreground/90 backdrop-blur-sm',
'font-mono text-[0.625rem] font-medium text-white ', 'font-mono text-[0.625rem] font-medium text-background',
'opacity-0 group-hover/thumb:opacity-100', 'opacity-0 group-hover/thumb:opacity-100',
'transition-all duration-300', 'transition-all duration-300',
'pointer-events-none', 'pointer-events-none',

View File

@@ -38,27 +38,31 @@ const { Story } = defineMeta({
<script lang="ts"> <script lang="ts">
const smallDataSet = Array.from({ length: 20 }, (_, i) => `${i + 1}) I will not waste chalk.`); const smallDataSet = Array.from({ length: 20 }, (_, i) => `${i + 1}) I will not waste chalk.`);
const largeDataSet = Array.from( const mediumDataSet = Array.from(
{ length: 10000 }, { length: 200 },
(_, i) => `${i + 1}) I will not skateboard in the halls.`, (_, i) => `${i + 1}) I will not skateboard in the halls.`,
); );
const emptyDataSet: string[] = []; const emptyDataSet: string[] = [];
</script> </script>
<Story name="Small Dataset"> <Story name="Small Dataset">
<div class="h-[400px]">
<VirtualList items={smallDataSet} itemHeight={40}> <VirtualList items={smallDataSet} itemHeight={40}>
{#snippet children({ item })} {#snippet children({ item })}
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div> <div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
{/snippet} {/snippet}
</VirtualList> </VirtualList>
</div>
</Story> </Story>
<Story name="Large Dataset"> <Story name="Medium Dataset (200 items)">
<VirtualList items={largeDataSet} itemHeight={40}> <div class="h-[400px]">
<VirtualList items={mediumDataSet} itemHeight={40}>
{#snippet children({ item })} {#snippet children({ item })}
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div> <div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
{/snippet} {/snippet}
</VirtualList> </VirtualList>
</div>
</Story> </Story>
<Story name="Empty Dataset"> <Story name="Empty Dataset">

View File

@@ -6,15 +6,13 @@
- Keyboard navigation (ArrowUp/Down, Home, End) - Keyboard navigation (ArrowUp/Down, Home, End)
- Fixed or dynamic item heights - Fixed or dynamic item heights
- ARIA listbox/option pattern with single tab stop - ARIA listbox/option pattern with single tab stop
- Custom shadcn ScrollArea scrollbar - Native browser scroll
--> -->
<script lang="ts" generics="T"> <script lang="ts" generics="T">
import { createVirtualizer } from '$shared/lib'; import { createVirtualizer } from '$shared/lib';
import { ScrollArea } from '$shared/shadcn/ui/scroll-area'; import { throttle } from '$shared/lib/utils';
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 { flip } from 'svelte/animate';
import { quintOut } from 'svelte/easing';
interface Props { interface Props {
/** /**
@@ -135,10 +133,13 @@ let {
isLoading = false, isLoading = false,
}: Props = $props(); }: Props = $props();
// Reference to the ScrollArea viewport element for attaching the virtualizer // Reference to the scroll container element for attaching the virtualizer
let viewportRef = $state<HTMLElement | null>(null); let viewportRef = $state<HTMLElement | null>(null);
// Use items.length for count to keep existing item positions stable
// But calculate a separate totalSize for scrollbar that accounts for unloaded items
const virtualizer = createVirtualizer(() => ({ const virtualizer = createVirtualizer(() => ({
// Only virtualize loaded items - this keeps positions stable when new items load
count: items.length, count: items.length,
data: items, data: items,
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight, estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
@@ -146,6 +147,34 @@ const virtualizer = createVirtualizer(() => ({
useWindowScroll, useWindowScroll,
})); }));
// Calculate total size including unloaded items for proper scrollbar sizing
// Use estimateSize() for items that haven't been loaded yet
const estimatedTotalSize = $derived.by(() => {
if (total === items.length) {
// No unloaded items, use virtualizer's totalSize
return virtualizer.totalSize;
}
// Start with the virtualized (loaded) items size
const loadedSize = virtualizer.totalSize;
// Add estimated size for unloaded items
const unloadedCount = total - items.length;
if (unloadedCount <= 0) return loadedSize;
// Estimate the size of unloaded items
// Get the average size of loaded items, or use the estimateSize function
const estimateFn = typeof itemHeight === 'function' ? itemHeight : () => itemHeight;
// Use estimateSize for unloaded items (index from items.length to total - 1)
let unloadedSize = 0;
for (let i = items.length; i < total; i++) {
unloadedSize += estimateFn(i);
}
return loadedSize + unloadedSize;
});
// Attach virtualizer.container action to the viewport when it becomes available // Attach virtualizer.container action to the viewport when it becomes available
$effect(() => { $effect(() => {
if (viewportRef) { if (viewportRef) {
@@ -154,18 +183,29 @@ $effect(() => {
} }
}); });
const throttledVisibleChange = throttle((visibleItems: T[]) => {
onVisibleItemsChange?.(visibleItems);
}, 150); // 150ms throttle
const throttledNearBottom = throttle((lastVisibleIndex: number) => {
onNearBottom?.(lastVisibleIndex);
}, 200); // 200ms debounce
$effect(() => { $effect(() => {
const visibleItems = virtualizer.items.map(item => items[item.index]); const visibleItems = virtualizer.items.map(item => items[item.index]);
onVisibleItemsChange?.(visibleItems); throttledVisibleChange(visibleItems);
});
$effect(() => {
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items) // Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
if (virtualizer.items.length > 0 && onNearBottom) { // Only trigger if container has sufficient height to avoid false positives
if (virtualizer.items.length > 0 && onNearBottom && virtualizer.containerHeight > 100) {
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1]; const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
// Compare against loaded items length, not total // Compare against loaded items length, not total
const itemsRemaining = items.length - lastVisibleItem.index; const itemsRemaining = items.length - lastVisibleItem.index;
if (itemsRemaining <= 5) { if (itemsRemaining <= 5) {
onNearBottom(lastVisibleItem.index); throttledNearBottom(lastVisibleItem.index);
} }
} }
}); });
@@ -173,17 +213,18 @@ $effect(() => {
{#if useWindowScroll} {#if useWindowScroll}
<div class={cn('relative w-full', className)} bind:this={viewportRef}> <div class={cn('relative w-full', className)} bind:this={viewportRef}>
<div style:height="{virtualizer.totalSize}px" class="relative w-full"> <div style:height="{estimatedTotalSize}px" class="relative w-full">
{#each virtualizer.items as item (item.key)} {#each virtualizer.items as item (item.key)}
<div <div
use:virtualizer.measureElement use:virtualizer.measureElement
data-index={item.index} data-index={item.index}
class="absolute top-0 left-0 w-full will-change-transform" class="absolute top-0 left-0 w-full will-change-transform"
style:transform="translateY({item.start}px)" style:transform="translateY({item.start}px)"
data-lenis-prevent
> >
{#if item.index < items.length} {#if item.index < items.length}
{@render children({ {@render children({
// TODO: Fix indenation rule for this case // TODO: Fix indentation rule for this case
item: items[item.index], item: items[item.index],
index: item.index, index: item.index,
isFullyVisible: item.isFullyVisible, isFullyVisible: item.isFullyVisible,
@@ -196,27 +237,26 @@ $effect(() => {
</div> </div>
</div> </div>
{:else} {:else}
<ScrollArea <div
bind:viewportRef bind:this={viewportRef}
class={cn( class={cn(
'relative rounded-md bg-background', 'relative overflow-y-auto overflow-x-hidden',
'h-150 w-full', 'rounded-md bg-background',
'w-full min-h-[200px]',
className, className,
)} )}
orientation="vertical"
> >
<div style:height="{virtualizer.totalSize}px" class="relative w-full"> <div style:height="{estimatedTotalSize}px" class="relative w-full">
{#each virtualizer.items as item (item.key)} {#each virtualizer.items as item (item.key)}
<div <div
use:virtualizer.measureElement use:virtualizer.measureElement
data-index={item.index} data-index={item.index}
class="absolute top-0 left-0 w-full will-change-transform" class="absolute top-0 left-0 w-full will-change-transform"
style:transform="translateY({item.start}px)" style:transform="translateY({item.start}px)"
animate:flip={{ delay: 0, duration: 300, easing: quintOut }}
> >
{#if item.index < items.length} {#if item.index < items.length}
{@render children({ {@render children({
// TODO: Fix indenation rule for this case // TODO: Fix indentation rule for this case
item: items[item.index], item: items[item.index],
index: item.index, index: item.index,
isFullyVisible: item.isFullyVisible, isFullyVisible: item.isFullyVisible,
@@ -227,5 +267,5 @@ $effect(() => {
</div> </div>
{/each} {/each}
</div> </div>
</ScrollArea> </div>
{/if} {/if}

View File

@@ -6,11 +6,18 @@ export { default as Drawer } from './Drawer/Drawer.svelte';
export { default as ExpandableWrapper } from './ExpandableWrapper/ExpandableWrapper.svelte'; export { default as ExpandableWrapper } from './ExpandableWrapper/ExpandableWrapper.svelte';
export { default as Footnote } from './Footnote/Footnote.svelte'; export { default as Footnote } from './Footnote/Footnote.svelte';
export { default as IconButton } from './IconButton/IconButton.svelte'; export { default as IconButton } from './IconButton/IconButton.svelte';
export { default as Input } from './Input/Input.svelte'; export {
Input,
type InputSize,
type InputVariant,
} from './Input';
export { default as Label } from './Label/Label.svelte';
export { default as Loader } from './Loader/Loader.svelte'; export { default as Loader } from './Loader/Loader.svelte';
export { default as Logo } from './Logo/Logo.svelte'; export { default as Logo } from './Logo/Logo.svelte';
export { default as PerspectivePlan } from './PerspectivePlan/PerspectivePlan.svelte';
export { default as SearchBar } from './SearchBar/SearchBar.svelte'; export { default as SearchBar } from './SearchBar/SearchBar.svelte';
export { default as Section } from './Section/Section.svelte'; export { default as Section } from './Section/Section.svelte';
export { default as SidebarMenu } from './SidebarMenu/SidebarMenu.svelte';
export { default as Skeleton } from './Skeleton/Skeleton.svelte'; export { default as Skeleton } from './Skeleton/Skeleton.svelte';
export { default as Slider } from './Slider/Slider.svelte'; export { default as Slider } from './Slider/Slider.svelte';
export { default as VirtualList } from './VirtualList/VirtualList.svelte'; export { default as VirtualList } from './VirtualList/VirtualList.svelte';

View File

@@ -34,6 +34,7 @@ class ComparisonStore {
#fontB = $state<UnifiedFont | undefined>(); #fontB = $state<UnifiedFont | undefined>();
#sampleText = $state('The quick brown fox jumps over the lazy dog'); #sampleText = $state('The quick brown fox jumps over the lazy dog');
#isRestoring = $state(true); #isRestoring = $state(true);
#fontsReady = $state(false);
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography'); #typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
constructor() { constructor() {
@@ -49,6 +50,7 @@ class ComparisonStore {
// If we already have a selection, do nothing // If we already have a selection, do nothing
if (this.#fontA && this.#fontB) { if (this.#fontA && this.#fontB) {
this.#checkFontsLoaded();
return; return;
} }
@@ -66,6 +68,60 @@ class ComparisonStore {
}); });
} }
/**
* Checks if fonts are actually loaded in the browser at current weight.
* Uses CSS Font Loading API to prevent FOUT.
*/
async #checkFontsLoaded() {
if (!('fonts' in document)) {
this.#fontsReady = true;
return;
}
const weight = this.#typography.weight;
const size = this.#typography.renderedSize;
const fontAName = this.#fontA?.name;
const fontBName = this.#fontB?.name;
if (!fontAName || !fontBName) return;
const fontAString = `${weight} ${size}px "${fontAName}"`;
const fontBString = `${weight} ${size}px "${fontBName}"`;
// Check if already loaded to avoid UI flash
const isALoaded = document.fonts.check(fontAString);
const isBLoaded = document.fonts.check(fontBString);
if (isALoaded && isBLoaded) {
this.#fontsReady = true;
return;
}
this.#fontsReady = false;
try {
// Step 1: Load fonts into memory
await Promise.all([
document.fonts.load(fontAString),
document.fonts.load(fontBString),
]);
// Step 2: Wait for browser to be ready to render
await document.fonts.ready;
// Step 3: Force a layout/paint cycle (critical!)
await new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve); // Double rAF ensures paint completes
});
});
this.#fontsReady = true;
} catch (error) {
console.warn('[ComparisonStore] Font loading failed:', error);
setTimeout(() => this.#fontsReady = true, 1000);
}
}
/** /**
* Restore state from persistent storage * Restore state from persistent storage
*/ */
@@ -141,13 +197,12 @@ class ComparisonStore {
* Check if both fonts are selected * Check if both fonts are selected
*/ */
get isReady() { get isReady() {
return !!this.#fontA && !!this.#fontB; return !!this.#fontA && !!this.#fontB && this.#fontsReady;
} }
get isLoading() { get isLoading() {
return this.#isRestoring; return this.#isRestoring || !this.#fontsReady;
} }
/** /**
* Public initializer (optional, as constructor starts it) * Public initializer (optional, as constructor starts it)
* Kept for compatibility if manual re-init is needed * Kept for compatibility if manual re-init is needed

View File

@@ -0,0 +1,136 @@
<script module>
import Providers from '$shared/lib/storybook/Providers.svelte';
import { defineMeta } from '@storybook/addon-svelte-csf';
import ComparisonSlider from './ComparisonSlider.svelte';
const { Story } = defineMeta({
title: 'Widgets/ComparisonSlider',
component: ComparisonSlider,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'A multiline text comparison slider that morphs between two fonts. Features character-level morphing, responsive layout, and performance optimization using offscreen canvas. Switch between slider mode (interactive text) and settings mode (controls panel).',
},
story: { inline: false },
},
layout: 'fullscreen',
},
argTypes: {
// This component uses internal stores, so no direct props to document
},
});
</script>
<script lang="ts">
import type { UnifiedFont } from '$entities/Font';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
// Mock fonts for testing - using web-safe fonts that are always available
const mockArial: UnifiedFont = {
id: 'arial',
name: 'Arial',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 1,
},
features: {
isVariable: false,
},
};
const mockGeorgia: UnifiedFont = {
id: 'georgia',
name: 'Georgia',
provider: 'google',
category: 'serif',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 2,
},
features: {
isVariable: false,
},
};
const mockVerdana: UnifiedFont = {
id: 'verdana',
name: 'Verdana',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 3,
},
features: {
isVariable: false,
},
};
const mockCourier: UnifiedFont = {
id: 'courier-new',
name: 'Courier New',
provider: 'google',
category: 'monospace',
subsets: ['latin'],
variants: ['400', '700'],
styles: {
regular: '',
bold: '',
},
metadata: {
cachedAt: Date.now(),
version: '1.0',
popularity: 10,
},
features: {
isVariable: false,
},
};
</script>
<Story name="Default">
{@const _ = (comparisonStore.fontA = mockArial, comparisonStore.fontB = mockGeorgia)}
<Providers>
<div class="min-h-screen flex items-center justify-center p-8">
<div class="w-full max-w-5xl">
<ComparisonSlider />
</div>
</div>
</Providers>
</Story>
<Story name="Loading State">
{@const _ = (comparisonStore.fontA = undefined, comparisonStore.fontB = undefined)}
<Providers>
<div class="min-h-screen flex items-center justify-center p-8">
<div class="w-full max-w-5xl">
<ComparisonSlider />
</div>
</div>
</Providers>
</Story>

View File

@@ -8,17 +8,23 @@
- Character-level morphing: Font changes exactly when the slider passes the character's global position. - Character-level morphing: Font changes exactly when the slider passes the character's global position.
- Responsive layout with Tailwind breakpoints for font sizing. - Responsive layout with Tailwind breakpoints for font sizing.
- Performance optimized using offscreen canvas for measurements and transform-based animations. - Performance optimized using offscreen canvas for measurements and transform-based animations.
Modes:
- Slider mode: Text centered in 1st plan, controls hidden
- Settings mode: Text moves to left (2nd plan), controls appear on right (1st plan)
--> -->
<script lang="ts"> <script lang="ts">
import { import {
type CharacterComparison,
type LineData,
type ResponsiveManager,
createCharacterComparison, createCharacterComparison,
createTypographyControl, createPerspectiveManager,
} from '$shared/lib'; } from '$shared/lib';
import type { import {
LineData, Loader,
ResponsiveManager, PerspectivePlan,
} from '$shared/lib'; } from '$shared/ui';
import { Loader } from '$shared/ui';
import { comparisonStore } from '$widgets/ComparisonSlider/model'; import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { Spring } from 'svelte/motion'; import { Spring } from 'svelte/motion';
@@ -31,10 +37,11 @@ import SliderLine from './components/SliderLine.svelte';
const fontA = $derived(comparisonStore.fontA); const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB); const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady); const isLoading = $derived(
comparisonStore.isLoading || !comparisonStore.isReady,
);
let container = $state<HTMLElement>(); let container = $state<HTMLElement>();
let typographyControls = $state<HTMLDivElement | null>(null);
let measureCanvas = $state<HTMLCanvasElement>(); let measureCanvas = $state<HTMLCanvasElement>();
let isDragging = $state(false); let isDragging = $state(false);
const typography = $derived(comparisonStore.typography); const typography = $derived(comparisonStore.typography);
@@ -45,7 +52,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
* Encapsulated helper for text splitting, measuring, and character proximity calculations. * Encapsulated helper for text splitting, measuring, and character proximity calculations.
* Manages line breaking and character state based on fonts and container dimensions. * Manages line breaking and character state based on fonts and container dimensions.
*/ */
const charComparison = createCharacterComparison( const charComparison: CharacterComparison = createCharacterComparison(
() => comparisonStore.text, () => comparisonStore.text,
() => fontA, () => fontA,
() => fontB, () => fontB,
@@ -53,6 +60,22 @@ const charComparison = createCharacterComparison(
() => typography.renderedSize, () => typography.renderedSize,
); );
/**
* Perspective manager for back/front state toggling:
* - Front (slider mode): Text fully visible, interactive
* - Back (settings mode): Text blurred, scaled down, shifted left, controls visible
*
* Uses simple boolean flag for smooth transitions between states.
*/
const perspective = createPerspectiveManager({
parallaxIntensity: 0, // Disabled to not interfere with slider
horizontalOffset: 0, // Text shifts left when in back position
scaleStep: 0.5,
blurStep: 2,
depthStep: 100,
opacityStep: 0.3,
});
let lineElements = $state<(HTMLElement | undefined)[]>([]); let lineElements = $state<(HTMLElement | undefined)[]>([]);
/** Physics-based spring for smooth handle movement */ /** Physics-based spring for smooth handle movement */
@@ -64,7 +87,10 @@ const sliderPos = $derived(sliderSpring.current);
/** Updates spring target based on pointer position */ /** Updates spring target based on pointer position */
function handleMove(e: PointerEvent) { function handleMove(e: PointerEvent) {
if (!isDragging || !container) return; if (!isDragging || !container) {
return;
}
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const percentage = (x / rect.width) * 100; const percentage = (x / rect.width) * 100;
@@ -72,18 +98,15 @@ function handleMove(e: PointerEvent) {
} }
function startDragging(e: PointerEvent) { function startDragging(e: PointerEvent) {
if (
e.target === typographyControls
|| typographyControls?.contains(e.target as Node)
) {
e.stopPropagation();
return;
}
e.preventDefault(); e.preventDefault();
isDragging = true; isDragging = true;
handleMove(e); handleMove(e);
} }
function togglePerspective() {
perspective.toggle();
}
/** /**
* Sets the multiplier for slider font size based on the current responsive state * Sets the multiplier for slider font size based on the current responsive state
*/ */
@@ -146,32 +169,28 @@ $effect(() => {
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}); });
const isInSettingsMode = $derived(perspective.isBack);
</script> </script>
{#snippet renderLine(line: LineData, index: number)} {#snippet renderLine(line: LineData, index: number)}
{@const pos = sliderPos}
{@const element = lineElements[index]}
<div <div
bind:this={lineElements[index]} bind:this={lineElements[index]}
class="relative flex w-full justify-center items-center whitespace-nowrap" class="relative flex w-full justify-center items-center whitespace-nowrap"
style:height={`${typography.height}em`} style:height={`${typography.height}em`}
style:line-height={`${typography.height}em`} style:line-height={`${typography.height}em`}
> >
{#each line.text.split('') as char, charIndex} {#each line.text.split('') as char, index}
{@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)} {@const { proximity, isPast } = charComparison.getCharState(index, pos, element, container)}
<!-- <!--
Single Character Span Single Character Span
- Font Family switches based on `isPast` - Font Family switches based on `isPast`
- Transitions/Transforms provide the "morph" feel - Transitions/Transforms provide the "morph" feel
--> -->
{#if fontA && fontB} {#if fontA && fontB}
<CharacterSlot <CharacterSlot {char} {proximity} {isPast} />
{char}
{proximity}
{isPast}
weight={typography.weight}
size={typography.renderedSize}
fontAName={fontA.name}
fontBName={fontB.name}
/>
{/if} {/if}
{/each} {/each}
</div> </div>
@@ -180,7 +199,33 @@ $effect(() => {
<!-- Hidden canvas used for text measurement by the helper --> <!-- Hidden canvas used for text measurement by the helper -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas> <canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<div class="relative"> <!-- Main container with perspective and fixed height -->
<div
class="
relative w-full flex justify-center items-center
perspective-distant perspective-origin-center transform-3d
rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
min-h-72 sm:min-h-96 lg:min-h-128
backdrop-blur-lg bg-linear-to-br from-gray-200/40 via-white/80 to-gray-100/60
border border-border-muted
shadow-[inset_2px_0_8px_rgba(0,0,0,0.05)]
before:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-px
before:bg-linear-to-br before:from-black/5 before:via-black/2 before:to-transparent
before:-z-10 before:blur-sm
overflow-hidden
[perspective:1500px] perspective-origin-center transform-3d
"
>
{#if isLoading}
<div out:fade={{ duration: 300 }}>
<Loader size={24} />
</div>
{:else}
<!-- Text Plan -->
<PerspectivePlan
manager={perspective}
class="absolute inset-0 flex justify-center origin-right w-full h-full"
>
<div <div
bind:this={container} bind:this={container}
role="slider" role="slider"
@@ -189,29 +234,19 @@ $effect(() => {
aria-label="Font comparison slider" aria-label="Font comparison slider"
onpointerdown={startDragging} onpointerdown={startDragging}
class=" class="
group relative w-full py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24 overflow-hidden relative w-full h-full flex justify-center
rounded-xl sm:rounded-2xl md:rounded-[2.5rem] py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24
select-none touch-none cursor-ew-resize min-h-72 sm:min-h-96 flex flex-col justify-center select-none touch-none cursor-ew-resize
backdrop-blur-lg bg-linear-to-br from-gray-100/70 via-white/50 to-gray-100/60
border border-gray-300/40
shadow-[inset_0_4px_12px_0_rgba(0,0,0,0.12),inset_0_2px_4px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(255,255,255,0.8)]
before:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-px
before:bg-linear-to-br before:from-black/5 before:via-black/2 before:to-transparent
before:-z-10 before:blur-sm
" "
> >
<!-- Text Rendering Container -->
{#if isLoading}
<Loader size={24} />
{:else}
<div <div
class=" class="
relative flex flex-col items-center gap-3 sm:gap-4 relative flex flex-col items-center gap-3 sm:gap-4
text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold leading-[1.15] text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold leading-[1.15]
z-10 pointer-events-none text-center z-10 pointer-events-none text-center
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)] drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
my-auto
" "
style:perspective="1000px"
in:fade={{ duration: 300, delay: 300 }} in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300 }} out:fade={{ duration: 300 }}
> >
@@ -228,9 +263,16 @@ $effect(() => {
{/each} {/each}
</div> </div>
<!-- Slider Line - visible in slider mode -->
{#if !isInSettingsMode}
<SliderLine {sliderPos} {isDragging} /> <SliderLine {sliderPos} {isDragging} />
{/if} {/if}
</div> </div>
<!-- Since there're slider controls inside we put them outside the main one --> </PerspectivePlan>
<Controls {sliderPos} {isDragging} {typographyControls} {container} />
<Controls
class="absolute inset-y-0 left-0 transition-all duration-150"
handleToggle={togglePerspective}
/>
{/if}
</div> </div>

View File

@@ -1,52 +0,0 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props {
text: string;
fontName: string;
isAnimating: boolean;
onAnimationComplete?: () => void;
}
let { text, fontName, isAnimating, onAnimationComplete }: Props = $props();
// Split text into characters, preserving spaces
const chars = $derived(text.split('').map(c => c === ' ' ? '\u00A0' : c));
let completedCount = 0;
function handleTransitionEnd() {
completedCount++;
if (completedCount === chars.length) {
onAnimationComplete?.();
completedCount = 0;
}
}
</script>
<div class="relative inline-flex flex-wrap leading-tight">
{#each chars as char, i}
<span
class={cn(
'inline-block transition-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
isAnimating ? 'opacity-0 -translate-y-4 rotate-x-90' : 'opacity-100 translate-y-0 rotate-x-0',
)}
style:font-family={fontName}
style:transition-delay="{i * 25}ms"
ontransitionend={i === chars.length - 1 ? handleTransitionEnd : null}
>
{char}
</span>
{/each}
</div>
<style>
/* Necessary for the "Flip" feel */
div {
perspective: 1000px;
}
span {
transform-style: preserve-3d;
backface-visibility: hidden;
}
</style>

View File

@@ -4,6 +4,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '../../../model';
interface Props { interface Props {
/** /**
@@ -18,53 +19,48 @@ interface Props {
* Flag indicating whether character needed to be changed * Flag indicating whether character needed to be changed
*/ */
isPast: boolean; isPast: boolean;
/**
* Font weight of the character
*/
weight: number;
/**
* Font size of the character
*/
size: number;
/**
* Name of the font for the character after the change
*/
fontAName: string;
/**
* Name of the font for the character before the change
*/
fontBName: string;
} }
let { char, proximity, isPast, weight, size, fontAName, fontBName }: Props = $props(); let { char, proximity, isPast }: Props = $props();
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const typography = $derived(comparisonStore.typography);
</script> </script>
<span {#if fontA && fontB}
<span
class={cn( class={cn(
'inline-block transition-all duration-300 ease-out will-change-transform', 'inline-block transition-all duration-300 ease-out will-change-transform',
isPast ? 'text-indigo-500' : 'text-neutral-950', isPast ? 'text-indigo-500' : 'text-neutral-950',
)} )}
style:font-family={isPast ? fontBName : fontAName} style:font-family={isPast ? fontB.name : fontA.name}
style:font-weight={weight} style:font-weight={typography.weight}
style:font-size={`${size}px`} style:font-size={`${typography.renderedSize}px`}
style:transform=" style:transform="
scale({1 + proximity * 0.3}) scale({1 + proximity * 0.3}) translateY({-proximity * 12}px) rotateY({proximity *
translateY({-proximity * 12}px) 25 *
rotateY({proximity * 25 * (isPast ? -1 : 1)}deg) (isPast ? -1 : 1)}deg)
" "
style:filter="brightness({1 + proximity * 0.2}) contrast({1 + proximity * 0.1})" style:filter="brightness({1 + proximity * 0.2}) contrast({1 +
style:text-shadow={proximity > 0.5 ? '0 0 15px rgba(99,102,241,0.3)' : 'none'} proximity * 0.1})"
style:will-change={proximity > 0 ? 'transform, font-family, color' : 'auto'} style:text-shadow={proximity > 0.5
> ? '0 0 15px rgba(99,102,241,0.3)'
: 'none'}
style:will-change={proximity > 0
? 'transform, font-family, color'
: 'auto'}
>
{char === ' ' ? '\u00A0' : char} {char === ' ' ? '\u00A0' : char}
</span> </span>
{/if}
<style> <style>
span { span {
/* /*
Optimize for performance and smooth transitions. Optimize for performance and smooth transitions.
step-end logic is effectively handled by binary font switching in JS. step-end logic is effectively handled by binary font switching in JS.
*/ */
transition: transition:
font-family 0.15s ease-out, font-family 0.15s ease-out,
color 0.2s ease-out, color 0.2s ease-out,

View File

@@ -1,71 +1,131 @@
<!--
Component: Controls
Uses SidebarMenu to show ComparisonSlider's controls:
- List of fonts to pick
- Input to change text
- Sliders for font-weight, font-width, line-height
-->
<script lang="ts"> <script lang="ts">
import { appliedFontsManager } from '$entities/Font';
import { getFontUrl } from '$entities/Font/lib';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { import { SidebarMenu } from '$shared/ui';
Drawer, import { Label } from '$shared/ui';
IconButton, import Drawer from '$shared/ui/Drawer/Drawer.svelte';
} from '$shared/ui'; import { comparisonStore } from '$widgets/ComparisonSlider/model';
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { comparisonStore } from '../../../model'; import FontList from './FontList.svelte';
import SelectComparedFonts from './SelectComparedFonts.svelte'; import ToggleMenuButton from './ToggleMenuButton.svelte';
import TypographyControls from './TypographyControls.svelte'; import TypographyControls from './TypographyControls.svelte';
interface Props { interface Props {
sliderPos: number; /**
isDragging: boolean; * Additional class
typographyControls?: HTMLDivElement | null; */
container: HTMLElement; class?: string;
/**
* Handler to trigger when menu opens/closes
*/
handleToggle?: () => void;
} }
let { sliderPos, isDragging, typographyControls = $bindable<HTMLDivElement | null>(null), container }: Props = $props(); let { class: className, handleToggle }: Props = $props();
let visible = $state(false);
const fontA = $derived(comparisonStore.fontA); const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB); const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady); const typography = $derived(comparisonStore.typography);
let menuWrapper = $state<HTMLElement | null>(null);
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
$effect(() => {
if (!fontA || !fontB) {
return;
}
const weight = typography.weight;
const fontAUrl = getFontUrl(fontA, weight);
const fontBUrl = getFontUrl(fontB, weight);
if (!fontAUrl || !fontBUrl) {
return;
}
const fontAConfig = {
id: fontA.id,
name: fontA.name,
url: fontAUrl,
weight: weight,
};
const fontBConfig = {
id: fontB.id,
name: fontB.name,
url: fontBUrl,
weight: weight,
};
appliedFontsManager.touch([fontAConfig, fontBConfig]);
});
</script> </script>
{#if responsive.isMobile} {#if responsive.isMobile}
<Drawer> <Drawer>
{#snippet trigger({ isOpen, onClick })} {#snippet trigger({ onClick })}
<IconButton class="absolute right-3 top-3" onclick={onClick}> <div class={cn('absolute bottom-0.5 left-1/2 -translate-x-1/2 z-50')}>
{#snippet icon({ className })} <ToggleMenuButton bind:isActive={visible} {onClick} />
<SlidersIcon class={className} /> </div>
{/snippet}
</IconButton>
{/snippet} {/snippet}
{#snippet content({ className })}
<div class="w-full pt-4 grid grid-cols-[1fr_min-content_1fr] gap-2 items-center justify-center">
<div class="uppercase text-indigo-500 ml-auto font-semibold tracking-tight text-[0.825rem] whitespace-nowrap">
{fontB?.name ?? 'typeface_01'}
</div>
<div class="w-px h-2.5 bg-gray-400/50"></div>
<div class="uppercase text-neutral-950 mr-auto font-semibold tracking-tight text-[0.825rem] whitespace-nowrap">
{fontA?.name ?? 'typeface_02'}
</div>
</div>
<div class={cn(className, 'flex flex-col gap-2 h-[60vh]')}>
<Label class="mb-2" text="Available Fonts" align="center" />
{#snippet content({ isOpen, className })} <div class="h-full overflow-hidden">
<div class={cn(className, 'flex flex-col gap-6')}> <FontList />
<SelectComparedFonts {sliderPos} /> </div>
<TypographyControls <Label class="mb-2" text="Typography Controls" align="center" />
{sliderPos}
{isDragging} <div class="mx-4 flex-shrink-0">
isActive={isOpen} <TypographyControls />
bind:wrapper={typographyControls} </div>
containerWidth={container?.clientWidth}
staticPosition
/>
</div> </div>
{/snippet} {/snippet}
</Drawer> </Drawer>
{:else} {:else}
{#if !isLoading} <SidebarMenu
<div class="absolute top-3 sm:top-6 left-3 sm:left-6 z-50"> class={cn(
<TypographyControls 'w-96 flex flex-col h-full pl-4 lg:pl-6 py-4 sm:py-6 sm:pt-12 gap-0 sm:gap-0 pointer-events-auto overflow-hidden',
{sliderPos} 'relative h-full transition-all duration-700 ease-out',
{isDragging} className,
bind:wrapper={typographyControls} )}
containerWidth={container?.clientWidth} bind:visible
/> bind:wrapper={menuWrapper}
onClickOutside={handleToggle}
>
{#snippet action()}
<!-- Always-visible mode switch -->
<div class={cn('absolute top-2 left-0 z-50', visible && 'w-full')}>
<ToggleMenuButton bind:isActive={visible} onClick={handleToggle} />
</div> </div>
{/if} {/snippet}
<Label class="mb-2 mr-4 lg:mr-6" text="Available Fonts" align="left" />
{#if !isLoading} <div class="mb-2 h-2/3 overflow-hidden">
<div class="absolute bottom-3 sm:bottom-6 md:bottom-8 inset-x-3 sm:inset-x-6 md:inset-x-12"> <FontList />
<SelectComparedFonts {sliderPos} />
</div> </div>
{/if} <Label class="mb-2 mr-4 lg:mr-6" text="Typography Controls" align="left" />
<div class="mr-4 sm:mr-6">
<TypographyControls />
</div>
</SidebarMenu>
{/if} {/if}

View File

@@ -0,0 +1,215 @@
<!--
Component: FontList
A scrollable list of fonts with dual selection buttons for fontA and fontB.
-->
<script lang="ts">
import {
FontVirtualList,
type UnifiedFont,
} from '$entities/Font';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { cubicOut } from 'svelte/easing';
import { draw } from 'svelte/transition';
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const typography = $derived(comparisonStore.typography);
/**
* Select a font as fontA (right slot - compare_to)
*/
function selectFontA(font: UnifiedFont) {
comparisonStore.fontA = font;
}
/**
* Select a font as fontB (left slot - compare_from)
*/
function selectFontB(font: UnifiedFont) {
comparisonStore.fontB = font;
}
/**
* Check if a font is selected as fontA
*/
function isFontA(font: UnifiedFont): boolean {
return fontA?.id === font.id;
}
/**
* Check if a font is selected as fontB
*/
function isFontB(font: UnifiedFont): boolean {
return fontB?.id === font.id;
}
</script>
{#snippet rightBrackets(className?: string)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={cn(
'lucide lucide-focus-icon lucide-focus right-0 top-0 absolute',
className,
)}
>
<path
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
d="M17 3h2a2 2 0 0 1 2 2v2"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={cn(
'lucide lucide-focus-icon lucide-focus right-0 bottom-0 absolute',
className,
)}
>
<path
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
d="M21 17v2a2 2 0 0 1-2 2h-2"
/>
</svg>
{/snippet}
{#snippet leftBrackets(className?: string)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={cn(
'lucide lucide-focus-icon lucide-focus left-0 top-0 absolute',
className,
)}
>
<path
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
d="M3 7V5a2 2 0 0 1 2-2h2"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={cn(
'lucide lucide-focus-icon lucide-focus left-0 bottom-0 absolute',
className,
)}
>
<path
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
d="M7 21H5a2 2 0 0 1-2-2v-2"
/>
</svg>
{/snippet}
{#snippet brackets(
renderLeft?: boolean,
renderRight?: boolean,
className?: string,
)}
{#if renderLeft}
{@render leftBrackets(className)}
{/if}
{#if renderRight}
{@render rightBrackets(className)}
{/if}
{/snippet}
<div class="flex flex-col h-full min-h-0 bg-transparent">
<div class="flex-1 min-h-0">
<FontVirtualList
weight={typography.weight}
itemHeight={36}
class="bg-transparent h-full"
>
{#snippet children({ item: font })}
{@const isSelectedA = isFontA(font)}
{@const isSelectedB = isFontB(font)}
{@const isEither = isSelectedA || isSelectedB}
{@const isBoth = isSelectedA && isSelectedB}
{@const handleSelectFontA = () => selectFontA(font)}
{@const handleSelectFontB = () => selectFontB(font)}
<div class="group relative flex w-auto h-[36px] border-b border-black/[0.03] overflow-hidden sm:mr-4 lg:mr-6">
<div
class={cn(
'absolute inset-0 flex items-center justify-center z-20 pointer-events-none transition-all duration-500 cubic-bezier-out',
isSelectedB && !isBoth && '-translate-x-1/4',
isSelectedA && !isBoth && 'translate-x-1/4',
isBoth && 'translate-x-0',
)}
>
<div class="relative flex items-center px-6">
<span
class={cn(
'text-[0.625rem] sm:text-[0.75rem] tracking-tighter select-none transition-all duration-300',
isEither
? 'opacity-100 font-bold'
: 'opacity-30 group-hover:opacity-100',
isSelectedB && 'text-indigo-500',
isSelectedA && 'text-normal-950',
isBoth
&& 'bg-[linear-gradient(to_right,theme(colors.indigo.500)_50%,theme(colors.neutral.950)_50%)] bg-clip-text text-transparent',
)}
>
--- {font.name} ---
</span>
</div>
</div>
<button
onclick={handleSelectFontB}
class="flex-1 relative flex items-center justify-between transition-all duration-200 cursor-pointer hover:bg-indigo-500/[0.03]"
>
{@render brackets(
isSelectedB,
isSelectedB && !isBoth,
'stroke-1 size-7 stroke-indigo-600',
)}
</button>
<button
onclick={handleSelectFontA}
class="flex-1 relative flex items-center justify-end transition-all duration-200 cursor-pointer hover:bg-black/[0.02]"
>
{@render brackets(
isSelectedA && !isBoth,
isSelectedA,
'stroke-1 size-7 stroke-normal-950',
)}
</button>
</div>
{/snippet}
</FontVirtualList>
</div>
</div>

View File

@@ -1,147 +0,0 @@
<!--
Component: SelectComparedFonts
Displays selects that change the compared fonts
-->
<script lang="ts">
import {
FontVirtualList,
type UnifiedFont,
unifiedFontStore,
} from '$entities/Font';
import { getFontUrl } from '$entities/Font/lib';
import FontApplicator from '$entities/Font/ui/FontApplicator/FontApplicator.svelte';
import {
Content as SelectContent,
Item as SelectItem,
Root as SelectRoot,
Trigger as SelectTrigger,
} from '$shared/shadcn/ui/select';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { fade } from 'svelte/transition';
interface Props {
/**
* Position of the slider
*/
sliderPos: number;
}
let { sliderPos }: Props = $props();
const typography = $derived(comparisonStore.typography);
const fontA = $derived(comparisonStore.fontA);
const fontAUrl = $derived(fontA && getFontUrl(fontA, typography.weight));
const fontB = $derived(comparisonStore.fontB);
const fontBUrl = $derived(fontB && getFontUrl(fontB, typography.weight));
const fontList = $derived(unifiedFontStore.fonts);
function selectFontA(font: UnifiedFont) {
if (!font) return;
comparisonStore.fontA = font;
}
function selectFontB(font: UnifiedFont) {
if (!font) return;
comparisonStore.fontB = font;
}
</script>
{#snippet fontSelector(
font: UnifiedFont,
fonts: UnifiedFont[],
url: string,
onSelect: (f: UnifiedFont) => void,
align: 'start' | 'end',
)}
<div
class="z-50 pointer-events-auto"
onpointerdown={(e => e.stopPropagation())}
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300, delay: 300 }}
>
<SelectRoot type="single" disabled={!fontList.length}>
<SelectTrigger
class={cn(
'w-36 sm:w-44 md:w-52 h-8 sm:h-9 border border-gray-300/40 bg-white/60 backdrop-blur-sm',
'px-2 sm:px-3 rounded-lg transition-all flex items-center justify-between gap-2',
'font-mono text-[10px] sm:text-[11px] tracking-tight font-medium text-gray-900',
'hover:bg-white/80 hover:border-gray-400/60 hover:shadow-sm',
)}
>
<div class="text-left flex-1 min-w-0">
<FontApplicator name={font.name} id={font.id} {url}>
{font.name}
</FontApplicator>
</div>
</SelectTrigger>
<SelectContent
class={cn(
'bg-white/95 backdrop-blur-xl border border-gray-300/50 shadow-xl',
'w-44 sm:w-52 max-h-60 sm:max-h-64 overflow-hidden rounded-lg',
)}
side="top"
{align}
sideOffset={8}
size="small"
>
<div class="p-1 sm:p-1.5">
<FontVirtualList items={fonts} weight={typography.weight}>
{#snippet children({ item: fontListItem })}
{@const handleClick = () => onSelect(fontListItem)}
<SelectItem
value={fontListItem.id}
class="data-highlighted:bg-gray-100 font-mono text-[10px] sm:text-[11px] px-2 sm:px-3 py-2 sm:py-2.5 rounded-md cursor-pointer transition-colors"
onclick={handleClick}
>
<FontApplicator
name={fontListItem.name}
id={fontListItem.id}
url={getFontUrl(fontListItem, typography.weight) ?? ''}
>
{fontListItem.name}
</FontApplicator>
</SelectItem>
{/snippet}
</FontVirtualList>
</div>
</SelectContent>
</SelectRoot>
</div>
{/snippet}
<div class="flex justify-between items-end pointer-events-none z-20">
<div
class="flex flex-col gap-1.5 sm:gap-2 transition-all duration-500 items-start"
style:opacity={sliderPos < 20 ? 0 : 1}
style:transform="translateY({sliderPos < 20 ? '8px' : '0px'})"
>
<div class="flex items-center gap-2 sm:gap-2.5 px-1">
<div class="w-1.5 h-1.5 rounded-full bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.6)]"></div>
<div class="w-px h-2 sm:h-2.5 bg-gray-300/60"></div>
<span class="font-mono text-[8px] sm:text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
ch_01
</span>
</div>
{#if fontB && fontBUrl}
{@render fontSelector(fontB, fontList, fontBUrl, selectFontB, 'start')}
{/if}
</div>
<div
class="flex flex-col items-end text-right gap-1.5 sm:gap-2 transition-all duration-500"
style:opacity={sliderPos > 80 ? 0 : 1}
style:transform="translateY({sliderPos > 80 ? '8px' : '0px'})"
>
<div class="flex items-center gap-2 sm:gap-2.5 px-1">
<span class="font-mono text-[8px] sm:text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
ch_02
</span>
<div class="w-px h-2 sm:h-2.5 bg-gray-300/60"></div>
<div class="w-1.5 h-1.5 rounded-full bg-gray-900 shadow-[0_0_6px_rgba(0,0,0,0.4)]"></div>
</div>
{#if fontA && fontAUrl}
{@render fontSelector(fontA, fontList, fontAUrl, selectFontA, 'end')}
{/if}
</div>
</div>

View File

@@ -4,6 +4,8 @@
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { cubicOut } from 'svelte/easing';
import { fade } from 'svelte/transition';
interface Props { interface Props {
/** /**
@@ -17,9 +19,10 @@ interface Props {
} }
let { sliderPos, isDragging }: Props = $props(); let { sliderPos, isDragging }: Props = $props();
</script> </script>
<div <div
class={cn( class={cn(
'absolute inset-y-2 sm:inset-y-4 pointer-events-none -translate-x-1/2 z-50 flex flex-col justify-center items-center', 'absolute top-2 bottom-8 sm:top-4 sm:bottom-4 pointer-events-none -translate-x-1/2 z-50 flex flex-col justify-center items-center',
// Force GPU layer with translateZ // Force GPU layer with translateZ
'translate-z-0', 'translate-z-0',
// Only transition left when NOT dragging // Only transition left when NOT dragging
@@ -27,6 +30,8 @@ let { sliderPos, isDragging }: Props = $props();
)} )}
style:left="{sliderPos}%" style:left="{sliderPos}%"
style:will-change={isDragging ? 'left' : 'auto'} style:will-change={isDragging ? 'left' : 'auto'}
in:fade={{ duration: 300, delay: 150, easing: cubicOut }}
out:fade={{ duration: 150, easing: cubicOut }}
> >
<!-- We use part of lucide cursor svg icon as a handle --> <!-- We use part of lucide cursor svg icon as a handle -->
<svg <svg
@@ -48,7 +53,7 @@ let { sliderPos, isDragging }: Props = $props();
<div <div
class={cn( class={cn(
'relative h-full rounded-sm transition-all duration-300', 'relative h-full rounded-sm transition-all duration-300',
'bg-white/3 ', 'bg-background-3 ',
// These are the visible "edges" of the glass // These are the visible "edges" of the glass
'shadow-[0_0_40px_rgba(0,0,0,0.1)_inset_0_0_20px_rgba(255,255,255,0.1)]', 'shadow-[0_0_40px_rgba(0,0,0,0.1)_inset_0_0_20px_rgba(255,255,255,0.1)]',
'shadow-[0_10px_30px_-10px_rgba(0,0,0,0.2),inset_0_1px_1px_rgba(255,255,255,0.4)]', 'shadow-[0_10px_30px_-10px_rgba(0,0,0,0.2),inset_0_1px_1px_rgba(255,255,255,0.4)]',

View File

@@ -0,0 +1,88 @@
<!--
Component: ToggleMenuButton
Toggles menu sidebar, displays selected fonts names
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { cubicOut } from 'svelte/easing';
import { draw } from 'svelte/transition';
interface Props {
isActive?: boolean;
onClick?: () => void;
}
let { isActive = $bindable(false), onClick }: Props = $props();
// Handle click and toggle
const toggle = () => {
onClick?.();
isActive = !isActive;
};
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
</script>
{#snippet icon(className?: string)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={cn(
'lucide lucide-circle-arrow-right-icon lucide-circle-arrow-right',
className,
)}
>
<circle cx="12" cy="12" r="10" />
{#if isActive}
<path
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
d="m15 9-6 6"
/><path
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
d="m9 9 6 6"
/>
{:else}
<path
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
d="m12 16 4-4-4-4"
/><path
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
d="M8 12h8"
/>
{/if}
</svg>
{/snippet}
<button
onclick={toggle}
aria-pressed={isActive}
class={cn(
'group relative flex items-center justify-center gap-2 sm:gap-3 px-4 sm:px-6 py-2',
'cursor-pointer select-none overflow-hidden',
'transition-transform duration-150 active:scale-98',
)}
>
{@render icon(
cn(
'size-4 stroke-[1.5] stroke-gray-500',
!isActive && 'rotate-90 sm:rotate-0',
),
)}
<div class="w-px h-2.5 bg-gray-400/50"></div>
<div class="text-[0.75rem] sm:text-xs transition-all delay-150 group-hover:text-semibold text-indigo-500 text-right whitespace-nowrap">
{fontB?.name}
</div>
<div class="w-px h-2.5 bg-gray-400/50"></div>
<div class="text-[0.75rem] sm:text-xs transition-all delay-150 group-hover:text-semibold text-neural-950 text-left whitespace-nowrap">
{fontA?.name}
</div>
</button>

View File

@@ -1,186 +1,55 @@
<!-- <!--
Component: TypographyControls Component: TypographyControls
Wrapper for the controls of the slider. Controls for text input and typography settings (size, weight, height).
- Input to change the text Simplified version for static positioning in settings mode.
- Three combo controls with inputs and sliders for font-weight, font-size, and line-height
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { import {
ComboControlV2, ComboControlV2,
ExpandableWrapper,
Input, Input,
} from '$shared/ui'; } from '$shared/ui';
import { comparisonStore } from '$widgets/ComparisonSlider/model'; import { comparisonStore } from '$widgets/ComparisonSlider/model';
import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
import { type Orientation } from 'bits-ui';
import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition';
interface Props {
/**
* Ref
*/
wrapper?: HTMLDivElement | null;
/**
* Slider position
*/
sliderPos: number;
/**
* Whether slider is being dragged
*/
isDragging: boolean;
/** */
isActive?: boolean;
/**
* Container width
*/
containerWidth: number;
/**
* Reduced animations flag
*/
staticPosition?: boolean;
}
let {
sliderPos,
isDragging,
isActive = $bindable(false),
wrapper = $bindable(null),
containerWidth = 0,
staticPosition = false,
}: Props = $props();
const typography = $derived(comparisonStore.typography); const typography = $derived(comparisonStore.typography);
const panelWidth = $derived(wrapper?.clientWidth ?? 0);
const margin = 24;
let side = $state<'left' | 'right'>('left');
// Unified active state for the entire wrapper
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
const xSpring = new Spring(0, {
stiffness: 0.14, // Lower is slower
damping: 0.5, // Settle
});
const rotateSpring = new Spring(0, {
stiffness: 0.12,
damping: 0.55,
});
function handleInputFocus() {
isActive = true;
}
// Movement Logic
$effect(() => {
if (containerWidth === 0 || panelWidth === 0 || staticPosition) return;
const sliderX = (sliderPos / 100) * containerWidth;
const buffer = 40;
const leftTrigger = margin + panelWidth + buffer;
const rightTrigger = containerWidth - (margin + panelWidth + buffer);
if (side === 'left' && sliderX < leftTrigger) {
side = 'right';
} else if (side === 'right' && sliderX > rightTrigger) {
side = 'left';
}
});
$effect(() => {
const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0;
if (containerWidth > 0 && panelWidth > 0) {
// On side change set the position and the rotation
xSpring.target = targetX;
rotateSpring.target = side === 'right' ? 3.5 : -3.5;
timeoutId = setTimeout(() => {
rotateSpring.target = 0;
}, 600);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
</script> </script>
{#snippet InputComponent(className: string)} <!-- Text input -->
<Input <Input
class={className}
bind:value={comparisonStore.text} bind:value={comparisonStore.text}
disabled={isDragging} size="sm"
onfocusin={handleInputFocus} label="Text"
placeholder="The quick brown fox..." placeholder="The quick brown fox..."
class="w-full h-10 px-3 py-2 sm:mr-4 mb-8 sm:mb-4 rounded-lg border border-border-muted bg-background-60 backdrop-blur-sm"
/>
<!-- Typography controls -->
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
<div class="flex flex-col mt-1.5">
<ComboControlV2
control={typography.weightControl}
orientation="horizontal"
class="sm:py-0 sm:px-0 mb-5 sm:mb-1.5"
label="font weight"
showScale={false}
reduced
/> />
{/snippet}
{#snippet Controls(className: string, orientation: Orientation)} <ComboControlV2
{#if typography.weightControl && typography.sizeControl && typography.heightControl} control={typography.sizeControl}
<div class={className}> orientation="horizontal"
<ComboControlV2 control={typography.weightControl} {orientation} reduced /> class="sm:py-0 sm:px-0 mb-5 sm:mb-1.5"
<ComboControlV2 control={typography.sizeControl} {orientation} reduced /> label="font size"
<ComboControlV2 control={typography.heightControl} {orientation} reduced /> showScale={false}
reduced
/>
<ComboControlV2
control={typography.heightControl}
orientation="horizontal"
class="sm:py-0 sm:px-0"
label="line height"
showScale={false}
reduced
/>
</div> </div>
{/if} {/if}
{/snippet}
<div
class="z-50 will-change-transform"
style:transform="
translateX({xSpring.current}px)
rotateZ({rotateSpring.current}deg)
"
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300, delay: 300 }}
>
{#if staticPosition}
<div class="flex flex-col gap-6">
{@render InputComponent?.('p-6')}
{@render Controls?.('flex flex-col justify-between items-center-safe gap-6', 'horizontal')}
</div>
{:else}
<ExpandableWrapper
bind:element={wrapper}
bind:expanded={isActive}
disabled={isDragging}
aria-label="Font controls"
rotation={side === 'right' ? 'counterclockwise' : 'clockwise'}
class={cn(
'transition-opacity flex items-top gap-1.5',
panelWidth === 0 ? 'opacity-0' : 'opacity-100',
)}
containerClassName={cn(!isActive && 'p-2 sm:p-0')}
>
{#snippet badge()}
<div
class={cn(
'animate-nudge relative transition-all',
side === 'left' ? 'order-2' : 'order-0',
isActive ? 'opacity-0' : 'opacity-100',
isDragging && 'opacity-80 grayscale-[0.2]',
)}
>
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
</div>
{/snippet}
{#snippet visibleContent()}
{@render InputComponent(cn(
'pl-1 sm:pl-3 pr-1 sm:pr-3',
'h-6 sm:h-8 md:h-10',
'rounded-lg',
isActive
? 'h-7 sm:h-8 text-[0.825rem]'
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm sm:text-base font-medium focus-visible:ring-0 text-slate-900/50',
))}
{/snippet}
{#snippet hiddenContent()}
{@render Controls?.('flex flex-row justify-between items-center-safe gap-2 sm:gap-0 h-64', 'vertical')}
{/snippet}
</ExpandableWrapper>
{/if}
</div>

View File

@@ -0,0 +1,102 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import FontSearch from './FontSearch.svelte';
const { Story } = defineMeta({
title: 'Widgets/FontSearch',
component: FontSearch,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Primary search interface with filter panel. Provides a search input and filtering for fonts by provider, category, and subset. Filters can be toggled open/closed with an animated transition.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
showFilters: {
control: 'boolean',
description: 'Controllable flag to show/hide filters (bindable)',
},
},
});
</script>
<script lang="ts">
let showFiltersDefault = $state(true);
let showFiltersClosed = $state(false);
let showFiltersOpen = $state(true);
</script>
<Story name="Default">
<div class="w-full max-w-2xl">
<FontSearch bind:showFilters={showFiltersDefault} />
</div>
</Story>
<Story name="Filters Open">
<div class="w-full max-w-2xl">
<FontSearch bind:showFilters={showFiltersOpen} />
<div class="mt-8 text-center">
<p class="text-text-muted text-sm">Filters panel is open and visible</p>
</div>
</div>
</Story>
<Story name="Filters Closed">
<div class="w-full max-w-2xl">
<FontSearch bind:showFilters={showFiltersClosed} />
<div class="mt-8 text-center">
<p class="text-text-muted text-sm">Filters panel is closed - click the slider icon to open</p>
</div>
</div>
</Story>
<Story name="Full Width">
<div class="w-full px-8">
<FontSearch />
</div>
</Story>
<Story name="In Context" tags={['!autodocs']}>
<div class="w-full max-w-3xl p-8 space-y-6">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold mb-2">Font Browser</h1>
<p class="text-text-muted">Search and filter through our collection of fonts</p>
</div>
<div class="bg-background-20 rounded-2xl p-6">
<FontSearch />
</div>
<div class="mt-8 p-6 bg-background-40 rounded-xl">
<p class="text-text-muted text-center">Font results will appear here...</p>
</div>
</div>
</Story>
<Story name="With Filters Demo">
<div class="w-full max-w-2xl">
<div class="mb-4 p-4 bg-background-40 rounded-lg">
<p class="text-sm text-text-muted">
<strong class="text-foreground">Demo Note:</strong> Click the slider icon to toggle filters. Use the
filter categories to select options. Use the filter controls to reset or apply your selections.
</p>
</div>
<FontSearch />
</div>
</Story>
<Story name="Responsive Behavior">
<div class="w-full">
<div class="mb-4 text-center">
<p class="text-text-muted text-sm">Resize browser to see responsive layout</p>
</div>
<div class="px-4 sm:px-8 md:px-12">
<FontSearch />
</div>
</div>
</Story>

View File

@@ -33,7 +33,7 @@ interface Props {
showFilters?: boolean; showFilters?: boolean;
} }
let { showFilters = $bindable(false) }: Props = $props(); let { showFilters = $bindable(true) }: Props = $props();
onMount(() => { onMount(() => {
/** /**
@@ -77,14 +77,14 @@ function toggleFilters() {
<div class="absolute right-4 top-1/2 translate-y-[-50%] z-10"> <div class="absolute right-4 top-1/2 translate-y-[-50%] z-10">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-px h-5 bg-gray-300/60"></div> <div class="w-px h-5 bg-border-subtle"></div>
<div style:transform="scale({transform.current.scale})"> <div style:transform="scale({transform.current.scale})">
<IconButton onclick={toggleFilters}> <IconButton onclick={toggleFilters}>
{#snippet icon({ className })} {#snippet icon({ className })}
<SlidersHorizontalIcon <SlidersHorizontalIcon
class={cn( class={cn(
className, className,
showFilters ? 'stroke-gray-900 stroke-3' : 'stroke-gray-500', showFilters ? 'stroke-foreground stroke-3' : 'stroke-text-muted',
)} )}
/> />
{/snippet} {/snippet}
@@ -102,14 +102,14 @@ function toggleFilters() {
<div <div
class=" class="
p-3 sm:p-4 md:p-5 rounded-xl p-3 sm:p-4 md:p-5 rounded-xl
backdrop-blur-md bg-white/80 backdrop-blur-md bg-background-80
border border-gray-300/50 border border-border-muted
shadow-[0_1px_3px_rgba(0,0,0,0.04)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]
" "
> >
<div class="flex items-center gap-2 sm:gap-2.5 mb-3 sm:mb-4"> <div class="flex items-center gap-2 sm:gap-2.5 mb-3 sm:mb-4">
<div class="w-1 h-1 rounded-full bg-gray-900 opacity-70"></div> <div class="w-1 h-1 rounded-full bg-foreground opacity-70"></div>
<div class="w-px h-2.5 bg-gray-300/60"></div> <div class="w-px h-2.5 bg-border-subtle"></div>
<Footnote> <Footnote>
filter_params filter_params
</Footnote> </Footnote>
@@ -119,7 +119,7 @@ function toggleFilters() {
<Filters /> <Filters />
</div> </div>
<div class="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-300/40"> <div class="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-border-subtle">
<FilterControls class="m-auto w-fit" /> <FilterControls class="m-auto w-fit" />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,89 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import SampleList from './SampleList.svelte';
const { Story } = defineMeta({
title: 'Widgets/SampleList',
component: SampleList,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Virtualized font list with pagination. Renders a list of fonts with auto-loading when scrolling near the bottom. Includes a typography menu for font setup that appears when scrolling past the middle of the viewport.',
},
story: { inline: false },
},
layout: 'fullscreen',
},
argTypes: {
// This component uses internal stores, so no direct props to document
},
});
</script>
<Story name="Default">
<div class="min-h-screen bg-background">
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold mb-2">Font Samples</h1>
<p class="text-text-muted">Scroll to see more fonts and load additional pages</p>
</div>
<SampleList />
</div>
</div>
</Story>
<Story name="Full Page">
<div class="min-h-screen bg-background">
<SampleList />
</div>
</Story>
<Story name="With Typography Controls">
<div class="min-h-screen bg-background">
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold mb-2">Typography Controls</h1>
<p class="text-text-muted">Scroll down to see the typography menu appear</p>
</div>
<SampleList />
</div>
</div>
</Story>
<Story name="Custom Text">
<div class="min-h-screen bg-background">
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold mb-2">Custom Sample Text</h1>
<p class="text-text-muted">Edit the text in any card to change all samples</p>
</div>
<SampleList />
</div>
</div>
</Story>
<Story name="Pagination Info">
<div class="min-h-screen bg-background">
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold mb-2">Paginated List</h1>
<p class="text-text-muted">Fonts load automatically as you scroll</p>
</div>
<SampleList />
</div>
</div>
</Story>
<Story name="Responsive Layout">
<div class="min-h-screen bg-background">
<div class="max-w-6xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold mb-2">Responsive Sample List</h1>
<p class="text-text-muted">Resize browser to see responsive behavior</p>
</div>
<SampleList />
</div>
</div>
</Story>

View File

@@ -15,6 +15,8 @@ import {
TypographyMenu, TypographyMenu,
controlManager, controlManager,
} from '$features/SetupFont'; } from '$features/SetupFont';
import { throttle } from '$shared/lib/utils';
import { Skeleton } from '$shared/ui';
let text = $state('The quick brown fox jumps over the lazy dog...'); let text = $state('The quick brown fox jumps over the lazy dog...');
let wrapper = $state<HTMLDivElement | null>(null); let wrapper = $state<HTMLDivElement | null>(null);
@@ -23,36 +25,6 @@ let innerHeight = $state(0);
// Is the component above the middle of the viewport? // Is the component above the middle of the viewport?
let isAboveMiddle = $state(false); let isAboveMiddle = $state(false);
const isLoading = $derived(unifiedFontStore.isFetching || unifiedFontStore.isLoading);
/**
* 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();
}
}
/** /**
* Calculate display range for pagination info * Calculate display range for pagination info
*/ */
@@ -62,16 +34,30 @@ const displayRange = $derived.by(() => {
return `Showing ${loadedCount} of ${total} fonts`; return `Showing ${loadedCount} of ${total} fonts`;
}); });
function checkPosition() { const checkPosition = throttle(() => {
if (!wrapper) return; if (!wrapper) return;
const rect = wrapper.getBoundingClientRect(); const rect = wrapper.getBoundingClientRect();
const viewportMiddle = innerHeight / 2; const viewportMiddle = innerHeight / 2;
isAboveMiddle = rect.top < viewportMiddle; isAboveMiddle = rect.top < viewportMiddle;
} }, 100);
</script> </script>
{#snippet skeleton()}
<div class="flex flex-col gap-3 sm:gap-4 p-3 sm:p-4">
{#each Array(5) as _, i}
<div class="flex flex-col gap-1.5 sm:gap-2 p-3 sm:p-4 border rounded-lg sm:rounded-xl border-border-subtle bg-background-40">
<div class="flex items-center justify-between mb-3 sm:mb-4">
<Skeleton class="h-6 sm:h-8 w-1/3" />
<Skeleton class="h-6 sm:h-8 w-6 sm:w-8 rounded-full" />
</div>
<Skeleton class="h-24 sm:h-32 w-full" />
</div>
{/each}
</div>
{/snippet}
<svelte:window <svelte:window
bind:innerHeight bind:innerHeight
onscroll={checkPosition} onscroll={checkPosition}
@@ -80,13 +66,10 @@ function checkPosition() {
<div bind:this={wrapper}> <div bind:this={wrapper}>
<FontVirtualList <FontVirtualList
items={unifiedFontStore.fonts}
total={unifiedFontStore.pagination.total}
onNearBottom={handleNearBottom}
itemHeight={220} itemHeight={220}
useWindowScroll={true} useWindowScroll={true}
weight={controlManager.weight} weight={controlManager.weight}
{isLoading} {skeleton}
> >
{#snippet children({ {#snippet children({
item: font, item: font,
@@ -95,7 +78,12 @@ function checkPosition() {
proximity, proximity,
index, index,
})} })}
<FontListItem {font} {isFullyVisible} {isPartiallyVisible} {proximity}> <FontListItem
{font}
{isFullyVisible}
{isPartiallyVisible}
{proximity}
>
<FontSampler {font} bind:text {index} /> <FontSampler {font} bind:text {index} />
</FontListItem> </FontListItem>
{/snippet} {/snippet}