diff --git a/src/app/styles/app.css b/src/app/styles/app.css index 61d38c6..9883313 100644 --- a/src/app/styles/app.css +++ b/src/app/styles/app.css @@ -37,6 +37,28 @@ --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.705 0.015 286.067); + + --background-20: oklch(1 0 0 / 20%); + --background-40: oklch(1 0 0 / 40%); + --background-60: oklch(1 0 0 / 60%); + --background-80: oklch(1 0 0 / 80%); + --background-95: oklch(1 0 0 / 95%); + --background-subtle: oklch(0.98 0 0); + --background-muted: oklch(0.97 0.002 286.375); + + --text-muted: oklch(0.552 0.016 285.938); + --text-subtle: oklch(0.705 0.015 286.067); + --text-soft: oklch(0.5 0.01 286); + + --border-subtle: oklch(0.95 0.003 286.32); + --border-muted: oklch(0.92 0.004 286.32); + --border-soft: oklch(0.88 0.005 286.32); + + --gradient-from: oklch(0.98 0.002 286.32); + --gradient-via: oklch(1 0 0); + --gradient-to: oklch(0.98 0.002 286.32); + + --font-mono: 'Major Mono Display'; } .dark { @@ -71,6 +93,26 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.552 0.016 285.938); + + --background-20: oklch(0.21 0.006 285.885 / 20%); + --background-40: oklch(0.21 0.006 285.885 / 40%); + --background-60: oklch(0.21 0.006 285.885 / 60%); + --background-80: oklch(0.21 0.006 285.885 / 80%); + --background-95: oklch(0.21 0.006 285.885 / 95%); + --background-subtle: oklch(0.18 0.005 285.823); + --background-muted: oklch(0.274 0.006 286.033); + + --text-muted: oklch(0.705 0.015 286.067); + --text-subtle: oklch(0.552 0.016 285.938); + --text-soft: oklch(0.8 0.01 286); + + --border-subtle: oklch(1 0 0 / 8%); + --border-muted: oklch(1 0 0 / 10%); + --border-soft: oklch(1 0 0 / 15%); + + --gradient-from: oklch(0.25 0.005 285.885); + --gradient-via: oklch(0.21 0.006 285.885); + --gradient-to: oklch(0.25 0.005 285.885); } @theme inline { @@ -109,6 +151,23 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --color-background-20: var(--background-20); + --color-background-40: var(--background-40); + --color-background-60: var(--background-60); + --color-background-80: var(--background-80); + --color-background-95: var(--background-95); + --color-background-subtle: var(--background-subtle); + --color-background-muted: var(--background-muted); + --color-text-muted: var(--text-muted); + --color-text-subtle: var(--text-subtle); + --color-text-soft: var(--text-soft); + --color-border-subtle: var(--border-subtle); + --color-border-muted: var(--border-muted); + --color-border-soft: var(--border-soft); + --color-gradient-from: var(--gradient-from); + --color-gradient-via: var(--gradient-via); + --color-gradient-to: var(--gradient-to); + --font-mono: var(--font-mono); } @layer base { @@ -166,3 +225,82 @@ .barlow { font-family: "Barlow", system-ui, Inter, Roboto, "Segoe UI", Arial, sans-serif; } + +* { + scrollbar-width: thin; + scrollbar-color: hsl(0 0% 70% / 0.4) transparent; +} + +.dark * { + scrollbar-color: hsl(0 0% 40% / 0.5) transparent; +} + +/* ---- Webkit / Blink ---- */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: hsl(0 0% 70% / 0); + border-radius: 3px; + transition: background 0.2s ease; +} + +/* Show thumb when container is hovered or actively scrolling */ +:hover > ::-webkit-scrollbar-thumb, +::-webkit-scrollbar-thumb:hover, +*:hover::-webkit-scrollbar-thumb { + background: hsl(0 0% 70% / 0.4); +} + +::-webkit-scrollbar-thumb:hover { + background: hsl(0 0% 50% / 0.6); +} + +::-webkit-scrollbar-thumb:active { + background: hsl(0 0% 40% / 0.8); +} + +::-webkit-scrollbar-corner { + background: transparent; +} + +/* Dark mode */ +.dark ::-webkit-scrollbar-thumb { + background: hsl(0 0% 40% / 0); +} + +.dark :hover > ::-webkit-scrollbar-thumb, +.dark ::-webkit-scrollbar-thumb:hover, +.dark *:hover::-webkit-scrollbar-thumb { + background: hsl(0 0% 40% / 0.5); +} + +.dark ::-webkit-scrollbar-thumb:hover { + background: hsl(0 0% 55% / 0.6); +} + +.dark ::-webkit-scrollbar-thumb:active { + background: hsl(0 0% 65% / 0.7); +} + +/* ---- Behavior ---- */ +* { + scroll-behavior: smooth; + scrollbar-gutter: stable; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} + +body { + overscroll-behavior-y: none; +} diff --git a/src/app/types/ambient.d.ts b/src/app/types/ambient.d.ts index d2f5b6e..4700e35 100644 --- a/src/app/types/ambient.d.ts +++ b/src/app/types/ambient.d.ts @@ -35,3 +35,16 @@ declare module '*.jpg' { const content: string; export default content; } + +/// + +interface ImportMetaEnv { + readonly DEV: boolean; + readonly PROD: boolean; + readonly MODE: string; + // Add other env variables you use +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/src/app/ui/Layout.svelte b/src/app/ui/Layout.svelte index 021c334..3eacbd1 100644 --- a/src/app/ui/Layout.svelte +++ b/src/app/ui/Layout.svelte @@ -55,30 +55,36 @@ onMount(async () => { - + - - + + + 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" + /> ((e.currentTarget as HTMLLinkElement).media = 'all'))} - > + /> - - Compare Typography & Typefaces | GlyphDiff - + Compare Typography & Typefaces | GlyphDiff @@ -88,7 +94,7 @@ onMount(async () => { -
+
{#if fontsReady} {@render children?.()} diff --git a/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts b/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts index f914c5e..35f474f 100644 --- a/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts +++ b/src/entities/Breadcrumb/model/store/scrollBreadcrumbsStore.svelte.ts @@ -1,7 +1,17 @@ import type { Snippet } from 'svelte'; export interface BreadcrumbItem { + /** + * Index of the item to display + */ index: number; + /** + * ID of the item to navigate to + */ + id?: string; + /** + * Title snippet to render + */ title: Snippet<[{ className?: string }]>; } diff --git a/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte index 1398d23..4aa6fd6 100644 --- a/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte +++ b/src/entities/Breadcrumb/ui/BreadcrumbHeader/BreadcrumbHeader.svelte @@ -3,6 +3,7 @@ Fixed header for breadcrumbs navigation for sections in the page -->
{@render children?.(font)}
diff --git a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte index 0cf1834..c0b6f45 100644 --- a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte +++ b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte @@ -3,58 +3,57 @@ - Renders a virtualized list of fonts - Handles font registration with the manager --> - -{#key isLoading} -
- {#if isLoading} -
- {#each Array(5) as _, i} -
-
- - -
- -
- {/each} -
- {:else} - - {#snippet children(scope)} - {@render children(scope)} - {/snippet} - - {/if} -
-{/key} +
+ {#if skeleton && isLoading && unifiedFontStore.fonts.length === 0} + +
+ {@render skeleton()} +
+ {:else} + + + {#snippet children(scope)} + {@render children(scope)} + {/snippet} + + {/if} +
diff --git a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte index d1a41ed..0e43da1 100644 --- a/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte +++ b/src/features/DisplayFont/ui/FontSampler/FontSampler.svelte @@ -36,12 +36,7 @@ interface Props { letterSpacing?: number; } -let { - font, - text = $bindable(), - index = 0, - ...restProps -}: Props = $props(); +let { font, text = $bindable(), index = 0, ...restProps }: Props = $props(); const fontWeight = $derived(controlManager.weight); const fontSize = $derived(controlManager.renderedSize); @@ -53,22 +48,22 @@ const letterSpacing = $derived(controlManager.spacing); class=" w-full h-full rounded-xl sm:rounded-2xl flex flex-col - backdrop-blur-md bg-white/80 - border border-gray-300/50 + bg-background-80 + border border-border-muted shadow-[0_1px_3px_rgba(0,0,0,0.04)] relative overflow-hidden " style:font-weight={fontWeight} > -
+
typeface_{String(index).padStart(3, '0')} -
- +
+
{font.name} - +
-
+
{#snippet icon({ className })} {/snippet} {#snippet description({ className })} - - Project_Codename - + Project_Codename + {/snippet} + {#snippet content({ className })} +
+ +
{/snippet} -
-
+
{#snippet icon({ className })} {/snippet} @@ -71,10 +85,21 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe Optical
Comparator {/snippet} - + {#snippet content({ className })} +
+ +
+ {/snippet}
-
+
{#snippet icon({ className })} {/snippet} @@ -83,10 +108,21 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe Query
Module {/snippet} - + {#snippet content({ className })} +
+ +
+ {/snippet}
-
+
{#snippet icon({ className })} {/snippet} @@ -95,18 +131,22 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe Sample
Set {/snippet} - + {#snippet content({ className })} +
+ +
+ {/snippet}
diff --git a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts index 8abb64c..d3d426d 100644 --- a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts +++ b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts @@ -2,7 +2,13 @@ * Interface representing a line of text with its measured width. */ export interface LineData { + /** + * Line's text + */ text: string; + /** + * It's width + */ width: number; } @@ -80,16 +86,23 @@ export function createCharacterComparison< container: HTMLElement | undefined, measureCanvas: HTMLCanvasElement | undefined, ) { - if (!container || !measureCanvas || !fontA() || !fontB()) return; + if (!container || !measureCanvas || !fontA() || !fontB()) { + return; + } - const rect = container.getBoundingClientRect(); - containerWidth = rect.width; + // Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues + // getBoundingClientRect() returns transformed dimensions, which causes incorrect line breaking + // when PerspectivePlan applies scale() transforms (e.g., scale(0.5) in settings mode) + const width = container.offsetWidth; + containerWidth = width; // Padding considerations - matches the container padding const padding = window.innerWidth < 640 ? 48 : 96; - const availableWidth = rect.width - padding; + const availableWidth = width - padding; const ctx = measureCanvas.getContext('2d'); - if (!ctx) return; + if (!ctx) { + return; + } const controlledFontSize = size(); const fontSize = getFontSize(); @@ -276,3 +289,5 @@ export function createCharacterComparison< getCharState, }; } + +export type CharacterComparison = ReturnType; diff --git a/src/shared/lib/helpers/createPerspectiveManager/createPerspectiveManager.svelte.ts b/src/shared/lib/helpers/createPerspectiveManager/createPerspectiveManager.svelte.ts new file mode 100644 index 0000000..4a98ca8 --- /dev/null +++ b/src/shared/lib/helpers/createPerspectiveManager/createPerspectiveManager.svelte.ts @@ -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; + + 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); +} diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts index 4b92eb3..9b8ea6a 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts @@ -3,6 +3,7 @@ * * Used to render visible items with absolute positioning based on computed offsets. */ + export interface VirtualItem { /** * Index of the item in the data array @@ -120,9 +121,11 @@ export function createVirtualizer( // 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++) { @@ -130,6 +133,7 @@ export function createVirtualizer( // Accessing measuredSizes here creates the subscription accumulated += measuredSizes[i] ?? options.estimateSize(i); } + return result; }); @@ -144,6 +148,8 @@ export function createVirtualizer( // 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; @@ -185,10 +191,13 @@ export function createVirtualizer( const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd; // Proximity calculation: 1.0 at center, 0.0 at edges + // Guard against division by zero (containerHeight can be 0 on initial render) const itemCenter = itemStart + (itemSize / 2); const distanceToCenter = Math.abs(viewportCenter - itemCenter); const maxDistance = containerHeight / 2; - const proximity = Math.max(0, 1 - (distanceToCenter / maxDistance)); + const proximity = maxDistance > 0 + ? Math.max(0, 1 - (distanceToCenter / maxDistance)) + : 0; result.push({ index: i, @@ -202,16 +211,6 @@ export function createVirtualizer( }); } - // console.log('🎯 Virtual Items Calculation:', { - // scrollOffset, - // containerHeight, - // viewportEnd, - // startIdx, - // endIdx, - // withOverscan: { start, end }, - // itemCount: end - start, - // }); - return result; }); // Svelte Actions (The DOM Interface) @@ -252,25 +251,19 @@ export function createVirtualizer( scrollOffset = scrolledPastTop; rafId = null; }); - - // 🔍 DIAGNOSTIC - // console.log('📜 Scroll Event:', { - // windowScrollY: window.scrollY, - // elementRectTop: rect.top, - // scrolledPastTop, - // containerHeight - // }); }; const handleResize = () => { containerHeight = window.innerHeight; - cachedOffsetTop = getElementOffset(); + elementOffsetTop = getElementOffset(); + cachedOffsetTop = elementOffsetTop; handleScroll(); }; // Initial setup requestAnimationFrame(() => { - cachedOffsetTop = getElementOffset(); + elementOffsetTop = getElementOffset(); + cachedOffsetTop = elementOffsetTop; handleScroll(); }); @@ -289,6 +282,11 @@ export function createVirtualizer( cancelAnimationFrame(rafId); rafId = null; } + // Disconnect shared ResizeObserver + if (sharedResizeObserver) { + sharedResizeObserver.disconnect(); + sharedResizeObserver = null; + } elementRef = null; }, }; @@ -310,6 +308,11 @@ export function createVirtualizer( destroy() { node.removeEventListener('scroll', handleScroll); resizeObserver.disconnect(); + // Disconnect shared ResizeObserver + if (sharedResizeObserver) { + sharedResizeObserver.disconnect(); + sharedResizeObserver = null; + } elementRef = null; }, }; @@ -318,44 +321,67 @@ export function createVirtualizer( let measurementBuffer: Record = {}; 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. * - * Attaches a ResizeObserver to track actual element height and updates - * measured sizes when dimensions change. Requires `data-index` attribute on the element. + * Uses a single shared ResizeObserver for all items to track actual element heights. + * Requires `data-index` attribute on the element. * * @param node - The DOM element to measure (should have `data-index` attribute) * @returns Object with destroy method for cleanup */ function measureElement(node: HTMLElement) { - const resizeObserver = new ResizeObserver(([entry]) => { - if (!entry) return; - const index = parseInt(node.dataset.index || '', 10); - const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; + // Initialize shared observer on first use + if (!sharedResizeObserver) { + sharedResizeObserver = new ResizeObserver(entries => { + // 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)) { - 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 - measurementBuffer[index] = height; + if (!isNaN(index)) { + const oldHeight = measuredSizes[index]; - // Schedule a single update for the next animation frame - if (frameId === null) { - frameId = requestAnimationFrame(() => { - measuredSizes = { ...measuredSizes, ...measurementBuffer }; - // Reset the buffer - measurementBuffer = {}; - frameId = null; - }); + // Only update if the height difference is significant (> 0.5px) + if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) { + measurementBuffer[index] = height; + } } } - } - }); - resizeObserver.observe(node); - return { destroy: () => resizeObserver.disconnect() }; + // Schedule a single update for the next animation frame + if (frameId === null && Object.keys(measurementBuffer).length > 0) { + frameId = requestAnimationFrame(() => { + // Mutation in place for performance + Object.assign(measuredSizes, measurementBuffer); + + // Trigger reactivity + _version += 1; + + // Reset buffer + measurementBuffer = {}; + frameId = null; + }); + } + }); + } + + // Observe this element with the shared observer + sharedResizeObserver.observe(node); + + // Return cleanup that only unobserves this specific element + return { + destroy: () => { + sharedResizeObserver?.unobserve(node); + }, + }; } // Programmatic Scroll @@ -395,6 +421,28 @@ export function createVirtualizer( } } + /** + * 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 { get scrollOffset() { return scrollOffset; @@ -416,6 +464,8 @@ export function createVirtualizer( measureElement, /** Programmatic scroll method to scroll to a specific item */ scrollToIndex, + /** Programmatic scroll method to scroll to a specific pixel offset */ + scrollToOffset, }; } diff --git a/src/shared/lib/helpers/index.ts b/src/shared/lib/helpers/index.ts index 41139b0..d37eb5b 100644 --- a/src/shared/lib/helpers/index.ts +++ b/src/shared/lib/helpers/index.ts @@ -28,6 +28,7 @@ export { } from './createEntityStore/createEntityStore.svelte'; export { + type CharacterComparison, createCharacterComparison, type LineData, } from './createCharacterComparison/createCharacterComparison.svelte'; @@ -42,3 +43,8 @@ export { type ResponsiveManager, responsiveManager, } from './createResponsiveManager/createResponsiveManager.svelte'; + +export { + createPerspectiveManager, + type PerspectiveManager, +} from './createPerspectiveManager/createPerspectiveManager.svelte'; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 99ade48..dc62fee 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,4 +1,5 @@ export { + type CharacterComparison, type ControlDataModel, type ControlModel, createCharacterComparison, @@ -6,6 +7,7 @@ export { createEntityStore, createFilter, createPersistentStore, + createPerspectiveManager, createResponsiveManager, createTypographyControl, createVirtualizer, @@ -15,6 +17,7 @@ export { type FilterModel, type LineData, type PersistentStore, + type PerspectiveManager, type Property, type ResponsiveManager, responsiveManager, @@ -24,7 +27,16 @@ export { type VirtualizerOptions, } from './helpers'; -export { splitArray } from './utils'; +export { + buildQueryString, + clampNumber, + debounce, + getDecimalPlaces, + roundToStepPrecision, + smoothScroll, + splitArray, + throttle, +} from './utils'; export { springySlideFade } from './transitions'; diff --git a/src/shared/lib/utils/index.ts b/src/shared/lib/utils/index.ts index 08a788f..904d58c 100644 --- a/src/shared/lib/utils/index.ts +++ b/src/shared/lib/utils/index.ts @@ -11,4 +11,6 @@ export { clampNumber } from './clampNumber/clampNumber'; export { debounce } from './debounce/debounce'; 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'; diff --git a/src/shared/lib/utils/smoothScroll/smoothScroll.ts b/src/shared/lib/utils/smoothScroll/smoothScroll.ts new file mode 100644 index 0000000..9f3c616 --- /dev/null +++ b/src/shared/lib/utils/smoothScroll/smoothScroll.ts @@ -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); + }, + }; +} diff --git a/src/shared/lib/utils/throttle/throttle.ts b/src/shared/lib/utils/throttle/throttle.ts new file mode 100644 index 0000000..4bb0c90 --- /dev/null +++ b/src/shared/lib/utils/throttle/throttle.ts @@ -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 any>( + fn: T, + wait: number, +): (...args: Parameters) => void { + let lastCall = 0; + let timeoutId: ReturnType | null = null; + + return (...args: Parameters) => { + 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); + } + }; +} diff --git a/src/shared/shadcn/ui/slider/slider.svelte b/src/shared/shadcn/ui/slider/slider.svelte index 49e30bc..c3c0de4 100644 --- a/src/shared/shadcn/ui/slider/slider.svelte +++ b/src/shared/shadcn/ui/slider/slider.svelte @@ -44,7 +44,7 @@ let { {/each} {/snippet} diff --git a/src/shared/ui/ComboControl/ComboControl.svelte b/src/shared/ui/ComboControl/ComboControl.svelte index 78eeb7d..080380c 100644 --- a/src/shared/ui/ComboControl/ComboControl.svelte +++ b/src/shared/ui/ComboControl/ComboControl.svelte @@ -103,7 +103,7 @@ const handleSliderChange = (newValue: number) => { diff --git a/src/shared/ui/Input/Input.stories.svelte b/src/shared/ui/Input/Input.stories.svelte index 7ad789b..ed7fbd1 100644 --- a/src/shared/ui/Input/Input.stories.svelte +++ b/src/shared/ui/Input/Input.stories.svelte @@ -8,10 +8,11 @@ const { Story } = defineMeta({ parameters: { docs: { description: { - component: 'Styles Input component', + component: 'Styled input component with size and variant options', }, story: { inline: false }, // Render stories in iframe for state isolation }, + layout: 'centered', }, argTypes: { placeholder: { @@ -22,21 +23,76 @@ const { Story } = defineMeta({ control: 'text', description: "input's value", }, + variant: { + control: 'select', + options: ['default', 'ghost'], + description: 'Visual style variant', + }, + size: { + control: 'select', + options: ['sm', 'md', 'lg'], + description: 'Size variant', + }, }, }); - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Small + +
+
+ Medium + +
+
+ Large + +
+
diff --git a/src/shared/ui/Input/Input.svelte b/src/shared/ui/Input/Input.svelte index da1e393..818331e 100644 --- a/src/shared/ui/Input/Input.svelte +++ b/src/shared/ui/Input/Input.svelte @@ -2,60 +2,89 @@ Component: Input Provides styled input component with all the shadcn input props --> - + - diff --git a/src/shared/ui/Input/index.ts b/src/shared/ui/Input/index.ts new file mode 100644 index 0000000..68249c7 --- /dev/null +++ b/src/shared/ui/Input/index.ts @@ -0,0 +1,13 @@ +import type { ComponentProps } from 'svelte'; +import Input from './Input.svelte'; + +type InputProps = ComponentProps; +type InputSize = InputProps['size']; +type InputVariant = InputProps['variant']; + +export { + Input, + type InputProps, + type InputSize, + type InputVariant, +}; diff --git a/src/shared/ui/Label/Label.svelte b/src/shared/ui/Label/Label.svelte new file mode 100644 index 0000000..6265b3a --- /dev/null +++ b/src/shared/ui/Label/Label.svelte @@ -0,0 +1,45 @@ + + +
+ {#if align !== 'left'} +
+ {/if} +
+ {text} +
+ {#if align !== 'right'} +
+ {/if} +
diff --git a/src/shared/ui/Loader/Loader.svelte b/src/shared/ui/Loader/Loader.svelte index 2789f74..2b976d6 100644 --- a/src/shared/ui/Loader/Loader.svelte +++ b/src/shared/ui/Loader/Loader.svelte @@ -31,7 +31,7 @@ let { size = 20, class: className = '', message = 'analyzing_data' }: Props = $p out:fade={{ duration: 300 }} >
- + @@ -68,10 +68,10 @@ let { size = 20, class: className = '', message = 'analyzing_data' }: Props = $p
-
+
- + {message}
diff --git a/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte b/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte new file mode 100644 index 0000000..ceb200e --- /dev/null +++ b/src/shared/ui/PerspectivePlan/PerspectivePlan.svelte @@ -0,0 +1,83 @@ + + + +
+ {@render children({ className: isVisible ? 'visible' : 'hidden' })} +
diff --git a/src/shared/ui/SearchBar/SearchBar.svelte b/src/shared/ui/SearchBar/SearchBar.svelte index caf91ac..9af1cd5 100644 --- a/src/shared/ui/SearchBar/SearchBar.svelte +++ b/src/shared/ui/SearchBar/SearchBar.svelte @@ -35,5 +35,10 @@ let {
- +
diff --git a/src/shared/ui/Section/Section.svelte b/src/shared/ui/Section/Section.svelte index f8fa12c..9d0c7fd 100644 --- a/src/shared/ui/Section/Section.svelte +++ b/src/shared/ui/Section/Section.svelte @@ -14,6 +14,10 @@ import { import { Footnote } from '..'; interface Props extends Omit, 'title'> { + /** + * ID of the section + */ + id?: string; /** * Additional CSS classes to apply to the section container. */ @@ -40,19 +44,52 @@ interface Props extends Omit, 'title'> { * @param index - Index of the section * @param isPast - Whether the section is past the current scroll position * @param title - Snippet for a title itself + * @param id - ID of the section * @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 */ - 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(); -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 let isScrolledPast = $state(false); @@ -62,18 +99,21 @@ $effect(() => { return; } let cleanup: ((index: number) => void) | undefined; - const observer = new IntersectionObserver(entries => { - const entry = entries[0]; - const isPast = !entry.isIntersecting && entry.boundingClientRect.top < 0; + const observer = new IntersectionObserver( + entries => { + const entry = entries[0]; + const isPast = !entry.isIntersecting && entry.boundingClientRect.top < 0; - if (isPast !== isScrolledPast) { - isScrolledPast = isPast; - cleanup = onTitleStatusChange?.(index, isPast, title); - } - }, { - // Set threshold to 0 to trigger exactly when the last pixel leaves - threshold: 0, - }); + if (isPast !== isScrolledPast) { + isScrolledPast = isPast; + cleanup = onTitleStatusChange?.(index, isPast, title, id); + } + }, + { + // Set threshold to 0 to trigger exactly when the last pixel leaves + threshold: 0, + }, + ); observer.observe(titleContainer); return () => { @@ -84,19 +124,32 @@ $effect(() => {
-
+
{#if icon} - {@render icon({ className: 'size-3 sm:size-4 stroke-gray-900 stroke-1 opacity-60' })} -
+ {@render icon({ + className: 'size-3 sm:size-4 stroke-foreground stroke-1 opacity-60', +})} +
{/if} + {#if description} {#snippet render({ class: className })} @@ -113,10 +166,14 @@ $effect(() => { {#if title} {@render title({ 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}
- {@render children?.()} + {@render content?.({ + className: stickyTitle + ? 'row-start-2 col-start-2' + : 'row-start-2 col-start-2', +})}
diff --git a/src/shared/ui/SidebarMenu/SidebarMenu.svelte b/src/shared/ui/SidebarMenu/SidebarMenu.svelte new file mode 100644 index 0000000..0d7b5b7 --- /dev/null +++ b/src/shared/ui/SidebarMenu/SidebarMenu.svelte @@ -0,0 +1,99 @@ + + + + + +
+ {@render action?.()} + {#if visible} +
+ {@render children?.()} +
+ + +
+
+ {/if} +
diff --git a/src/shared/ui/Skeleton/Skeleton.stories.svelte b/src/shared/ui/Skeleton/Skeleton.stories.svelte index 426efb8..24c3635 100644 --- a/src/shared/ui/Skeleton/Skeleton.stories.svelte +++ b/src/shared/ui/Skeleton/Skeleton.stories.svelte @@ -30,7 +30,7 @@ const { Story } = defineMeta({ }} >
-
+
diff --git a/src/shared/ui/Skeleton/Skeleton.svelte b/src/shared/ui/Skeleton/Skeleton.svelte index 0e28399..0cf9bd1 100644 --- a/src/shared/ui/Skeleton/Skeleton.svelte +++ b/src/shared/ui/Skeleton/Skeleton.svelte @@ -18,7 +18,7 @@ let { class: className, animate = true, ...rest }: Props = $props();
& { - /** - * Slider value, numeric. - */ - value: number; - /** - * A callback function called when the value changes. - * @param newValue - number - */ - onValueChange?: (newValue: number) => void; - /** - * A callback function called when the user stops dragging the thumb and the value is committed. - * @param newValue - number - */ - onValueCommit?: (newValue: number) => void; -}; +type Props = + & Omit< + SliderRootProps, + 'type' | 'onValueChange' | 'onValueCommit' + > + & { + /** + * Slider value, numeric. + */ + value: number; + /** + * Optional label displayed inline on the track before the filled range. + */ + label?: string; + /** + * A callback function called when the value changes. + * @param newValue - number + */ + onValueChange?: (newValue: number) => void; + /** + * A callback function called when the user stops dragging the thumb and the value is committed. + * @param newValue - number + */ + 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(); {#snippet children(props)} + {#if label && orientation === 'horizontal'} + + {label} + + {/if} - -
- {#snippet renderLine(line: LineData, index: number)} + {@const pos = sliderPos} + {@const element = lineElements[index]}
- {#each line.text.split('') as char, charIndex} - {@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)} + {#each line.text.split('') as char, index} + {@const { proximity, isPast } = charComparison.getCharState(index, pos, element, container)} {#if fontA && fontB} - + {/if} {/each}
@@ -176,59 +199,80 @@ $effect(() => { -
-
- - {#if isLoading} -
- -
- {:else} + +
+ {#if isLoading} +
+ +
+ {:else} + +
- {#each charComparison.lines as line, lineIndex} -
- {@render renderLine(line, lineIndex)} -
- {/each} -
+
+ {#each charComparison.lines as line, lineIndex} +
+ {@render renderLine(line, lineIndex)} +
+ {/each} +
- - {/if} -
- - + + {#if !isInSettingsMode} + + {/if} +
+ + + + {/if}
diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/CharacterSlot.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/CharacterSlot.svelte index 3f174f1..3c6ce24 100644 --- a/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/CharacterSlot.svelte +++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/components/CharacterSlot.svelte @@ -3,8 +3,6 @@ Renders a character with particular styling based on proximity, isPast, weight, fontAName, and fontBName. --> {#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) - " - 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'} + 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'} > {char === ' ' ? '\u00A0' : char}
@@ -82,9 +58,9 @@ $effect(() => {