refactor(features): rename SetupFont to AdjustTypography + reorganize

Structural:
- Merge factory + singleton from lib/settingsManager and model/state into
  one model/store/typographySettingsStore/ slice
- Drop now-empty lib/ and model/state/ directories

Semantic:
- Rename feature SetupFont -> AdjustTypography (the feature owns
  continuous typography adjustment, not one-time font setup)
- Drop "Manager" from TypographySettingsManager -> TypographySettingsStore
  (class + factory); singleton typographySettingsStore unchanged

All consumers (Character, Line, SampleList, SliderArea, FontSampler,
comparisonStore) updated. Public barrel signature changed: now exports
createTypographySettingsStore and type TypographySettingsStore.
This commit is contained in:
Ilia Mashkov
2026-05-24 18:27:10 +03:00
parent ca382fd43d
commit df59dfda02
18 changed files with 78 additions and 76 deletions
+6
View File
@@ -0,0 +1,6 @@
export {
createTypographySettingsStore,
type TypographySettingsStore,
typographySettingsStore,
} from './model';
export { TypographyMenu } from './ui';
@@ -0,0 +1,5 @@
export {
createTypographySettingsStore,
type TypographySettingsStore,
typographySettingsStore,
} from './store/typographySettingsStore/typographySettingsStore.svelte';
@@ -0,0 +1,326 @@
/**
* Typography control manager
*
* Manages a collection of typography controls (font size, weight, line height,
* letter spacing) with persistent storage. Supports responsive scaling
* through a multiplier system.
*
* The font size control uses a multiplier system to allow responsive scaling
* while preserving the user's base size preference. The multiplier is applied
* when displaying/editing, but the base size is what's stored.
*/
import {
type ControlId,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} from '$entities/Font';
import {
type ControlDataModel,
type ControlModel,
type PersistentStore,
type TypographyControl,
createPersistentStore,
createTypographyControl,
} from '$shared/lib';
import { SvelteMap } from 'svelte/reactivity';
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
/**
* A control with its associated instance
*/
export interface Control extends ControlOnlyFields<ControlId> {
/**
* The reactive typography control instance
*/
instance: TypographyControl;
}
/**
* Storage schema for typography settings
*/
export interface TypographySettings {
/**
* Base font size (User preference, unscaled)
*/
fontSize: number;
/**
* Numeric font weight (100-900)
*/
fontWeight: number;
/**
* Line height multiplier (e.g. 1.5)
*/
lineHeight: number;
/**
* Letter spacing in em/px
*/
letterSpacing: number;
}
/**
* Typography control manager class
*
* Manages multiple typography controls with persistent storage and
* responsive scaling support for font size.
*/
export class TypographySettingsStore {
/**
* Internal map of reactive controls keyed by their identifier
*/
#controls = new SvelteMap<string, Control>();
/**
* Global multiplier for responsive font size scaling
*/
#multiplier = $state(1);
/**
* LocalStorage-backed storage for persistence
*/
#storage: PersistentStore<TypographySettings>;
/**
* The underlying font size before responsive scaling is applied
*/
#baseSize = $state(DEFAULT_FONT_SIZE);
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
this.#storage = storage;
// Initial Load
const saved = storage.value;
this.#baseSize = saved.fontSize;
// Setup Controls
configs.forEach(config => {
const initialValue = this.#getInitialValue(config.id, saved);
this.#controls.set(config.id, {
...config,
instance: createTypographyControl({
...config,
value: initialValue,
}),
});
});
// The Sync Effect (UI -> Storage)
// We access .value explicitly to ensure Svelte 5 tracks the dependency
$effect.root(() => {
$effect(() => {
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
const fontSize = this.#baseSize;
const fontWeight = this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
const lineHeight = this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
const letterSpacing = this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
// Syncing back to storage
this.#storage.value = {
fontSize,
fontWeight,
lineHeight,
letterSpacing,
};
});
// The Font Size Proxy Effect
// This handles the "Multiplier" logic specifically for the Font Size Control
$effect(() => {
const ctrl = this.#controls.get('font_size')?.instance;
if (!ctrl) {
return;
}
// If the user moves the slider/clicks buttons in the UI:
// We update the baseSize (User Intent)
const currentDisplayValue = ctrl.value;
const calculatedBase = currentDisplayValue / this.#multiplier;
// Only update if the difference is significant (prevents rounding jitter)
if (Math.abs(this.#baseSize - calculatedBase) > 0.01) {
this.#baseSize = calculatedBase;
}
});
});
}
/**
* Gets initial value for a control from storage or defaults
*/
#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;
}
/**
* Active scaling factor for the rendered font size
*/
get multiplier() {
return this.#multiplier;
}
/**
* Updates the multiplier and recalculates dependent control values
*/
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 actual pixel value for CSS font-size (baseSize * multiplier)
*/
get renderedSize() {
return this.#baseSize * this.#multiplier;
}
/**
* The raw font size preference before scaling
*/
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;
}
}
/**
* List of all managed typography controls
*/
get controls() {
return Array.from(this.#controls.values());
}
/**
* Reactive instance for weight manipulation
*/
get weightControl() {
return this.#controls.get('font_weight')?.instance;
}
/**
* Reactive instance for size manipulation
*/
get sizeControl() {
return this.#controls.get('font_size')?.instance;
}
/**
* Reactive instance for line-height manipulation
*/
get heightControl() {
return this.#controls.get('line_height')?.instance;
}
/**
* Reactive instance for letter-spacing manipulation
*/
get spacingControl() {
return this.#controls.get('letter_spacing')?.instance;
}
/**
* Current numeric font weight (reactive)
*/
get weight() {
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
}
/**
* Current numeric line height (reactive)
*/
get height() {
return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
}
/**
* Current numeric letter spacing (reactive)
*/
get spacing() {
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
}
/**
* Reset all controls to project-defined defaults
*/
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;
}
}
});
}
}
/**
* Creates a typography control manager
*
* @param configs - Array of control configurations
* @param storageId - Persistent storage identifier
* @returns Typography control manager instance
*/
export function createTypographySettingsStore(
configs: ControlModel<ControlId>[],
storageId: string = 'glyphdiff:typography',
) {
const storage = createPersistentStore<TypographySettings>(storageId, {
fontSize: DEFAULT_FONT_SIZE,
fontWeight: DEFAULT_FONT_WEIGHT,
lineHeight: DEFAULT_LINE_HEIGHT,
letterSpacing: DEFAULT_LETTER_SPACING,
});
return new TypographySettingsStore(configs, storage);
}
/**
* App-wide typography settings singleton, keyed for the comparison view.
*/
export const typographySettingsStore = createTypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
'glyphdiff:comparison:typography',
);
@@ -0,0 +1,724 @@
/**
* @vitest-environment jsdom
*/
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} from '$entities/Font';
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import {
type TypographySettings,
TypographySettingsStore,
} from './typographySettingsStore.svelte';
/**
* Test Strategy for TypographySettingsStore
*
* This test suite validates the TypographySettingsStore state management logic.
* These are unit tests for the manager logic, separate from component rendering.
*
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
* after state changes to test reactive behavior. This is a limitation of unit
* testing Svelte 5 reactive code in Node.js.
*
* Test Coverage:
* 1. Initialization: Loading from storage, creating controls with correct values
* 2. Multiplier System: Changing multiplier updates font size display
* 3. Base Size Proxy: UI changes update #baseSize via the proxy effect
* 4. Storage Sync: Changes to controls sync to storage (via $effect)
* 5. Reset Functionality: Clearing storage resets all controls
* 6. Rendered Size: base * multiplier calculation
* 7. Control Getters: Return correct control instances
*/
// Helper to flush Svelte effects (they run in microtasks)
async function flushEffects() {
await Promise.resolve();
await Promise.resolve();
}
describe('TypographySettingsStore - Unit Tests', () => {
let mockStorage: TypographySettings;
let mockPersistentStore: {
value: TypographySettings;
clear: () => void;
};
const createMockPersistentStore = (initialValue: TypographySettings) => {
let value = initialValue;
return {
get value() {
return value;
},
set value(v: TypographySettings) {
value = v;
},
clear() {
value = {
fontSize: DEFAULT_FONT_SIZE,
fontWeight: DEFAULT_FONT_WEIGHT,
lineHeight: DEFAULT_LINE_HEIGHT,
letterSpacing: DEFAULT_LETTER_SPACING,
};
},
};
};
beforeEach(() => {
// Reset mock storage with default values before each test
mockStorage = {
fontSize: DEFAULT_FONT_SIZE,
fontWeight: DEFAULT_FONT_WEIGHT,
lineHeight: DEFAULT_LINE_HEIGHT,
letterSpacing: DEFAULT_LETTER_SPACING,
};
mockPersistentStore = createMockPersistentStore(mockStorage);
});
describe('Initialization', () => {
it('creates manager with default values from storage', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE);
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
});
it('creates manager with saved values from storage', () => {
mockStorage = {
fontSize: 72,
fontWeight: 700,
lineHeight: 1.8,
letterSpacing: 0.05,
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.baseSize).toBe(72);
expect(manager.weight).toBe(700);
expect(manager.height).toBe(1.8);
expect(manager.spacing).toBe(0.05);
});
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE);
});
it('returns all controls via controls getter', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
const controls = manager.controls;
expect(controls).toHaveLength(4);
expect(controls.map(c => c.id)).toEqual([
'font_size',
'font_weight',
'line_height',
'letter_spacing',
]);
});
it('returns individual controls via specific getters', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.sizeControl).toBeDefined();
expect(manager.weightControl).toBeDefined();
expect(manager.heightControl).toBeDefined();
expect(manager.spacingControl).toBeDefined();
// Control instances have value, min, max, step, isAtMax, isAtMin, increase, decrease
expect(manager.sizeControl).toHaveProperty('value');
expect(manager.weightControl).toHaveProperty('value');
expect(manager.heightControl).toHaveProperty('value');
expect(manager.spacingControl).toHaveProperty('value');
});
it('control instances have expected interface', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
const ctrl = manager.sizeControl!;
expect(typeof ctrl.value).toBe('number');
expect(typeof ctrl.min).toBe('number');
expect(typeof ctrl.max).toBe('number');
expect(typeof ctrl.step).toBe('number');
expect(typeof ctrl.isAtMax).toBe('boolean');
expect(typeof ctrl.isAtMin).toBe('boolean');
expect(typeof ctrl.increase).toBe('function');
expect(typeof ctrl.decrease).toBe('function');
});
});
describe('Multiplier System', () => {
it('has default multiplier of 1', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.multiplier).toBe(1);
});
it('updates multiplier when set', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.multiplier = 0.75;
expect(manager.multiplier).toBe(0.75);
manager.multiplier = 0.5;
expect(manager.multiplier).toBe(0.5);
});
it('does not update multiplier if set to same value', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
const originalSizeValue = manager.sizeControl?.value;
manager.multiplier = 1; // Same as default
expect(manager.sizeControl?.value).toBe(originalSizeValue);
});
it('updates font size control display value when multiplier changes', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Initial state: base = 48, multiplier = 1, display = 48
expect(manager.baseSize).toBe(48);
expect(manager.sizeControl?.value).toBe(48);
// Change multiplier to 0.75
manager.multiplier = 0.75;
// Display should be 48 * 0.75 = 36
expect(manager.sizeControl?.value).toBe(36);
// Change multiplier to 0.5
manager.multiplier = 0.5;
// Display should be 48 * 0.5 = 24
expect(manager.sizeControl?.value).toBe(24);
// Base size should remain unchanged
expect(manager.baseSize).toBe(48);
});
it('updates font size control display value when multiplier increases', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Start with multiplier 0.5
manager.multiplier = 0.5;
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5);
// Increase to 0.75
manager.multiplier = 0.75;
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.75);
// Increase to 1.0
manager.multiplier = 1;
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE);
});
});
describe('Base Size Setter', () => {
it('updates baseSize when set directly', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 72;
expect(manager.baseSize).toBe(72);
});
it('updates size control value when baseSize is set', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 60;
expect(manager.sizeControl?.value).toBe(60);
});
it('applies multiplier to size control when baseSize is set', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.multiplier = 0.5;
manager.baseSize = 60;
expect(manager.sizeControl?.value).toBe(30); // 60 * 0.5
});
});
describe('Rendered Size Calculation', () => {
it('calculates renderedSize as baseSize * multiplier', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 1);
});
it('updates renderedSize when multiplier changes', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.multiplier = 0.5;
expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 0.5);
manager.multiplier = 0.75;
expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 0.75);
});
it('updates renderedSize when baseSize changes', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 72;
expect(manager.renderedSize).toBe(72);
manager.multiplier = 0.5;
expect(manager.renderedSize).toBe(36);
});
});
describe('Base Size Proxy Effect (UI -> baseSize)', () => {
// NOTE: The proxy effect that updates baseSize when the control value changes
// runs in a $effect, which is asynchronous in unit tests. We test the
// synchronous behavior here (baseSize setter) and note that the full
// proxy effect behavior should be tested in E2E tests.
it('does NOT immediately update baseSize from control change (effect is async)', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
const originalBaseSize = manager.baseSize;
// Change the control value directly
manager.sizeControl!.value = 60;
// baseSize is NOT updated immediately because the effect runs in microtasks
expect(manager.baseSize).toBe(originalBaseSize);
});
it('updates baseSize via direct setter (synchronous)', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 60;
expect(manager.baseSize).toBe(60);
expect(manager.sizeControl?.value).toBe(60);
});
});
describe('Storage Sync (Controls -> Storage)', () => {
// NOTE: Storage sync happens via $effect which runs in microtasks.
// In unit tests, we verify the initial sync and test async behavior.
it('has initial values in storage from constructor', () => {
mockStorage = {
fontSize: 60,
fontWeight: 500,
lineHeight: 1.6,
letterSpacing: 0.02,
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Initial values are loaded from storage
expect(manager.baseSize).toBe(60);
expect(manager.weight).toBe(500);
expect(manager.height).toBe(1.6);
expect(manager.spacing).toBe(0.02);
});
it('syncs to storage after effect flush (async)', async () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 72;
// Storage is NOT updated immediately
expect(mockPersistentStore.value.fontSize).toBe(DEFAULT_FONT_SIZE);
// After flushing effects, storage should be updated
await flushEffects();
expect(mockPersistentStore.value.fontSize).toBe(72);
});
it('syncs control changes to storage after effect flush (async)', async () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.weightControl!.value = 700;
// After flushing effects
await flushEffects();
expect(mockPersistentStore.value.fontWeight).toBe(700);
});
it('syncs height control changes to storage after effect flush (async)', async () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.heightControl!.value = 1.8;
await flushEffects();
expect(mockPersistentStore.value.lineHeight).toBe(1.8);
});
it('syncs spacing control changes to storage after effect flush (async)', async () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.spacingControl!.value = 0.05;
await flushEffects();
expect(mockPersistentStore.value.letterSpacing).toBe(0.05);
});
});
describe('Control Value Getters', () => {
it('returns current weight value', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
manager.weightControl!.value = 700;
expect(manager.weight).toBe(700);
});
it('returns current height value', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
manager.heightControl!.value = 1.8;
expect(manager.height).toBe(1.8);
});
it('returns current spacing value', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
manager.spacingControl!.value = 0.05;
expect(manager.spacing).toBe(0.05);
});
it('returns default value when control is not found', () => {
// Create a manager with empty configs (no controls)
const manager = new TypographySettingsStore([], mockPersistentStore);
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
});
});
describe('Reset Functionality', () => {
it('resets all controls to default values', () => {
mockStorage = {
fontSize: 72,
fontWeight: 700,
lineHeight: 1.8,
letterSpacing: 0.05,
};
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Modify values
manager.baseSize = 80;
manager.weightControl!.value = 900;
manager.heightControl!.value = 2.0;
manager.spacingControl!.value = 0.1;
// Reset
manager.reset();
// Check all values are reset to defaults
expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE);
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
});
it('calls storage.clear() on reset', () => {
const clearSpy = vi.fn();
mockPersistentStore = {
get value() {
return mockStorage;
},
set value(v: TypographySettings) {
mockStorage = v;
},
clear: clearSpy,
};
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.reset();
expect(clearSpy).toHaveBeenCalled();
});
it('respects multiplier when resetting font size control', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.multiplier = 0.5;
manager.baseSize = 80;
manager.reset();
// Font size control should show default * multiplier
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5);
expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE);
});
});
describe('Complex Scenarios', () => {
it('handles changing multiplier then modifying baseSize', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Change multiplier
manager.multiplier = 0.5;
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5);
// Change baseSize
manager.baseSize = 60;
expect(manager.sizeControl?.value).toBe(30); // 60 * 0.5
expect(manager.baseSize).toBe(60);
// Change multiplier again
manager.multiplier = 1;
expect(manager.sizeControl?.value).toBe(60); // 60 * 1
expect(manager.baseSize).toBe(60);
});
it('maintains correct renderedSize throughout changes', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Initial: 48 * 1 = 48
expect(manager.renderedSize).toBe(48);
// Change baseSize: 60 * 1 = 60
manager.baseSize = 60;
expect(manager.renderedSize).toBe(60);
// Change multiplier: 60 * 0.5 = 30
manager.multiplier = 0.5;
expect(manager.renderedSize).toBe(30);
// Change baseSize again: 72 * 0.5 = 36
manager.baseSize = 72;
expect(manager.renderedSize).toBe(36);
});
it('handles multiple control changes in sequence', async () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
// Change multiple controls
manager.baseSize = 72;
manager.weightControl!.value = 700;
manager.heightControl!.value = 1.8;
manager.spacingControl!.value = 0.05;
// After flushing effects, verify all are synced to storage
await flushEffects();
expect(mockPersistentStore.value.fontSize).toBe(72);
expect(mockPersistentStore.value.fontWeight).toBe(700);
expect(mockPersistentStore.value.lineHeight).toBe(1.8);
expect(mockPersistentStore.value.letterSpacing).toBe(0.05);
});
});
describe('Edge Cases', () => {
it('handles multiplier of 1 (no change)', () => {
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
mockPersistentStore = createMockPersistentStore(mockStorage);
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.multiplier = 1;
expect(manager.sizeControl?.value).toBe(48);
expect(manager.baseSize).toBe(48);
});
it('handles very small multiplier', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 100;
manager.multiplier = 0.1;
expect(manager.sizeControl?.value).toBe(10);
expect(manager.renderedSize).toBe(10);
});
it('handles large base size with multiplier', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 100;
manager.multiplier = 0.75;
expect(manager.sizeControl?.value).toBe(75);
expect(manager.renderedSize).toBe(75);
});
it('handles floating point precision in multiplier', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
manager.baseSize = 48;
manager.multiplier = 0.5;
// 48 * 0.5 = 24 (exact, no rounding needed)
expect(manager.sizeControl?.value).toBe(24);
expect(manager.renderedSize).toBe(24);
// 48 * 0.33 = 15.84 -> rounds to 16 (step precision is 1)
manager.multiplier = 0.33;
expect(manager.sizeControl?.value).toBe(16);
expect(manager.renderedSize).toBeCloseTo(15.84);
});
it('handles control methods (increase/decrease)', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
const initialWeight = manager.weight;
manager.weightControl!.increase();
expect(manager.weight).toBe(initialWeight + 100);
manager.weightControl!.decrease();
expect(manager.weight).toBe(initialWeight);
});
it('handles control boundary conditions', () => {
const manager = new TypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
mockPersistentStore,
);
const sizeCtrl = manager.sizeControl!;
// Test min boundary
sizeCtrl.value = 5;
expect(sizeCtrl.value).toBe(sizeCtrl.min); // Should clamp to MIN_FONT_SIZE (8)
// Test max boundary
sizeCtrl.value = 200;
expect(sizeCtrl.value).toBe(sizeCtrl.max); // Should clamp to MAX_FONT_SIZE (100)
});
});
});
@@ -0,0 +1,45 @@
<script module>
import Providers from '$shared/lib/storybook/Providers.svelte';
import { defineMeta } from '@storybook/addon-svelte-csf';
import TypographyMenu from './TypographyMenu.svelte';
const { Story } = defineMeta({
title: 'Features/TypographyMenu',
component: TypographyMenu,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Floating typography controls. Mobile/tablet: settings button that opens a popover. Desktop: inline bar with combo controls.',
},
story: { inline: false },
},
layout: 'centered',
storyStage: { maxWidth: 'max-w-xl' },
},
argTypes: {
hidden: { control: 'boolean' },
},
});
</script>
<Story name="Desktop">
{#snippet template()}
<Providers>
<div class="relative h-20 flex items-end justify-center p-4">
<TypographyMenu />
</div>
</Providers>
{/snippet}
</Story>
<Story name="Hidden">
{#snippet template()}
<Providers>
<div class="relative h-20 flex items-end justify-center p-4">
<TypographyMenu hidden={true} />
</div>
</Providers>
{/snippet}
</Story>
@@ -0,0 +1,189 @@
<!--
Component: TypographyMenu
Floating controls bar for typography settings.
Mobile: popover with slider controls anchored to settings button.
Desktop: inline bar with combo controls.
-->
<script lang="ts">
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import {
Button,
ComboControl,
ControlGroup,
Slider,
} from '$shared/ui';
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import XIcon from '@lucide/svelte/icons/x';
import { Popover } from 'bits-ui';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { typographySettingsStore } from '../../model';
interface Props {
/**
* CSS classes
*/
class?: string;
/**
* Hidden state
* @default false
*/
hidden?: boolean;
/**
* Bindable popover open state
* @default false
*/
open?: boolean;
}
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
/**
* Sets the common font size multiplier based on the current responsive state.
*/
$effect(() => {
if (!responsive) {
return;
}
switch (true) {
case responsive.isMobile:
typographySettingsStore.multiplier = MULTIPLIER_S;
break;
case responsive.isTablet:
typographySettingsStore.multiplier = MULTIPLIER_M;
break;
case responsive.isDesktop:
typographySettingsStore.multiplier = MULTIPLIER_L;
break;
default:
typographySettingsStore.multiplier = MULTIPLIER_L;
}
});
</script>
{#if !hidden}
{#if responsive.isMobileOrTablet}
<div class={className}>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button variant="primary" {...props}>
{#snippet icon()}
<Settings2Icon class="size-4" />
{/snippet}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="top"
align="end"
sideOffset={8}
class={cn(
'z-50 w-72',
'bg-surface dark:bg-dark-card',
'border border-subtle',
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
'rounded-none p-4',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=top]:slide-in-from-bottom-2',
'data-[side=bottom]:slide-in-from-top-2',
)}
interactOutsideBehavior="close"
escapeKeydownBehavior="close"
>
<!-- Header -->
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
<div class="flex items-center gap-1.5">
<Settings2Icon size={12} class="text-swiss-red" />
<span
class="text-3xs font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
>
CONTROLS
</span>
</div>
<Popover.Close>
{#snippet child({ props })}
<button
{...props}
class="inline-flex items-center justify-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="Close controls"
>
<XIcon class="size-3.5 text-neutral-500" />
</button>
{/snippet}
</Popover.Close>
</div>
<!-- Controls -->
{#each typographySettingsStore.controls as control (control.id)}
<ControlGroup label={control.controlLabel ?? ''}>
<Slider
bind:value={control.instance.value}
min={control.instance.min}
max={control.instance.max}
step={control.instance.step}
/>
</ControlGroup>
{/each}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
{:else}
<div
class={cn('w-full md:w-auto', className)}
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
>
<div
class={cn(
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
'border border-subtle',
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
)}
>
<!-- Header: icon + label -->
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-subtle mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
<Settings2Icon
size={14}
class="text-swiss-red"
/>
<span
class="text-3xs md:text-2xs font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
>
GLOBAL_CONTROLS
</span>
</div>
<!-- Controls with dividers between each -->
{#each typographySettingsStore.controls as control, i (control.id)}
{#if i > 0}
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
{/if}
<ComboControl
control={control.instance}
label={control.controlLabel}
increaseLabel={control.increaseLabel}
decreaseLabel={control.decreaseLabel}
controlLabel={control.controlLabel}
/>
{/each}
</div>
</div>
{/if}
{/if}
@@ -0,0 +1 @@
export { default as TypographyMenu } from './TypographyMenu/TypographyMenu.svelte';