Compare commits

...

15 Commits

Author SHA1 Message Date
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
19 changed files with 322 additions and 166 deletions

View File

@@ -67,6 +67,7 @@
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@tanstack/svelte-query": "^6.0.14"
"@tanstack/svelte-query": "^6.0.14",
"lenis": "^1.3.17"
}
}

View File

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

View File

@@ -2,11 +2,10 @@
Component: FontApplicator
Loads fonts from fontshare with link tag
- 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
-->
<script lang="ts">
import { getFontUrl } from '$entities/Font/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion';
@@ -34,46 +33,23 @@ interface Props {
children?: Snippet;
}
let { font, weight = 400, className, children }: Props = $props();
let element: Element;
let {
font,
weight = 400,
className,
children,
}: Props = $props();
// Track if the user has actually scrolled this into view
let hasEnteredViewport = $state(false);
const status = $derived(appliedFontsManager.getFontStatus(font.id, weight, font.features.isVariable));
$effect(() => {
if (status === 'loaded' || status === 'error') {
hasEnteredViewport = true;
return;
}
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
hasEnteredViewport = true;
const url = getFontUrl(font, weight);
// Touch ensures it's in the queue.
// It's safe to call this even if VirtualList called it
// (Manager dedupes based on key)
if (url) {
appliedFontsManager.touch([{
id: font.id,
const status = $derived(
appliedFontsManager.getFontStatus(
font.id,
weight,
name: font.name,
url,
isVariable: font.features.isVariable,
}]);
}
font.features.isVariable,
),
);
observer.unobserve(element);
}
});
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'));
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
const shouldReveal = $derived(status === 'loaded' || status === 'error');
const transitionClasses = $derived(
prefersReducedMotion.current
@@ -83,12 +59,14 @@ const transitionClasses = $derived(
</script>
<div
bind:this={element}
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
style:font-family={shouldReveal
? `'${font.name}'`
: 'system-ui, -apple-system, sans-serif'}
class={cn(
transitionClasses,
// If reduced motion is on, we skip the transform/blur entirely
!shouldReveal && !prefersReducedMotion.current
!shouldReveal
&& !prefersReducedMotion.current
&& 'opacity-50 scale-[0.95] blur-sm',
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
shouldReveal && 'opacity-100 scale-100 blur-0',

View File

@@ -1,11 +1,6 @@
<!--
Component: FontListItem
Displays a font item and manages its animations
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { Spring } from 'svelte/motion';
import { type UnifiedFont } from '../../model';
interface Props {
@@ -31,51 +26,14 @@ interface Props {
children: Snippet<[font: UnifiedFont]>;
}
const { font, isFullyVisible, isPartiallyVisible, proximity, 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);
}
const { font, children }: Props = $props();
</script>
<div
class={cn('pb-1 will-change-transform')}
style:opacity={bloom.current}
style:transform="
scale({0.92 + (bloom.current * 0.08)})
translateY({(1 - bloom.current) * 10}px)
"
class={cn(
'pb-1 will-change-transform transition-transform duration-200 ease-out',
'hover:scale-[0.98]', // Simple CSS hover effect
)}
>
{@render children?.(font)}
</div>

View File

@@ -53,7 +53,7 @@ const letterSpacing = $derived(controlManager.spacing);
class="
w-full h-full rounded-xl sm:rounded-2xl
flex flex-col
backdrop-blur-md bg-background-80
bg-background-80
border border-border-muted
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
relative overflow-hidden

View File

@@ -0,0 +1,32 @@
import Lenis from 'lenis';
import {
getContext,
setContext,
} from 'svelte';
const LENIS_KEY = Symbol('lenis');
export function createLenisContext() {
let lenis = $state<Lenis | null>(null);
return {
get lenis() {
return lenis;
},
setLenis(instance: Lenis) {
lenis = instance;
},
destroyLenis() {
lenis?.destroy();
lenis = null;
},
};
}
export function setLenisContext(context: ReturnType<typeof createLenisContext>) {
setContext(LENIS_KEY, context);
}
export function getLenisContext() {
return getContext<ReturnType<typeof createLenisContext>>(LENIS_KEY);
}

View File

@@ -120,9 +120,11 @@ export function createVirtualizer<T>(
// By wrapping the getter in $derived, we track everything inside it
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 count = options.count;
// Implicit dependency on version signal
const v = _version;
const result = new Float64Array(count);
let accumulated = 0;
for (let i = 0; i < count; i++) {
@@ -144,6 +146,8 @@ export function createVirtualizer<T>(
// We MUST read options.data here so Svelte knows to re-run
// this derivation when the items array is replaced!
const { count, data } = options;
// Implicit dependency
const v = _version;
if (count === 0 || containerHeight === 0 || !data) return [];
const overscan = options.overscan ?? 5;
@@ -318,6 +322,9 @@ export function createVirtualizer<T>(
let measurementBuffer: Record<number, number> = {};
let frameId: number | null = null;
// Signal to trigger updates when mutating measuredSizes in place
let _version = $state(0);
/**
* Svelte action to measure individual item elements for dynamic height support.
*
@@ -334,18 +341,25 @@ export function createVirtualizer<T>(
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
if (!isNaN(index)) {
// Accessing the version ensures we have the latest state if needed,
// though here we just read the raw object.
const oldHeight = measuredSizes[index];
// 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) {
// Stuff the measurement into a temporary buffer
// Stuff the measurement into a temporary buffer to batch updates
measurementBuffer[index] = height;
// Schedule a single update for the next animation frame
if (frameId === null) {
frameId = requestAnimationFrame(() => {
measuredSizes = { ...measuredSizes, ...measurementBuffer };
// Reset the buffer
// Mutation in place for performance
Object.assign(measuredSizes, measurementBuffer);
// Trigger reactivity
_version += 1;
// Reset buffer
measurementBuffer = {};
frameId = null;
});

View File

@@ -42,3 +42,9 @@ export {
type ResponsiveManager,
responsiveManager,
} from './createResponsiveManager/createResponsiveManager.svelte';
export {
createLenisContext,
getLenisContext,
setLenisContext,
} from './createScrollContext/createScrollContext.svelte';

View File

@@ -5,6 +5,7 @@ export {
createDebouncedState,
createEntityStore,
createFilter,
createLenisContext,
createPersistentStore,
createResponsiveManager,
createTypographyControl,
@@ -13,11 +14,13 @@ export {
type EntityStore,
type Filter,
type FilterModel,
getLenisContext,
type LineData,
type PersistentStore,
type Property,
type ResponsiveManager,
responsiveManager,
setLenisContext,
type TypographyControl,
type VirtualItem,
type Virtualizer,
@@ -27,10 +30,12 @@ export {
export {
buildQueryString,
clampNumber,
debounce,
getDecimalPlaces,
roundToStepPrecision,
smoothScroll,
splitArray,
throttle,
} from './utils';
export { springySlideFade } from './transitions';

View File

@@ -13,3 +13,4 @@ export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
export { smoothScroll } from './smoothScroll/smoothScroll';
export { splitArray } from './splitArray/splitArray';
export { throttle } from './throttle/throttle';

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

@@ -0,0 +1,78 @@
<script lang="ts">
import {
createLenisContext,
setLenisContext,
} from '$shared/lib';
import Lenis from 'lenis';
import type { LenisOptions } from 'lenis';
import { onMount } from 'svelte';
interface Props {
children?: import('svelte').Snippet;
// Lenis options - all optional with sensible defaults
duration?: number;
easing?: (t: number) => number;
smoothWheel?: boolean;
wheelMultiplier?: number;
touchMultiplier?: number;
infinite?: boolean;
orientation?: 'vertical' | 'horizontal';
gestureOrientation?: 'vertical' | 'horizontal' | 'both';
}
let {
children,
duration = 1.2,
easing = t => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel = true,
wheelMultiplier = 1,
touchMultiplier = 2,
infinite = false,
orientation = 'vertical',
gestureOrientation = 'vertical',
}: Props = $props();
const lenisContext = createLenisContext();
setLenisContext(lenisContext);
onMount(() => {
const lenisOptions: LenisOptions = {
duration,
easing,
smoothWheel,
wheelMultiplier,
touchMultiplier,
infinite,
orientation,
gestureOrientation,
// Prevent jitter with virtual scroll
prevent: (node: HTMLElement) => {
// Don't smooth scroll inside elements with data-lenis-prevent
return node.hasAttribute('data-lenis-prevent');
},
};
const lenis = new Lenis(lenisOptions);
lenisContext.setLenis(lenis);
// RAF loop
function raf(time: number) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
// Expose to window for debugging (only in dev)
if (import.meta.env?.DEV) {
(window as any).lenis = lenis;
}
return () => {
lenisContext.destroyLenis();
};
});
</script>
{@render children?.()}

View File

@@ -10,6 +10,7 @@
-->
<script lang="ts" generics="T">
import { createVirtualizer } from '$shared/lib';
import { throttle } from '$shared/lib/utils';
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
@@ -154,10 +155,20 @@ $effect(() => {
}
});
const throttledVisibleChange = throttle((visibleItems: T[]) => {
onVisibleItemsChange?.(visibleItems);
}, 150); // 150ms debounce
const throttledNearBottom = throttle((lastVisibleIndex: number) => {
onNearBottom?.(lastVisibleIndex);
}, 200); // 200ms debounce
$effect(() => {
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)
if (virtualizer.items.length > 0 && onNearBottom) {
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
@@ -165,7 +176,7 @@ $effect(() => {
const itemsRemaining = items.length - lastVisibleItem.index;
if (itemsRemaining <= 5) {
onNearBottom(lastVisibleItem.index);
throttledNearBottom(lastVisibleItem.index);
}
}
});
@@ -180,6 +191,7 @@ $effect(() => {
data-index={item.index}
class="absolute top-0 left-0 w-full will-change-transform"
style:transform="translateY({item.start}px)"
data-lenis-prevent
>
{#if item.index < items.length}
{@render children({
@@ -212,7 +224,6 @@ $effect(() => {
data-index={item.index}
class="absolute top-0 left-0 w-full will-change-transform"
style:transform="translateY({item.start}px)"
animate:flip={{ delay: 0, duration: 300, easing: quintOut }}
>
{#if item.index < items.length}
{@render children({

View File

@@ -13,4 +13,5 @@ export { default as SearchBar } from './SearchBar/SearchBar.svelte';
export { default as Section } from './Section/Section.svelte';
export { default as Skeleton } from './Skeleton/Skeleton.svelte';
export { default as Slider } from './Slider/Slider.svelte';
export { default as SmoothScroll } from './SmoothScroll/SmoothScroll.svelte';
export { default as VirtualList } from './VirtualList/VirtualList.svelte';

View File

@@ -78,8 +78,6 @@ class ComparisonStore {
return;
}
this.#fontsReady = false;
const weight = this.#typography.weight;
const size = this.#typography.renderedSize;
const fontAName = this.#fontA?.name;
@@ -87,11 +85,25 @@ class ComparisonStore {
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(`${weight} ${size}px "${fontAName}"`),
document.fonts.load(`${weight} ${size}px "${fontBName}"`),
document.fonts.load(fontAString),
document.fonts.load(fontBString),
]);
// Step 2: Wait for browser to be ready to render

View File

@@ -3,8 +3,6 @@
Renders a character with particular styling based on proximity, isPast, weight, fontAName, and fontBName.
-->
<script lang="ts">
import { appliedFontsManager } from '$entities/Font';
import { getFontUrl } from '$entities/Font/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '../../../model';
@@ -28,33 +26,6 @@ let { char, proximity, isPast }: Props = $props();
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const typography = $derived(comparisonStore.typography);
$effect(() => {
if (!fontA || !fontB) {
return;
}
const urlA = getFontUrl(fontA, typography.weight);
const urlB = getFontUrl(fontB, typography.weight);
if (!urlA || !urlB) {
return;
}
appliedFontsManager.touch([{
id: fontA.id,
weight: typography.weight,
name: fontA.name,
url: urlA,
isVariable: fontA.features.isVariable,
}, {
id: fontB.id,
weight: typography.weight,
name: fontB.name,
url: urlB,
isVariable: fontB.features.isVariable,
}]);
});
</script>
{#if fontA && fontB}
@@ -67,13 +38,18 @@ $effect(() => {
style:font-weight={typography.weight}
style:font-size={`${typography.renderedSize}px`}
style:transform="
scale({1 + proximity * 0.3})
translateY({-proximity * 12}px)
rotateY({proximity * 25 * (isPast ? -1 : 1)}deg)
scale({1 + proximity * 0.3}) translateY({-proximity * 12}px) rotateY({proximity *
25 *
(isPast ? -1 : 1)}deg)
"
style:filter="brightness({1 + proximity * 0.2}) contrast({1 + proximity * 0.1})"
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'}
style:filter="brightness({1 + proximity * 0.2}) contrast({1 +
proximity * 0.1})"
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}
</span>
@@ -84,7 +60,7 @@ span {
/*
Optimize for performance and smooth transitions.
step-end logic is effectively handled by binary font switching in JS.
*/
*/
transition:
font-family 0.15s ease-out,
color 0.2s ease-out,

View File

@@ -20,11 +20,16 @@ interface Props {
container: HTMLElement;
}
let { sliderPos, isDragging, typographyControls = $bindable<HTMLDivElement | null>(null), container }: Props = $props();
let {
sliderPos,
isDragging,
typographyControls = $bindable<HTMLDivElement | null>(null),
container,
}: Props = $props();
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
const weight = $derived(comparisonStore.typography.weight);
const responsive = getContext<ResponsiveManager>('responsive');
@@ -41,8 +46,18 @@ $effect(() => {
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 };
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]);
});
@@ -73,7 +88,6 @@ $effect(() => {
{/snippet}
</Drawer>
{:else}
{#if !isLoading}
<div class="absolute top-3 sm:top-6 left-3 sm:left-6 z-50">
<TypographyControls
{sliderPos}
@@ -82,11 +96,8 @@ $effect(() => {
containerWidth={container?.clientWidth}
/>
</div>
{/if}
{#if !isLoading}
<div class="absolute bottom-3 sm:bottom-6 md:bottom-8 inset-x-3 sm:inset-x-6 md:inset-x-12">
<SelectComparedFonts {sliderPos} />
</div>
{/if}
{/if}

View File

@@ -15,6 +15,7 @@ import {
TypographyMenu,
controlManager,
} from '$features/SetupFont';
import { throttle } from '$shared/lib/utils';
let text = $state('The quick brown fox jumps over the lazy dog...');
let wrapper = $state<HTMLDivElement | null>(null);
@@ -23,7 +24,9 @@ let innerHeight = $state(0);
// Is the component above the middle of the viewport?
let isAboveMiddle = $state(false);
const isLoading = $derived(unifiedFontStore.isFetching || unifiedFontStore.isLoading);
const isLoading = $derived(
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
);
/**
* Load more fonts by moving to the next page
@@ -62,14 +65,14 @@ const displayRange = $derived.by(() => {
return `Showing ${loadedCount} of ${total} fonts`;
});
function checkPosition() {
const checkPosition = throttle(() => {
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const viewportMiddle = innerHeight / 2;
isAboveMiddle = rect.top < viewportMiddle;
}
}, 100);
</script>
<svelte:window
@@ -95,7 +98,12 @@ function checkPosition() {
proximity,
index,
})}
<FontListItem {font} {isFullyVisible} {isPartiallyVisible} {proximity}>
<FontListItem
{font}
{isFullyVisible}
{isPartiallyVisible}
{proximity}
>
<FontSampler {font} bind:text {index} />
</FontListItem>
{/snippet}

View File

@@ -2459,6 +2459,7 @@ __metadata:
dprint: "npm:^0.50.2"
jsdom: "npm:^27.4.0"
lefthook: "npm:^2.0.13"
lenis: "npm:^1.3.17"
oxlint: "npm:^1.35.0"
playwright: "npm:^1.57.0"
storybook: "npm:^10.1.11"
@@ -2849,6 +2850,24 @@ __metadata:
languageName: node
linkType: hard
"lenis@npm:^1.3.17":
version: 1.3.17
resolution: "lenis@npm:1.3.17"
peerDependencies:
"@nuxt/kit": ">=3.0.0"
react: ">=17.0.0"
vue: ">=3.0.0"
peerDependenciesMeta:
"@nuxt/kit":
optional: true
react:
optional: true
vue:
optional: true
checksum: 10c0/c268da36d5711677b239c7d173bc52775276df08f86f7f89f305c4e02ba4055d8c50ea69125d16c94bb1e1999ccd95f654237d11c6647dc5fdf63aa90515fbfb
languageName: node
linkType: hard
"lightningcss-android-arm64@npm:1.30.2":
version: 1.30.2
resolution: "lightningcss-android-arm64@npm:1.30.2"