Compare commits

...

10 Commits

21 changed files with 360 additions and 102 deletions

View File

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

View File

@@ -11,6 +11,7 @@ import {
import { controlManager } from '$features/SetupFont';
import {
ContentEditable,
Footnote,
// IconButton,
} from '$shared/ui';
// import XIcon from '@lucide/svelte/icons/x';
@@ -44,7 +45,7 @@ let {
}: Props = $props();
const fontWeight = $derived(controlManager.weight);
const fontSize = $derived(controlManager.size);
const fontSize = $derived(controlManager.renderedSize);
const lineHeight = $derived(controlManager.height);
const letterSpacing = $derived(controlManager.spacing);
@@ -66,13 +67,13 @@ function removeSample() {
>
<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="flex items-center gap-2 sm:gap-2.5">
<span class="font-mono text-[8px] sm:text-[9px] uppercase tracking-[0.25em] text-gray-500 font-medium">
<Footnote>
typeface_{String(index).padStart(3, '0')}
</span>
</Footnote>
<div class="w-px h-2 sm:h-2.5 bg-gray-300/60"></div>
<span class="font-mono text-[9px] sm:text-[10px] tracking-[0.15em] font-bold uppercase text-gray-900">
<Footnote class="tracking-[0.15em] font-bold text-gray-900">
{font.name}
</span>
</Footnote>
</div>
<!--
@@ -101,20 +102,20 @@ function removeSample() {
</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">
<span class="text-[7px] sm:text-[8px] font-mono text-gray-400 uppercase tracking-wider ml-auto">
<Footnote class="text-[7px] sm:text-[8px] tracking-wider ml-auto">
SZ:{fontSize}PX
</span>
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/40 hidden sm:block"></div>
<span class="text-[7px] sm:text-[8px] font-mono text-gray-400 uppercase tracking-wider">
</Footnote>
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div>
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
WGT:{fontWeight}
</span>
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/40 hidden sm:block"></div>
<span class="text-[7px] sm:text-[8px] font-mono text-gray-400 uppercase tracking-wider">
</Footnote>
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div>
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
LH:{lineHeight?.toFixed(2)}
</span>
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/40 hidden sm:block"></div>
<span class="text-[7px] sm:text-[8px] font-mono text-gray-400 uppercase tracking-wider">
</Footnote>
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div>
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider">
LTR:{letterSpacing}
</span>
</Footnote>
</div>
</div>

View File

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

View File

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

View File

@@ -14,4 +14,5 @@ export {
MIN_LINE_HEIGHT,
} from './const/const';
export { type TypographyControlManager } from '../lib';
export { controlManager } from './state/manager.svelte';

View File

@@ -19,7 +19,9 @@ import {
MIN_LINE_HEIGHT,
} from '../const/const';
const controlData: ControlModel[] = [
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
const controlData: ControlModel<ControlId>[] = [
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,

View File

@@ -31,7 +31,7 @@ $effect(() => {
});
</script>
<div class="py-2 px-10 flex flex-row items-center gap-2">
<div class="sm:py-2 sm:px-10 flex flex-row items-center gap-2">
<div class="flex flex-row gap-3">
{#each controlManager.controls as control (control.id)}
<ComboControl
@@ -39,6 +39,7 @@ $effect(() => {
increaseLabel={control.increaseLabel}
decreaseLabel={control.decreaseLabel}
controlLabel={control.controlLabel}
reduced={responsive.isMobile}
/>
{/each}
</div>

View File

@@ -5,7 +5,10 @@
<script lang="ts">
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Section } from '$shared/ui';
import {
Logo,
Section,
} from '$shared/ui';
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
import { FontSearch } from '$widgets/FontSearch';
import { SampleList } from '$widgets/SampleList';
@@ -52,9 +55,7 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
Project_Codename
</span>
{/snippet}
<h2 class={cn('font-[Barlow] font-thin text-5xl sm:text-6xl md:text-7xl lg:text-8xl text-justify [text-align-last:justify] [text-justify:inter-character]')}>
GLYPHDIFF
</h2>
<Logo />
</Section>
<!--
<Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}>

View File

@@ -49,3 +49,5 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
},
};
}
export type PersistentStore<T> = ReturnType<typeof createPersistentStore<T>>;

View File

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

View File

@@ -32,7 +32,10 @@ export {
type LineData,
} from './createCharacterComparison/createCharacterComparison.svelte';
export { createPersistentStore } from './createPersistentStore/createPersistentStore.svelte';
export {
createPersistentStore,
type PersistentStore,
} from './createPersistentStore/createPersistentStore.svelte';
export {
createResponsiveManager,

View File

@@ -14,6 +14,7 @@ export {
type Filter,
type FilterModel,
type LineData,
type PersistentStore,
type Property,
type ResponsiveManager,
responsiveManager,

View File

@@ -26,7 +26,7 @@ import PlusIcon from '@lucide/svelte/icons/plus';
import type { ChangeEventHandler } from 'svelte/elements';
import IconButton from '../IconButton/IconButton.svelte';
interface ComboControlProps {
interface Props {
/**
* Text for increase button aria-label
*/
@@ -43,6 +43,10 @@ interface ComboControlProps {
* Control instance
*/
control: TypographyControl;
/**
* Reduced amount of controls
*/
reduced?: boolean;
}
const {
@@ -50,7 +54,8 @@ const {
decreaseLabel,
increaseLabel,
controlLabel,
}: ComboControlProps = $props();
reduced = false,
}: Props = $props();
// Local state for the slider to prevent infinite loops
// svelte-ignore state_referenced_locally - $state captures initial value, $effect syncs updates
@@ -80,16 +85,18 @@ const handleSliderChange = (newValue: number) => {
<TooltipRoot>
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
<TooltipTrigger class="flex items-center">
<IconButton
onclick={control.decrease}
disabled={control.isAtMin}
aria-label={decreaseLabel}
rotation="counterclockwise"
>
{#snippet icon({ className })}
<MinusIcon class={className} />
{/snippet}
</IconButton>
{#if !reduced}
<IconButton
onclick={control.decrease}
disabled={control.isAtMin}
aria-label={decreaseLabel}
rotation="counterclockwise"
>
{#snippet icon({ className })}
<MinusIcon class={className} />
{/snippet}
</IconButton>
{/if}
<PopoverRoot>
<PopoverTrigger>
{#snippet child({ props })}
@@ -127,16 +134,18 @@ const handleSliderChange = (newValue: number) => {
</PopoverContent>
</PopoverRoot>
<IconButton
aria-label={increaseLabel}
onclick={control.increase}
disabled={control.isAtMax}
rotation="clockwise"
>
{#snippet icon({ className })}
<PlusIcon class={className} />
{/snippet}
</IconButton>
{#if !reduced}
<IconButton
aria-label={increaseLabel}
onclick={control.increase}
disabled={control.isAtMax}
rotation="clockwise"
>
{#snippet icon({ className })}
<PlusIcon class={className} />
{/snippet}
</IconButton>
{/if}
</TooltipTrigger>
</ButtonGroupRoot>
{#if controlLabel}

View File

@@ -0,0 +1,31 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Footnote from './Footnote.svelte';
const { Story } = defineMeta({
title: 'Shared/Footnote',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Styles footnote text',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
});
</script>
<Story name="Default">
<Footnote>
Footnote
</Footnote>
</Story>
<Story name="With custom render">
<Footnote>
{#snippet render({ class: className })}
<span class={className}>Footnote</span>
{/snippet}
</Footnote>
</Story>

View File

@@ -0,0 +1,30 @@
<!--
Component: Footnote
Provides classes for styling footnotes
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
interface Props {
children?: Snippet;
class?: string;
/**
* Custom render function for full control
*/
render?: Snippet<[{ class: string }]>;
}
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>
{#if render}
{@render render({ class: combinedClasses })}
{:else if children}
<span class={combinedClasses}>
{@render children()}
</span>
{/if}

View File

@@ -0,0 +1,21 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Logo from './Logo.svelte';
const { Story } = defineMeta({
title: 'Shared/Logo',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Projec Logo',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
});
</script>
<Story name="Default">
<Logo />
</Story>

View File

@@ -0,0 +1,19 @@
<!--
Component: Logo
Project logo with apropriate styles
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props {
class?: string;
}
const { class: className }: Props = $props();
const baseClasses =
'font-[Barlow] font-thin text-5xl sm:text-6xl md:text-7xl lg:text-8xl text-justify [text-align-last:justify] [text-justify:inter-character]';
</script>
<h2 class={cn(baseClasses, className)}>
GLYPHDIFF
</h2>

View File

@@ -11,6 +11,7 @@ import {
type FlyParams,
fly,
} from 'svelte/transition';
import { Footnote } from '..';
interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
/**
@@ -91,17 +92,21 @@ $effect(() => {
out:fly={flyParams}
>
<div class="flex flex-col gap-2 sm:gap-3" bind:this={titleContainer}>
<div class="flex items-center gap-2 sm:gap-3 opacity-60">
<div class="flex items-center gap-2 sm:gap-3">
{#if icon}
{@render icon({ className: 'size-3 sm:size-4 stroke-gray-900 stroke-1' })}
<div class="w-px h-2.5 sm:h-3 bg-gray-400/50"></div>
{@render icon({ className: 'size-3 sm:size-4 stroke-gray-900 stroke-1 opacity-60' })}
<div class="w-px h-2.5 sm:h-3 bg-gray-300/60"></div>
{/if}
{#if description}
{@render description({ className: 'font-mono text-[9px] sm:text-[10px] uppercase tracking-[0.2em] text-gray-600' })}
<Footnote>
{#snippet render({ class: className })}
{@render description({ className })}
{/snippet}
</Footnote>
{:else if typeof index === 'number'}
<span class="font-mono text-[9px] sm:text-[10px] uppercase tracking-[0.2em] text-gray-600">
<Footnote>
Component_{String(index).padStart(3, '0')}
</span>
</Footnote>
{/if}
</div>

View File

@@ -4,8 +4,10 @@ export { default as ComboControl } from './ComboControl/ComboControl.svelte';
export { default as ComboControlV2 } from './ComboControlV2/ComboControlV2.svelte';
export { default as ContentEditable } from './ContentEditable/ContentEditable.svelte';
export { default as ExpandableWrapper } from './ExpandableWrapper/ExpandableWrapper.svelte';
export { default as Footnote } from './Footnote/Footnote.svelte';
export { default as IconButton } from './IconButton/IconButton.svelte';
export { default as Loader } from './Loader/Loader.svelte';
export { default as Logo } from './Logo/Logo.svelte';
export { default as SearchBar } from './SearchBar/SearchBar.svelte';
export { default as Section } from './Section/Section.svelte';
export { default as Skeleton } from './Skeleton/Skeleton.svelte';

View File

@@ -13,6 +13,7 @@ import {
import { springySlideFade } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
Footnote,
IconButton,
SearchBar,
} from '$shared/ui';
@@ -107,14 +108,12 @@ function toggleFilters() {
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 opacity-70">
<div class="w-1 h-1 rounded-full bg-gray-900"></div>
<div class="w-px h-2.5 bg-gray-400/50"></div>
<span
class="font-mono text-[9px] sm:text-[10px] uppercase tracking-[0.2em] text-gray-500 font-medium"
>
<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-px h-2.5 bg-gray-300/60"></div>
<Footnote>
filter_params
</span>
</Footnote>
</div>
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">

View File

@@ -26,13 +26,13 @@ const [send, receive] = crossfade({
</script>
<div
class="w-auto fixed bottom-4 sm:bottom-5 left-4 right-4 sm:left-auto sm:right-auto sm:inset-x-0 max-screen z-10 flex justify-center"
class="w-auto fixed bottom-4 sm:bottom-5 left-4 right-4 sm:left-1/2 sm:right-[unset] sm:-translate-x-1/2 sm:inset-x-0 max-screen z-10 flex justify-center"
in:receive={{ key: 'panel' }}
out:send={{ key: 'panel' }}
>
<ItemRoot
variant="outline"
class="w-auto max-w-full sm:max-w-max p-2 sm:p-2.5 rounded-xl sm:rounded-2xl backdrop-blur-lg"
class="w-full sm:w-auto max-w-full sm:max-w-max p-2 sm:p-2.5 rounded-xl sm:rounded-2xl backdrop-blur-lg"
>
<ItemContent class="flex flex-row justify-center items-center max-w-full sm:max-w-max">
<SetupFontMenu />