Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ace4aee07 | |||
| f3a2a6a7bd | |||
| 118c588859 | |||
| 59097ca9ad | |||
| 738ed3b4ed | |||
| 132d1327f5 | |||
| 92ea7b9dc4 | |||
| e55e713517 | |||
| f49180e83d | |||
| 2c3d88c81f | |||
| 0e9288c295 | |||
| dbd48b287d | |||
| f29e0b0c7c | |||
| 91bb046339 | |||
| f680fe01ea | |||
| d37d01e6d8 | |||
| c78b8e032e | |||
| 11d5ba0e63 | |||
| 99e9a1fb2c | |||
| 5084df3914 | |||
| a2ec025a65 | |||
| 8dbea97a33 | |||
| 744cdc9d19 | |||
| 600b905e01 | |||
| 4ad0fe4cfa | |||
| eafe89b313 | |||
| 724b00d3d5 | |||
| c09ca93f4e | |||
| 99ab7e9e08 | |||
| ec488cf1ce | |||
| fe07c60dd4 | |||
| 0aae710e35 | |||
| ded9606c30 | |||
| f0736f4d35 | |||
| 5eb458eabb | |||
| a428eac309 | |||
| 09869aed00 | |||
| 028853aff5 | |||
| 1c6427c586 | |||
| 60e115309c |
@@ -1,3 +1,4 @@
|
||||
import { windowSizeForLine } from '../src/entities/Font/domain/windowSizeForLine/windowSizeForLine';
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
@@ -5,12 +6,22 @@ import {
|
||||
|
||||
test.describe('preview text', () => {
|
||||
test('drives the slider character rendering', async ({ comparison }) => {
|
||||
/**
|
||||
* Must stay a single unwrapped line of ASCII: the assertion feeds
|
||||
* `text.length` (UTF-16 code units) to `windowSizeForLine`, but the
|
||||
* renderer feeds it the line's grapheme count. They match only for
|
||||
* plain ASCII — emoji/combining marks (length > graphemes) or wrapping
|
||||
* (one input string splitting into several lines) silently desync them.
|
||||
*/
|
||||
const text = 'Sphinx';
|
||||
await comparison.pickPair('Inter', 'Roboto');
|
||||
await comparison.setPreviewText('Sphinx');
|
||||
await comparison.setPreviewText(text);
|
||||
|
||||
// Each grapheme renders as a `.char-wrap` cell in the slider once
|
||||
// both fonts are loaded. Six glyphs → six cells.
|
||||
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(6);
|
||||
// Window chars render as `.char-wrap` cells for crossfade. The window
|
||||
// size is a pure function of the line's grapheme count — assert against
|
||||
// the rule, not a hardcoded constant, so tuning the policy can't silently
|
||||
// break this. "Sphinx" is one unwrapped line of 6 graphemes.
|
||||
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(windowSizeForLine(text.length));
|
||||
});
|
||||
|
||||
test('preserves the typed value in the input', async ({ comparison }) => {
|
||||
|
||||
+1
-2
@@ -6,8 +6,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"*.css",
|
||||
"**/router.ts",
|
||||
"**/bindings.svelte.ts"
|
||||
"**/router.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
descendants of this provider.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
@@ -18,6 +18,9 @@ interface Props {
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// First call to the lazy singleton — constructs the shared client for the app.
|
||||
const queryClient = getQueryClient();
|
||||
</script>
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -19,7 +19,9 @@ vi.mock('$shared/api/api', () => ({
|
||||
}));
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { FontResponseError } from '../../lib/errors/errors';
|
||||
import {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { buildQueryString } from '$shared/lib/utils';
|
||||
import type { QueryParams } from '$shared/lib/utils';
|
||||
@@ -26,7 +26,7 @@ import type { UnifiedFont } from '../../model/types';
|
||||
*/
|
||||
export function seedFontCache(fonts: UnifiedFont[]): void {
|
||||
fonts.forEach(font => {
|
||||
queryClient.setQueryData(fontKeys.detail(font.id), font);
|
||||
getQueryClient().setQueryData(fontKeys.detail(font.id), font);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,4 @@ export {
|
||||
findSplitIndex,
|
||||
type LineRenderModel,
|
||||
} from './computeLineRenderModel/computeLineRenderModel';
|
||||
export { windowSizeForLine } from './windowSizeForLine/windowSizeForLine';
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { windowSizeForLine } from './windowSizeForLine';
|
||||
|
||||
describe('windowSizeForLine', () => {
|
||||
it('returns 0 for an empty or non-positive line', () => {
|
||||
expect(windowSizeForLine(0)).toBe(0);
|
||||
expect(windowSizeForLine(-3)).toBe(0);
|
||||
});
|
||||
|
||||
it('floors non-empty short lines at the minimum window of 1', () => {
|
||||
expect(windowSizeForLine(1)).toBe(1);
|
||||
expect(windowSizeForLine(2)).toBe(1);
|
||||
expect(windowSizeForLine(3)).toBe(1);
|
||||
});
|
||||
|
||||
it('scales with round(n / 3) in the mid range', () => {
|
||||
expect(windowSizeForLine(6)).toBe(2);
|
||||
expect(windowSizeForLine(12)).toBe(4);
|
||||
});
|
||||
|
||||
it('caps at the maximum window of 5', () => {
|
||||
expect(windowSizeForLine(15)).toBe(5);
|
||||
expect(windowSizeForLine(16)).toBe(5);
|
||||
expect(windowSizeForLine(100)).toBe(5);
|
||||
});
|
||||
|
||||
it('rounds to nearest at fractional boundaries', () => {
|
||||
// round(4/3)=1, round(5/3)=2, round(13/3)=4, round(14/3)=5
|
||||
expect(windowSizeForLine(4)).toBe(1);
|
||||
expect(windowSizeForLine(5)).toBe(2);
|
||||
expect(windowSizeForLine(13)).toBe(4);
|
||||
expect(windowSizeForLine(14)).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Crossfade-window sizing policy for the dual-font slider.
|
||||
*
|
||||
* The slider renders a band of per-char `Character` cells that opacity-crossfade
|
||||
* between the two fonts; everything outside the band is committed native bulk
|
||||
* text. A fixed band looked wrong on short lines — a 6-grapheme line left almost
|
||||
* no bulk, so nearly the whole line shimmered as per-char DOM. The band size
|
||||
* therefore scales with the line's grapheme count and caps so long lines don't
|
||||
* pay for an oversized per-char DOM band.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fraction of a line's graphemes that sit in the crossfade band.
|
||||
*/
|
||||
const WINDOW_RATIO = 1 / 3;
|
||||
/**
|
||||
* Smallest band for a non-empty line — guarantees at least one crossfading char.
|
||||
*
|
||||
* Accepted tradeoff: short lines now get a band of 1–2, so a fast slider drag
|
||||
* can unmount a char before its ~100ms opacity crossfade finishes, a slight pop.
|
||||
* Worth it for the "bulk committed, small band shimmering" look on short lines;
|
||||
* raising this trades that pop back for less committed bulk.
|
||||
*/
|
||||
const WINDOW_MIN = 1;
|
||||
/**
|
||||
* Largest band regardless of line length — bounds per-char DOM cost.
|
||||
*/
|
||||
const WINDOW_MAX = 5;
|
||||
|
||||
/**
|
||||
* Crossfade window size, in graphemes, for a line of `n` graphemes.
|
||||
* `clamp(round(n / 3), 1, 5)`; an empty/non-positive line gets no window.
|
||||
*/
|
||||
export function windowSizeForLine(n: number): number {
|
||||
if (n <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(WINDOW_MAX, Math.max(WINDOW_MIN, Math.round(n * WINDOW_RATIO)));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
computeLineRenderModel,
|
||||
DualFontLayout,
|
||||
findSplitIndex,
|
||||
windowSizeForLine,
|
||||
} from './domain';
|
||||
export type {
|
||||
ComparisonLine,
|
||||
@@ -19,6 +20,7 @@ export type { FontRowSizeResolverOptions } from './lib';
|
||||
|
||||
export {
|
||||
FontApplicator,
|
||||
FontSampler,
|
||||
FontVirtualList,
|
||||
} from './ui';
|
||||
|
||||
|
||||
@@ -27,18 +27,21 @@ vi.mock('$shared/api/queryClient', async importOriginal => {
|
||||
*/
|
||||
const { QueryClient } = await import('@tanstack/query-core');
|
||||
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
|
||||
const mockClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||
});
|
||||
return {
|
||||
...actual,
|
||||
queryClient: new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||
}),
|
||||
getQueryClient: () => mockClient,
|
||||
};
|
||||
});
|
||||
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import { fetchProxyFonts } from '../../../api';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
||||
|
||||
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
DEFAULT_QUERY_GC_TIME_MS,
|
||||
DEFAULT_QUERY_STALE_TIME_MS,
|
||||
queryClient,
|
||||
getQueryClient,
|
||||
} from '$shared/api/queryClient';
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
import {
|
||||
type InfiniteData,
|
||||
InfiniteQueryObserver,
|
||||
@@ -46,7 +47,7 @@ export class FontCatalogStore {
|
||||
readonly unknown[],
|
||||
PageParam
|
||||
>;
|
||||
#qc = queryClient;
|
||||
#qc = getQueryClient();
|
||||
#unsubscribe: () => void;
|
||||
|
||||
constructor(params: FontStoreParams = {}) {
|
||||
@@ -483,14 +484,12 @@ export class FontCatalogStore {
|
||||
}
|
||||
}
|
||||
|
||||
let _catalog: FontCatalogStore | undefined;
|
||||
const catalog = createSingleton(
|
||||
() => new FontCatalogStore({ limit: 50 }),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export function getFontCatalog(): FontCatalogStore {
|
||||
return (_catalog ??= new FontCatalogStore({ limit: 50 }));
|
||||
}
|
||||
export const getFontCatalog = catalog.get;
|
||||
|
||||
// test-only reset, so specs don't share a live observer
|
||||
export function __resetFontCatalog() {
|
||||
_catalog?.destroy();
|
||||
_catalog = undefined;
|
||||
}
|
||||
export const __resetFontCatalog = catalog.reset;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import {
|
||||
type FontLoadRequestConfig,
|
||||
@@ -419,18 +420,16 @@ export class FontLifecycleManager {
|
||||
}
|
||||
}
|
||||
|
||||
let _fontLifecycleManager: FontLifecycleManager | undefined;
|
||||
|
||||
/**
|
||||
* App-wide font lifecycle manager, created on first access. Lazy so its
|
||||
* AbortController / FontFace bookkeeping isn't set up at module load.
|
||||
*/
|
||||
export function getFontLifecycleManager(): FontLifecycleManager {
|
||||
return (_fontLifecycleManager ??= new FontLifecycleManager());
|
||||
}
|
||||
const fontLifecycleManager = createSingleton(
|
||||
() => new FontLifecycleManager(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getFontLifecycleManager = fontLifecycleManager.get;
|
||||
|
||||
// test-only reset, so specs don't share loaded-font/eviction state
|
||||
export function __resetFontLifecycleManager() {
|
||||
_fontLifecycleManager?.destroy();
|
||||
_fontLifecycleManager = undefined;
|
||||
}
|
||||
export const __resetFontLifecycleManager = fontLifecycleManager.reset;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import {
|
||||
beforeEach,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK FONT DATA
|
||||
* ============================================================================
|
||||
*
|
||||
* Factory functions and preset mock data for fonts.
|
||||
* Mock font data: factory functions and preset fixtures.
|
||||
* Used in Storybook stories, tests, and development.
|
||||
*
|
||||
* ## Usage
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK DATA HELPERS - MAIN EXPORT
|
||||
* ============================================================================
|
||||
*
|
||||
* Mock data helpers (main export).
|
||||
* Comprehensive mock data for Storybook stories, tests, and development.
|
||||
*
|
||||
* ## Quick Start
|
||||
|
||||
+12
-2
@@ -4,7 +4,7 @@ import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import FontSampler from './FontSampler.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Features/FontSampler',
|
||||
title: 'Entities/Font/FontSampler',
|
||||
component: FontSampler,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
@@ -39,8 +39,8 @@ const { Story } = defineMeta({
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { UnifiedFont } from '$entities/Font';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
|
||||
// Mock fonts for testing
|
||||
const mockArial: UnifiedFont = {
|
||||
@@ -84,6 +84,14 @@ const mockGeorgia: UnifiedFont = {
|
||||
isVariable: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Stand-in for the AdjustTypography store the composing widget injects.
|
||||
const mockTypography = {
|
||||
renderedSize: 48,
|
||||
weight: 400,
|
||||
height: 1.5,
|
||||
spacing: 0,
|
||||
};
|
||||
</script>
|
||||
|
||||
<Story
|
||||
@@ -93,6 +101,7 @@ const mockGeorgia: UnifiedFont = {
|
||||
status: 'loaded',
|
||||
text: 'The quick brown fox jumps over the lazy dog',
|
||||
index: 0,
|
||||
typography: mockTypography,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||
@@ -111,6 +120,7 @@ const mockGeorgia: UnifiedFont = {
|
||||
text:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
|
||||
index: 1,
|
||||
typography: mockTypography,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||
+45
-21
@@ -4,12 +4,6 @@
|
||||
Visual design matches FontCard: sharp corners, red hover accent, header stats.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontApplicator,
|
||||
type FontLoadStatus,
|
||||
type UnifiedFont,
|
||||
} from '$entities/Font';
|
||||
import { getTypographySettingsStore } from '$features/AdjustTypography/model';
|
||||
import {
|
||||
Badge,
|
||||
ContentEditable,
|
||||
@@ -18,6 +12,35 @@ import {
|
||||
Stat,
|
||||
} from '$shared/ui';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type {
|
||||
FontLoadStatus,
|
||||
UnifiedFont,
|
||||
} from '../../model/types';
|
||||
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
|
||||
|
||||
/**
|
||||
* Minimal typography contract this view renders with. The AdjustTypography
|
||||
* store satisfies it structurally; defining it here keeps the entity decoupled
|
||||
* from that feature (no entity -> feature import).
|
||||
*/
|
||||
interface FontSampleTypography {
|
||||
/**
|
||||
* Rendered font size in px
|
||||
*/
|
||||
renderedSize: number;
|
||||
/**
|
||||
* Numeric font weight
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Line-height multiplier
|
||||
*/
|
||||
height: number;
|
||||
/**
|
||||
* Letter spacing
|
||||
*/
|
||||
spacing: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -39,11 +62,15 @@ interface Props {
|
||||
* @default 0
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* Typography settings to render the sample with. Injected by the composing
|
||||
* widget (which owns the AdjustTypography store) so this entity view stays
|
||||
* decoupled from that feature — the same inversion as `status`.
|
||||
*/
|
||||
typography: FontSampleTypography;
|
||||
}
|
||||
|
||||
let { font, status, text = $bindable(), index = 0 }: Props = $props();
|
||||
|
||||
const typographySettingsStore = getTypographySettingsStore();
|
||||
let { font, status, text = $bindable(), index = 0, typography }: Props = $props();
|
||||
|
||||
// Extract provider badge with fallback
|
||||
const providerBadge = $derived(
|
||||
@@ -52,10 +79,10 @@ const providerBadge = $derived(
|
||||
);
|
||||
|
||||
const stats = $derived([
|
||||
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
|
||||
{ label: 'WGT', value: `${typographySettingsStore.weight}` },
|
||||
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
|
||||
{ label: 'LTR', value: `${typographySettingsStore.spacing}` },
|
||||
{ label: 'SZ', value: `${typography.renderedSize}PX` },
|
||||
{ label: 'WGT', value: `${typography.weight}` },
|
||||
{ label: 'LH', value: typography.height.toFixed(2) },
|
||||
{ label: 'LTR', value: `${typography.spacing}` },
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -73,9 +100,8 @@ const stats = $derived([
|
||||
min-h-60
|
||||
rounded-none
|
||||
"
|
||||
style:font-weight={typographySettingsStore.weight}
|
||||
style:font-weight={typography.weight}
|
||||
>
|
||||
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
flex items-center justify-between
|
||||
@@ -136,19 +162,18 @@ const stats = $derived([
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Main content area ──────────────────────────────────────────── -->
|
||||
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
|
||||
<FontApplicator {font} {status}>
|
||||
<ContentEditable
|
||||
bind:text
|
||||
fontSize={typographySettingsStore.renderedSize}
|
||||
lineHeight={typographySettingsStore.height}
|
||||
letterSpacing={typographySettingsStore.spacing}
|
||||
fontSize={typography.renderedSize}
|
||||
lineHeight={typography.height}
|
||||
letterSpacing={typography.spacing}
|
||||
/>
|
||||
</FontApplicator>
|
||||
</div>
|
||||
|
||||
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
|
||||
<!-- Mobile stats footer; md:hidden because the header stats take over above -->
|
||||
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-subtle flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
|
||||
{#each stats as stat, i}
|
||||
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
||||
@@ -160,7 +185,6 @@ const stats = $derived([
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- ── Red hover line ─────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
absolute bottom-0 left-0 right-0
|
||||
@@ -1,7 +1,9 @@
|
||||
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||
|
||||
export {
|
||||
FontApplicator,
|
||||
FontSampler,
|
||||
FontVirtualList,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { comboKey } from './comboKey';
|
||||
|
||||
describe('comboKey', () => {
|
||||
it('derives a key from the two font ids', () => {
|
||||
expect(comboKey({ id: 'x', headerFontId: 'Inter', bodyFontId: 'Lora' })).toBe('Inter|Lora');
|
||||
});
|
||||
it('ignores the surrogate id (content not identity)', () => {
|
||||
const a = comboKey({ id: 'a', headerFontId: 'Inter', bodyFontId: 'Lora' });
|
||||
const b = comboKey({ id: 'b', headerFontId: 'Inter', bodyFontId: 'Lora' });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
it('is order-sensitive on role', () => {
|
||||
expect(comboKey({ id: 'x', headerFontId: 'Lora', bodyFontId: 'Inter' })).toBe('Lora|Inter');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Pairing } from '../types';
|
||||
|
||||
/**
|
||||
* Natural key describing a Pairing's current fonts (not its identity).
|
||||
* Used for URL share-encoding and "is this combo already on the board" checks.
|
||||
* Recomputed on swap; two cards may share a comboKey but never an id.
|
||||
*
|
||||
* @param pairing - The pairing whose fonts form the key (its `id` is ignored).
|
||||
* @returns The `headerFontId|bodyFontId` key.
|
||||
*/
|
||||
export function comboKey(pairing: Pairing): string {
|
||||
return `${pairing.headerFontId}|${pairing.bodyFontId}`;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { createPairing } from './createPairing';
|
||||
|
||||
describe('createPairing', () => {
|
||||
it('builds a pairing from two font ids', () => {
|
||||
const p = createPairing('Inter', 'Lora');
|
||||
expect(p.headerFontId).toBe('Inter');
|
||||
expect(p.bodyFontId).toBe('Lora');
|
||||
});
|
||||
it('generates a unique id each call (duplicates stay distinct)', () => {
|
||||
const a = createPairing('Inter', 'Lora');
|
||||
const b = createPairing('Inter', 'Lora');
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
it('accepts an explicit id for rehydration', () => {
|
||||
expect(createPairing('Inter', 'Lora', 'fixed-id').id).toBe('fixed-id');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Pairing } from '../types';
|
||||
|
||||
/**
|
||||
* Creates a Pairing with a fresh surrogate id (or a supplied one when
|
||||
* rehydrating from storage). The id is identity, never content — two pairings
|
||||
* with the same fonts are still distinct cards.
|
||||
*
|
||||
* @param headerFontId - Font entity id for the header role.
|
||||
* @param bodyFontId - Font entity id for the body role.
|
||||
* @param id - Explicit id for rehydration; defaults to a fresh UUID.
|
||||
* @returns The new Pairing.
|
||||
*/
|
||||
export function createPairing(headerFontId: string, bodyFontId: string, id: string = crypto.randomUUID()): Pairing {
|
||||
return { id, headerFontId, bodyFontId };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { comboKey } from './comboKey/comboKey';
|
||||
export { createPairing } from './createPairing/createPairing';
|
||||
export { nextFocalId } from './nextFocalId/nextFocalId';
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { nextFocalId } from './nextFocalId';
|
||||
|
||||
const ids = ['a', 'b', 'c'];
|
||||
|
||||
describe('nextFocalId', () => {
|
||||
it('steps forward', () => {
|
||||
expect(nextFocalId(ids, 'a', 1)).toBe('b');
|
||||
});
|
||||
it('steps backward', () => {
|
||||
expect(nextFocalId(ids, 'b', -1)).toBe('a');
|
||||
});
|
||||
it('wraps forward at the end', () => {
|
||||
expect(nextFocalId(ids, 'c', 1)).toBe('a');
|
||||
});
|
||||
it('wraps backward at the start', () => {
|
||||
expect(nextFocalId(ids, 'a', -1)).toBe('c');
|
||||
});
|
||||
it('returns the only id when list has one', () => {
|
||||
expect(nextFocalId(['solo'], 'solo', 1)).toBe('solo');
|
||||
});
|
||||
it('returns current when focal id is absent', () => {
|
||||
expect(nextFocalId(ids, 'missing', 1)).toBe('missing');
|
||||
});
|
||||
it('returns null for an empty list', () => {
|
||||
expect(nextFocalId([], 'x', 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The id one step from `currentId` in board order, wrapping at both ends.
|
||||
*
|
||||
* @param orderedIds - Pairing ids in board order.
|
||||
* @param currentId - The currently focal id to step from.
|
||||
* @param direction - +1 for next, -1 for previous.
|
||||
* @returns The neighbouring id (wrapped), `currentId` unchanged if it isn't in
|
||||
* the list, or null for an empty list.
|
||||
*/
|
||||
export function nextFocalId(orderedIds: string[], currentId: string, direction: 1 | -1): string | null {
|
||||
if (orderedIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const i = orderedIds.indexOf(currentId);
|
||||
if (i === -1) {
|
||||
return currentId;
|
||||
}
|
||||
const len = orderedIds.length;
|
||||
const next = (i + direction + len) % len;
|
||||
return orderedIds[next];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type {
|
||||
Pairing,
|
||||
Role,
|
||||
} from './pairing';
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* A slot within a Pairing that a font fills.
|
||||
*/
|
||||
export type Role = 'header' | 'body';
|
||||
|
||||
/**
|
||||
* The atomic unit of comparison: a header font + a body font.
|
||||
* Carries a surrogate `id` (stable for the card's life, never tracks content)
|
||||
* and the two font ids it pairs. Text and typography are global to the Board,
|
||||
* not stored here.
|
||||
*/
|
||||
export interface Pairing {
|
||||
/**
|
||||
* Surrogate key generated at creation, stable for the card's life.
|
||||
* Distinguishes duplicates with identical fonts. Focal/cycling key on this.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Font entity id filling the header role.
|
||||
*/
|
||||
headerFontId: string;
|
||||
/**
|
||||
* Font entity id filling the body role.
|
||||
*/
|
||||
bodyFontId: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
comboKey,
|
||||
createPairing,
|
||||
nextFocalId,
|
||||
} from './domain';
|
||||
export type {
|
||||
Pairing,
|
||||
Role,
|
||||
} from './model/types';
|
||||
@@ -0,0 +1,4 @@
|
||||
export type {
|
||||
Pairing,
|
||||
Role,
|
||||
} from './pairing';
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Re-export of the Pairing identity types. The source of truth lives in
|
||||
* `domain/types` so the pure domain segment can reference them without importing
|
||||
* `model` (FSD+ domain isolation: ui -> model -> domain, never back).
|
||||
*/
|
||||
export type {
|
||||
Pairing,
|
||||
Role,
|
||||
} from '../../domain/types';
|
||||
+13
-13
@@ -15,10 +15,14 @@ import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
} from '$entities/Font';
|
||||
// Deep path (not the root barrel) on purpose: pulls only these pure
|
||||
// constants, not the entity's UI/store graph (+ @tanstack) — keeps this
|
||||
// feature store and its spec light at import. See audit D-1.
|
||||
} from '$entities/Font/model/const/const';
|
||||
import {
|
||||
type PersistentStore,
|
||||
createPersistentStore,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
import type { NumericControl } from '$shared/ui';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
@@ -166,6 +170,7 @@ export class TypographySettingsStore {
|
||||
*/
|
||||
destroy(): void {
|
||||
this.#disposeEffects();
|
||||
this.#storage.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,22 +353,17 @@ export function createTypographySettingsStore(
|
||||
|
||||
export type TypographySettingsStoreInstance = ReturnType<typeof createTypographySettingsStore>;
|
||||
|
||||
let _typographySettingsStore: TypographySettingsStoreInstance | undefined;
|
||||
|
||||
/**
|
||||
* App-wide typography settings store, keyed for the comparison view.
|
||||
* Created on first access so its persistent-store sync effects aren't set up
|
||||
* at module load.
|
||||
*/
|
||||
export function getTypographySettingsStore(): TypographySettingsStoreInstance {
|
||||
return (_typographySettingsStore ??= createTypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
COMPARISON_STORAGE_KEY,
|
||||
));
|
||||
}
|
||||
const typographySettingsStore = createSingleton(
|
||||
() => createTypographySettingsStore(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, COMPARISON_STORAGE_KEY),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getTypographySettingsStore = typographySettingsStore.get;
|
||||
|
||||
// test-only reset, so specs don't share persisted typography state or leak effects
|
||||
export function __resetTypographySettingsStore() {
|
||||
_typographySettingsStore?.destroy();
|
||||
_typographySettingsStore = undefined;
|
||||
}
|
||||
export const __resetTypographySettingsStore = typographySettingsStore.reset;
|
||||
|
||||
+4
-1
@@ -6,7 +6,7 @@ import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
} from '$entities/Font';
|
||||
} from '$entities/Font/model/const/const';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -51,6 +51,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
||||
let mockPersistentStore: {
|
||||
value: TypographySettings;
|
||||
clear: () => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
const createMockPersistentStore = (initialValue: TypographySettings) => {
|
||||
@@ -70,6 +71,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||
};
|
||||
},
|
||||
destroy() {},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -535,6 +537,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
||||
mockStorage = v;
|
||||
},
|
||||
clear: clearSpy,
|
||||
destroy() {},
|
||||
};
|
||||
|
||||
const manager = new TypographySettingsStore(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
|
||||
/**
|
||||
* Scroll-based breadcrumb tracking store
|
||||
*
|
||||
@@ -279,17 +281,15 @@ export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
||||
return new ScrollBreadcrumbsStore();
|
||||
}
|
||||
|
||||
let _scrollBreadcrumbsStore: ScrollBreadcrumbsStore | undefined;
|
||||
|
||||
/**
|
||||
* App-wide scroll breadcrumbs store, created on first access.
|
||||
*/
|
||||
export function getScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
||||
return (_scrollBreadcrumbsStore ??= createScrollBreadcrumbsStore());
|
||||
}
|
||||
const scrollBreadcrumbsStore = createSingleton(
|
||||
() => createScrollBreadcrumbsStore(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getScrollBreadcrumbsStore = scrollBreadcrumbsStore.get;
|
||||
|
||||
// test-only reset, so specs don't share observer/scroll state
|
||||
export function __resetScrollBreadcrumbsStore() {
|
||||
_scrollBreadcrumbsStore?.destroy();
|
||||
_scrollBreadcrumbsStore = undefined;
|
||||
}
|
||||
export const __resetScrollBreadcrumbsStore = scrollBreadcrumbsStore.reset;
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
import {
|
||||
createPersistentStore,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
|
||||
export const STORAGE_KEY = 'glyphdiff:theme';
|
||||
|
||||
@@ -125,6 +128,7 @@ class ThemeManager {
|
||||
destroy(): void {
|
||||
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
||||
this.#mediaQuery = null;
|
||||
this.#store.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,23 +198,18 @@ class ThemeManager {
|
||||
}
|
||||
}
|
||||
|
||||
let _themeManager: ThemeManager | undefined;
|
||||
|
||||
/**
|
||||
* App-wide theme manager, created on first access.
|
||||
*
|
||||
* Lazy so its persistent-store subscription isn't set up at module load.
|
||||
* Call init() on mount and destroy() on unmount (see Layout).
|
||||
*/
|
||||
export function getThemeManager(): ThemeManager {
|
||||
return (_themeManager ??= new ThemeManager());
|
||||
}
|
||||
const themeManager = createSingleton(() => new ThemeManager(), instance => instance.destroy());
|
||||
|
||||
export const getThemeManager = themeManager.get;
|
||||
|
||||
// test-only reset, so specs don't share persisted theme state
|
||||
export function __resetThemeManager() {
|
||||
_themeManager?.destroy();
|
||||
_themeManager = undefined;
|
||||
}
|
||||
export const __resetThemeManager = themeManager.reset;
|
||||
|
||||
/**
|
||||
* ThemeManager class exported for testing purposes
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export { fitColumns } from './lib';
|
||||
export {
|
||||
__resetBoard,
|
||||
type BoardStore,
|
||||
FRAME_ROLE_GAP,
|
||||
getBoard,
|
||||
MAX_COLUMNS,
|
||||
type RoleTypography,
|
||||
} from './model';
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
combineFrameHeight,
|
||||
type CombineFrameHeightInput,
|
||||
fitColumns,
|
||||
type FitColumnsInput,
|
||||
measureRoleHeight,
|
||||
type RoleHeightInput,
|
||||
} from './measure';
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { combineFrameHeight } from './combineFrameHeight';
|
||||
|
||||
describe('combineFrameHeight', () => {
|
||||
it('sums header + gap + body block heights', () => {
|
||||
expect(combineFrameHeight({ headerHeight: 60, bodyHeight: 200, gap: 24 })).toBe(284);
|
||||
});
|
||||
it('omits the gap when one block is empty (zero height)', () => {
|
||||
expect(combineFrameHeight({ headerHeight: 0, bodyHeight: 200, gap: 24 })).toBe(200);
|
||||
expect(combineFrameHeight({ headerHeight: 60, bodyHeight: 0, gap: 24 })).toBe(60);
|
||||
});
|
||||
it('is zero when both blocks are empty', () => {
|
||||
expect(combineFrameHeight({ headerHeight: 0, bodyHeight: 0, gap: 24 })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Inputs for combining a frame's two role blocks into one height.
|
||||
*/
|
||||
export interface CombineFrameHeightInput {
|
||||
/**
|
||||
* Measured header block height in px.
|
||||
*/
|
||||
headerHeight: number;
|
||||
/**
|
||||
* Measured body block height in px.
|
||||
*/
|
||||
bodyHeight: number;
|
||||
/**
|
||||
* Gap in px between the header and body blocks.
|
||||
*/
|
||||
gap: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total focal-frame height: header block + gap + body block. The gap only
|
||||
* applies when both blocks have height — an empty role (no specimen text)
|
||||
* contributes neither height nor a dangling gap.
|
||||
*
|
||||
* @param input - The two block heights and the inter-block gap.
|
||||
* @returns The combined frame height in px.
|
||||
*/
|
||||
export function combineFrameHeight({ headerHeight, bodyHeight, gap }: CombineFrameHeightInput): number {
|
||||
const gapApplies = headerHeight > 0 && bodyHeight > 0;
|
||||
return headerHeight + bodyHeight + (gapApplies ? gap : 0);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { fitColumns } from './fitColumns';
|
||||
|
||||
describe('fitColumns', () => {
|
||||
it('packs as many honest columns as fit, gap-aware', () => {
|
||||
// each needs 600, gap 40, available 1280 -> 1 col=600, 2 cols=1240, 3=1880
|
||||
expect(fitColumns({ naturalWidth: 600, available: 1280, gap: 40, maxColumns: 3 })).toBe(2);
|
||||
});
|
||||
it('never exceeds maxColumns even with room', () => {
|
||||
expect(fitColumns({ naturalWidth: 100, available: 5000, gap: 20, maxColumns: 3 })).toBe(3);
|
||||
});
|
||||
it('never returns less than 1', () => {
|
||||
expect(fitColumns({ naturalWidth: 9000, available: 300, gap: 20, maxColumns: 3 })).toBe(1);
|
||||
});
|
||||
it('fits a column at the exact boundary (inclusive)', () => {
|
||||
// 2 cols: 2*600 + 1*40 = 1240 == available -> fits
|
||||
expect(fitColumns({ naturalWidth: 600, available: 1240, gap: 40, maxColumns: 3 })).toBe(2);
|
||||
// one px short -> only 1
|
||||
expect(fitColumns({ naturalWidth: 600, available: 1239, gap: 40, maxColumns: 3 })).toBe(1);
|
||||
});
|
||||
it('respects a maxColumns of 1 even with unlimited room', () => {
|
||||
expect(fitColumns({ naturalWidth: 100, available: 5000, gap: 20, maxColumns: 1 })).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Inputs for column gating.
|
||||
*/
|
||||
export interface FitColumnsInput {
|
||||
/**
|
||||
* The widest pairing's Pretext natural (shrink-wrap) width in px.
|
||||
*/
|
||||
naturalWidth: number;
|
||||
/**
|
||||
* Total available width in px for the columns row.
|
||||
*/
|
||||
available: number;
|
||||
/**
|
||||
* Gap in px between columns.
|
||||
*/
|
||||
gap: number;
|
||||
/**
|
||||
* Hard cap on columns that still preserve an honest measure (2–3).
|
||||
*/
|
||||
maxColumns: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many equal honest columns fit. Uses the real per-pairing required width
|
||||
* (Pretext shrink-wrap) — the 45–75ch rule is only a fallback bound elsewhere.
|
||||
* `n` columns occupy `n*naturalWidth + (n-1)*gap`. Clamped to [1, maxColumns].
|
||||
*
|
||||
* @param input - Natural width, available width, gap, and column cap.
|
||||
* @returns The number of columns that fit, in [1, maxColumns].
|
||||
*/
|
||||
export function fitColumns({ naturalWidth, available, gap, maxColumns }: FitColumnsInput): number {
|
||||
let fit = 1;
|
||||
for (let n = 2; n <= maxColumns; n++) {
|
||||
if (n * naturalWidth + (n - 1) * gap <= available) {
|
||||
fit = n;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return fit;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
combineFrameHeight,
|
||||
type CombineFrameHeightInput,
|
||||
} from './combineFrameHeight';
|
||||
export {
|
||||
fitColumns,
|
||||
type FitColumnsInput,
|
||||
} from './fitColumns';
|
||||
export {
|
||||
measureRoleHeight,
|
||||
type RoleHeightInput,
|
||||
} from './measureFrameHeight';
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { measureRoleHeight } from './measureFrameHeight';
|
||||
|
||||
describe('measureRoleHeight', () => {
|
||||
it('multiplies pretext line count by sizePx*lineHeight', () => {
|
||||
const layout = vi.fn().mockReturnValue({ lineCount: 3, height: 0 });
|
||||
const prepared = {} as never;
|
||||
// 3 lines * 20px * 1.5 = 90
|
||||
expect(measureRoleHeight({ prepared, maxWidth: 600, sizePx: 20, lineHeight: 1.5 }, layout)).toBe(90);
|
||||
});
|
||||
it('passes width and pixel line-height into pretext layout', () => {
|
||||
const layout = vi.fn().mockReturnValue({ lineCount: 1, height: 0 });
|
||||
measureRoleHeight({ prepared: {} as never, maxWidth: 600, sizePx: 16, lineHeight: 1.25 }, layout);
|
||||
expect(layout).toHaveBeenCalledWith(expect.anything(), 600, 16 * 1.25);
|
||||
});
|
||||
it('returns 0 when the text lays out to zero lines (empty specimen)', () => {
|
||||
const layout = vi.fn().mockReturnValue({ lineCount: 0, height: 0 });
|
||||
expect(measureRoleHeight({ prepared: {} as never, maxWidth: 600, sizePx: 16, lineHeight: 1.5 }, layout))
|
||||
.toBe(0);
|
||||
});
|
||||
it('handles fractional sizes and line-heights without rounding', () => {
|
||||
const layout = vi.fn().mockReturnValue({ lineCount: 2, height: 0 });
|
||||
// 2 * 15.5 * 1.4 = 43.4
|
||||
expect(measureRoleHeight({ prepared: {} as never, maxWidth: 320, sizePx: 15.5, lineHeight: 1.4 }, layout))
|
||||
.toBeCloseTo(43.4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
type PreparedText,
|
||||
layout as pretextLayout,
|
||||
} from '@chenglou/pretext';
|
||||
|
||||
/**
|
||||
* Inputs for measuring one role block's rendered height.
|
||||
*/
|
||||
export interface RoleHeightInput {
|
||||
/**
|
||||
* Pretext-prepared specimen text for this role+font.
|
||||
*/
|
||||
prepared: PreparedText;
|
||||
/**
|
||||
* Available width in px (the focal frame's content width).
|
||||
*/
|
||||
maxWidth: number;
|
||||
/**
|
||||
* Resolved font-size in px.
|
||||
*/
|
||||
sizePx: number;
|
||||
/**
|
||||
* Unitless line-height multiplier.
|
||||
*/
|
||||
lineHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Height in px of a role's text block at the given width, from Pretext's
|
||||
* pure-arithmetic line count.
|
||||
*
|
||||
* Height is `lineCount * sizePx * lineHeight` rather than Pretext's own
|
||||
* `height` so it tracks the CSS box model exactly (line-height as a multiple of
|
||||
* font-size), keeping measurement and render in lockstep — the zero-shift
|
||||
* invariant.
|
||||
*
|
||||
* @param input - Prepared text plus width and resolved type metrics.
|
||||
* @param layout - Pretext layout fn; injectable for unit tests, defaults to
|
||||
* `@chenglou/pretext`'s `layout`.
|
||||
* @returns The block height in px.
|
||||
*/
|
||||
export function measureRoleHeight(input: RoleHeightInput, layout = pretextLayout): number {
|
||||
const { prepared, maxWidth, sizePx, lineHeight } = input;
|
||||
const { lineCount } = layout(prepared, maxWidth, sizePx * lineHeight);
|
||||
return lineCount * sizePx * lineHeight;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* localStorage key for the persisted board (pairings + focal + specimen).
|
||||
*/
|
||||
export const BOARD_STORAGE_KEY = 'glyphdiff:board';
|
||||
|
||||
/**
|
||||
* Per-role typography storage key — header AdjustTypography instance.
|
||||
*/
|
||||
export const HEADER_TYPO_KEY = 'glyphdiff:typo:header';
|
||||
|
||||
/**
|
||||
* Per-role typography storage key — body AdjustTypography instance.
|
||||
*/
|
||||
export const BODY_TYPO_KEY = 'glyphdiff:typo:body';
|
||||
|
||||
/**
|
||||
* Schema version stamped into persisted board state (gates future
|
||||
* migrations / the URL share-state codec).
|
||||
*/
|
||||
export const BOARD_SCHEMA_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Hard cap on side-by-side columns that still preserve an honest measure.
|
||||
*/
|
||||
export const MAX_COLUMNS = 3;
|
||||
|
||||
/**
|
||||
* Vertical gap in px between the header block and the body block within a frame.
|
||||
* Used by frame-height measurement so the reserved height matches the rendered
|
||||
* layout exactly (zero-shift).
|
||||
*/
|
||||
export const FRAME_ROLE_GAP = 24;
|
||||
|
||||
/**
|
||||
* Default shared specimen — one header line + one body paragraph (single
|
||||
* language). Used to seed the board and as the share-state fallback.
|
||||
*/
|
||||
export const DEFAULT_SPECIMEN = {
|
||||
header: 'The Art of Harmonious Type',
|
||||
body:
|
||||
'Good typography is invisible. It guides the eye without calling attention to itself, balancing rhythm, contrast, and proportion so the reader forgets there is a typeface at all and simply reads.',
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
FRAME_ROLE_GAP,
|
||||
MAX_COLUMNS,
|
||||
} from './const/const';
|
||||
export {
|
||||
__resetBoard,
|
||||
type BoardStore,
|
||||
getBoard,
|
||||
type RoleTypography,
|
||||
} from './store/boardStore/boardStore.svelte';
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
/**
|
||||
* Font orchestration is exercised in e2e (needs a real browser/queryClient).
|
||||
* Here we stub the entity's font stores so the board's pure logic stays testable
|
||||
* off the network — only `candidateFontIds` derivation is asserted at this level.
|
||||
*/
|
||||
const mockLifecycle = vi.hoisted(() => ({
|
||||
touch: vi.fn(),
|
||||
pin: vi.fn(),
|
||||
unpin: vi.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Catalog stub with four fonts so the seeding effect has material to pair.
|
||||
* Seeding only fires when storage is empty AND nothing has been added yet, so
|
||||
* the empty/add tests (which never flush before asserting) are unaffected.
|
||||
*/
|
||||
const mockCatalog = vi.hoisted(() => ({
|
||||
fonts: [
|
||||
{ id: 'c0', name: 'C0' },
|
||||
{ id: 'c1', name: 'C1' },
|
||||
{ id: 'c2', name: 'C2' },
|
||||
{ id: 'c3', name: 'C3' },
|
||||
],
|
||||
}));
|
||||
|
||||
/** Mutable resolved-font list the stubbed FontsByIdsStore returns; reset per test. */
|
||||
const mockFonts = vi.hoisted(() => [] as { id: string; name: string }[]);
|
||||
|
||||
vi.mock('$entities/Font', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('$entities/Font')>();
|
||||
class MockFontsByIdsStore {
|
||||
setIds() {}
|
||||
get fonts() {
|
||||
return mockFonts;
|
||||
}
|
||||
get isLoading() {
|
||||
return false;
|
||||
}
|
||||
destroy() {}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
FontsByIdsStore: MockFontsByIdsStore,
|
||||
getFontLifecycleManager: () => mockLifecycle,
|
||||
getFontCatalog: () => mockCatalog,
|
||||
getFontUrl: () => 'https://example.com/font.woff2',
|
||||
};
|
||||
});
|
||||
|
||||
// ensureCanvasFonts needs a real browser canvas; stub it to resolve immediately.
|
||||
// Spread actual so createPersistentStore/getPretextFontString stay real.
|
||||
vi.mock('$shared/lib', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('$shared/lib')>();
|
||||
return { ...actual, ensureCanvasFonts: vi.fn(() => Promise.resolve()) };
|
||||
});
|
||||
|
||||
// Pretext measures via canvas (degenerate in jsdom); stub for deterministic lines.
|
||||
vi.mock('@chenglou/pretext', () => ({
|
||||
prepareWithSegments: vi.fn(() => ({})),
|
||||
layout: vi.fn(() => ({ lineCount: 2, height: 0 })),
|
||||
}));
|
||||
|
||||
import { flushSync } from 'svelte';
|
||||
import {
|
||||
__resetBoard,
|
||||
getBoard,
|
||||
} from './boardStore.svelte';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockFonts.length = 0;
|
||||
__resetBoard();
|
||||
});
|
||||
afterEach(() => __resetBoard());
|
||||
|
||||
describe('boardStore', () => {
|
||||
it('starts empty with no focal', () => {
|
||||
const board = getBoard();
|
||||
expect(board.pairings).toEqual([]);
|
||||
expect(board.focalId).toBeNull();
|
||||
});
|
||||
|
||||
it('adds a pairing and makes the first one focal', () => {
|
||||
const board = getBoard();
|
||||
const p = board.addPairing('Inter', 'Lora');
|
||||
expect(board.pairings).toHaveLength(1);
|
||||
expect(board.focalId).toBe(p.id);
|
||||
expect(board.focal).toEqual(p);
|
||||
});
|
||||
|
||||
it('cycles focal forward with wrap', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
const b = board.addPairing('Roboto', 'Merriweather');
|
||||
board.setFocal(a.id);
|
||||
board.cycle(1);
|
||||
expect(board.focalId).toBe(b.id);
|
||||
board.cycle(1);
|
||||
expect(board.focalId).toBe(a.id);
|
||||
});
|
||||
|
||||
it('cycles focal backward with wrap', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
const b = board.addPairing('Roboto', 'Merriweather');
|
||||
board.setFocal(a.id);
|
||||
board.cycle(-1);
|
||||
expect(board.focalId).toBe(b.id);
|
||||
});
|
||||
|
||||
it('empties the board and clears focal when the last pairing is removed', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
board.removePairing(a.id);
|
||||
expect(board.pairings).toEqual([]);
|
||||
expect(board.focalId).toBeNull();
|
||||
});
|
||||
|
||||
it('duplicates a pairing as a distinct card next to the source', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
const dup = board.duplicate(a.id);
|
||||
expect(dup.id).not.toBe(a.id);
|
||||
expect(dup.headerFontId).toBe('Inter');
|
||||
expect(board.pairings[1].id).toBe(dup.id);
|
||||
});
|
||||
|
||||
it('swaps one role on the focal pairing', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
board.swapFont(a.id, 'body', 'Merriweather');
|
||||
expect(board.focal?.bodyFontId).toBe('Merriweather');
|
||||
});
|
||||
|
||||
it('rewrites the shared specimen (global, not per-pairing)', () => {
|
||||
const board = getBoard();
|
||||
board.addPairing('Inter', 'Lora');
|
||||
board.setSpecimen('header', 'New Header');
|
||||
expect(board.specimen.header).toBe('New Header');
|
||||
});
|
||||
|
||||
it('keeps a focal when the focal pairing is removed', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
const b = board.addPairing('Roboto', 'Merriweather');
|
||||
board.setFocal(a.id);
|
||||
board.removePairing(a.id);
|
||||
expect(board.pairings).toHaveLength(1);
|
||||
expect(board.focalId).toBe(b.id);
|
||||
});
|
||||
|
||||
it('seeds curated pairings from the catalog when storage is empty', () => {
|
||||
const board = getBoard();
|
||||
flushSync(); // let the seed effect run
|
||||
expect(board.pairings.length).toBeGreaterThan(0);
|
||||
expect(board.focalId).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not seed when storage already has pairings', () => {
|
||||
// pre-seed storage so a fresh board rehydrates instead of seeding
|
||||
const first = getBoard();
|
||||
const p = first.addPairing('Inter', 'Lora');
|
||||
__resetBoard();
|
||||
const restored = getBoard();
|
||||
flushSync();
|
||||
expect(restored.pairings).toHaveLength(1);
|
||||
expect(restored.pairings[0].id).toBe(p.id);
|
||||
});
|
||||
|
||||
it('returns fallback 0 before warm, positive height once fonts resolve and warm', async () => {
|
||||
mockFonts.push({ id: 'Inter', name: 'Inter' }, { id: 'Lora', name: 'Lora' });
|
||||
const board = getBoard();
|
||||
const p = board.addPairing('Inter', 'Lora');
|
||||
// Cold: canvas not yet warm -> reserved fallback, never a cold measure.
|
||||
expect(board.frameHeight(p.id, 600)).toBe(0);
|
||||
await vi.waitFor(() => expect(board.measureReady).toBe(true));
|
||||
expect(board.frameHeight(p.id, 600)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('collects every distinct candidate font id for preloading', () => {
|
||||
const board = getBoard();
|
||||
board.addPairing('Inter', 'Lora');
|
||||
board.addPairing('Inter', 'Merriweather'); // Inter deduped
|
||||
expect(new Set(board.candidateFontIds)).toEqual(new Set(['Inter', 'Lora', 'Merriweather']));
|
||||
});
|
||||
|
||||
it('exposes default per-role typography', () => {
|
||||
const board = getBoard();
|
||||
expect(board.typo.header.size).toBeGreaterThan(0);
|
||||
expect(board.typo.header.weight).toBeGreaterThan(0);
|
||||
expect(board.typo.body.leading).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('sets one role typography independently via setTypo', () => {
|
||||
const board = getBoard();
|
||||
board.setTypo('header', { size: 64, weight: 700, leading: 1.1, tracking: -0.02 });
|
||||
expect(board.typo.header.size).toBe(64);
|
||||
expect(board.typo.header.weight).toBe(700);
|
||||
// body untouched
|
||||
expect(board.typo.body.size).not.toBe(64);
|
||||
});
|
||||
|
||||
it('persists and rehydrates pairings, focal, and specimen', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
board.setSpecimen('body', 'Persisted body');
|
||||
__resetBoard();
|
||||
const restored = getBoard();
|
||||
expect(restored.pairings).toHaveLength(1);
|
||||
expect(restored.pairings[0].id).toBe(a.id);
|
||||
expect(restored.focalId).toBe(a.id);
|
||||
expect(restored.specimen.body).toBe('Persisted body');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* CompareBoard store — the board singleton.
|
||||
*
|
||||
* Owns the comparison board's business state: the ordered list of Pairings, the
|
||||
* single focal pairing, and the board-global specimen text (header + body).
|
||||
* Persists to localStorage as a compact, URL-encoding-friendly blob.
|
||||
*
|
||||
* Typography is NOT owned here as an AdjustTypography store (features can't
|
||||
* import sibling features). Instead the board holds plain per-role typography
|
||||
* values, fed in via `setTypo` by `widgets/Board` (dependency inversion).
|
||||
*
|
||||
* Font metadata is resolved + preloaded via the Font entity (candidate
|
||||
* preloading, focal pinning). Frame heights are Pretext-measured behind a
|
||||
* canvas warm-gate (the zero-shift core) and memoized for flicker-free cycling.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
type FontCatalogStore,
|
||||
type FontLifecycleManager,
|
||||
type FontLoadRequestConfig,
|
||||
FontsByIdsStore,
|
||||
type UnifiedFont,
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
getFontUrl,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
type Pairing,
|
||||
type Role,
|
||||
comboKey,
|
||||
createPairing,
|
||||
nextFocalId,
|
||||
} from '$entities/Pairing';
|
||||
import {
|
||||
combineFrameHeight,
|
||||
measureRoleHeight,
|
||||
} from '$features/CompareBoard/lib/measure';
|
||||
import {
|
||||
BOARD_SCHEMA_VERSION,
|
||||
BOARD_STORAGE_KEY,
|
||||
DEFAULT_SPECIMEN,
|
||||
FRAME_ROLE_GAP,
|
||||
} from '$features/CompareBoard/model/const/const';
|
||||
import {
|
||||
createPersistentStore,
|
||||
createSingleton,
|
||||
ensureCanvasFonts,
|
||||
getPretextFontString,
|
||||
} from '$shared/lib';
|
||||
import { prepareWithSegments } from '@chenglou/pretext';
|
||||
import {
|
||||
flushSync,
|
||||
untrack,
|
||||
} from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
/**
|
||||
* Compact persisted board shape. Font ids are abbreviated (`h`/`b`) to keep the
|
||||
* blob small and URL-encoding-friendly; specimen text is in localStorage but is
|
||||
* intentionally excluded from any future URL share.
|
||||
*/
|
||||
interface PersistedBoard {
|
||||
/**
|
||||
* Schema version (gates migrations / the future URL codec).
|
||||
*/
|
||||
v: number;
|
||||
/**
|
||||
* Pairings in board order: surrogate id + the two font ids.
|
||||
*/
|
||||
pairings: { id: string; h: string; b: string }[];
|
||||
/**
|
||||
* The focal pairing's id, or null when the board is empty.
|
||||
*/
|
||||
focalId: string | null;
|
||||
/**
|
||||
* Board-global specimen text.
|
||||
*/
|
||||
specimen: { header: string; body: string };
|
||||
}
|
||||
|
||||
const emptyBoard = (): PersistedBoard => ({
|
||||
v: BOARD_SCHEMA_VERSION,
|
||||
pairings: [],
|
||||
focalId: null,
|
||||
specimen: { ...DEFAULT_SPECIMEN },
|
||||
});
|
||||
|
||||
/**
|
||||
* Plain per-role typography values the board renders and measures with. Mirrors
|
||||
* the four axes an `AdjustTypography` store exposes, but as a framework-free
|
||||
* value shape the board owns — the inversion seam (`widgets/Board` pushes the
|
||||
* concrete store's values in via `setTypo`). Not persisted here: the
|
||||
* AdjustTypography stores own typography persistence.
|
||||
*/
|
||||
export interface RoleTypography {
|
||||
/**
|
||||
* Font size in px (honest, absolute — no responsive multiplier).
|
||||
*/
|
||||
size: number;
|
||||
/**
|
||||
* Numeric font weight (100–900).
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Unitless line-height multiplier.
|
||||
*/
|
||||
leading: number;
|
||||
/**
|
||||
* Letter spacing in px.
|
||||
*/
|
||||
tracking: number;
|
||||
}
|
||||
|
||||
const defaultRoleTypography = (): RoleTypography => ({
|
||||
size: DEFAULT_FONT_SIZE,
|
||||
weight: DEFAULT_FONT_WEIGHT,
|
||||
leading: DEFAULT_LINE_HEIGHT,
|
||||
tracking: DEFAULT_LETTER_SPACING,
|
||||
});
|
||||
|
||||
/**
|
||||
* Singleton board store. Pairings live as a reassigned `$state` array (ordered
|
||||
* cycling needs index order); mutations reassign so Svelte tracks them and
|
||||
* persist synchronously through the persistent store.
|
||||
*/
|
||||
export class BoardStore {
|
||||
/**
|
||||
* Ordered pairings on the board.
|
||||
*/
|
||||
#pairings = $state<Pairing[]>([]);
|
||||
/**
|
||||
* The focal pairing's id, or null when the board is empty.
|
||||
*/
|
||||
#focalId = $state<string | null>(null);
|
||||
/**
|
||||
* Board-global specimen text shared by every pairing.
|
||||
*/
|
||||
#specimen = $state<{ header: string; body: string }>({ ...DEFAULT_SPECIMEN });
|
||||
/**
|
||||
* Per-role typography, fed in by the widget from the AdjustTypography stores
|
||||
* (dependency-inversion seam). Read by font-loading and frame measurement.
|
||||
*/
|
||||
#typo = $state<{ header: RoleTypography; body: RoleTypography }>({
|
||||
header: defaultRoleTypography(),
|
||||
body: defaultRoleTypography(),
|
||||
});
|
||||
/**
|
||||
* localStorage-backed mirror of the board blob.
|
||||
*/
|
||||
#storage = createPersistentStore<PersistedBoard>(BOARD_STORAGE_KEY, emptyBoard());
|
||||
/**
|
||||
* Batch font-metadata resolver, kept in sync with `candidateFontIds`.
|
||||
*/
|
||||
#fontsByIds: FontsByIdsStore;
|
||||
/**
|
||||
* Font load/cache/eviction manager; pinned to keep on-screen fonts resident.
|
||||
*/
|
||||
#lifecycle: FontLifecycleManager;
|
||||
/**
|
||||
* Paginated font catalog — source of fonts for default seeding.
|
||||
*/
|
||||
#fontCatalog: FontCatalogStore;
|
||||
/**
|
||||
* One-shot guard: only seed a default board when storage was empty at
|
||||
* construction (never re-seed after the user empties the board).
|
||||
*/
|
||||
#shouldSeed: boolean;
|
||||
/**
|
||||
* Font strings whose canvas metrics are confirmed real (warm). Reactive
|
||||
* (SvelteSet) so a completed warm re-runs height readers. Gates `prepare()`
|
||||
* to avoid poisoning Pretext's cache with fallback widths.
|
||||
*/
|
||||
#warmed = new SvelteSet<string>();
|
||||
/**
|
||||
* Font strings with an in-flight `ensureCanvasFonts` — dedupes warm requests.
|
||||
*/
|
||||
#warming = new Set<string>();
|
||||
/**
|
||||
* Memoized frame heights keyed by (combo, width, specimen, typography), so
|
||||
* cycling back to a measured pairing is O(1) and never reflows.
|
||||
*/
|
||||
#heightCache = new Map<string, number>();
|
||||
/**
|
||||
* Last computed height per pairing — the reserved fallback returned while a
|
||||
* pairing's fonts load/warm, so the frame never collapses to 0 mid-cycle.
|
||||
*/
|
||||
#lastHeight = new Map<string, number>();
|
||||
/**
|
||||
* Disposes the constructor's $effect.root. Must run on teardown.
|
||||
*/
|
||||
#disposeEffects: () => void;
|
||||
|
||||
constructor() {
|
||||
const stored = this.#storage.value;
|
||||
this.#pairings = stored.pairings.map(p => createPairing(p.h, p.b, p.id));
|
||||
this.#focalId = stored.focalId;
|
||||
this.#specimen = { ...stored.specimen };
|
||||
this.#shouldSeed = stored.pairings.length === 0;
|
||||
|
||||
this.#lifecycle = getFontLifecycleManager();
|
||||
this.#fontCatalog = getFontCatalog();
|
||||
this.#fontsByIds = new FontsByIdsStore(this.candidateFontIds);
|
||||
|
||||
this.#disposeEffects = $effect.root(() => {
|
||||
// Seed a curated default board the first time the catalog is ready and
|
||||
// storage was empty — so the screen is never blank on first visit.
|
||||
$effect(() => {
|
||||
if (!this.#shouldSeed || this.#pairings.length > 0) {
|
||||
return;
|
||||
}
|
||||
const fonts = this.#fontCatalog.fonts;
|
||||
if (fonts.length < 2) {
|
||||
return;
|
||||
}
|
||||
untrack(() => {
|
||||
this.#shouldSeed = false;
|
||||
const count = Math.min(4, Math.floor(fonts.length / 2));
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.addPairing(fonts[i * 2].id, fonts[i * 2 + 1].id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Keep the batch query's id set in sync with the board's candidates.
|
||||
$effect(() => {
|
||||
this.#fontsByIds.setIds(this.candidateFontIds);
|
||||
});
|
||||
|
||||
// Preload every candidate font at its role weight (brief §Performance).
|
||||
$effect(() => {
|
||||
const configs = this.#candidateConfigs();
|
||||
if (configs.length > 0) {
|
||||
this.#lifecycle.touch(configs);
|
||||
}
|
||||
});
|
||||
|
||||
// Pin the focal pairing's fonts so eviction never drops on-screen
|
||||
// glyphs; unpin on focal/weight change via the cleanup return.
|
||||
$effect(() => {
|
||||
const focal = this.focal;
|
||||
if (!focal) {
|
||||
return;
|
||||
}
|
||||
const headerWeight = this.#typo.header.weight;
|
||||
const bodyWeight = this.#typo.body.weight;
|
||||
const header = this.fontById(focal.headerFontId);
|
||||
const body = this.fontById(focal.bodyFontId);
|
||||
if (header) {
|
||||
this.#lifecycle.pin(header.id, headerWeight, header.features?.isVariable);
|
||||
}
|
||||
if (body) {
|
||||
this.#lifecycle.pin(body.id, bodyWeight, body.features?.isVariable);
|
||||
}
|
||||
return () => {
|
||||
if (header) {
|
||||
this.#lifecycle.unpin(header.id, headerWeight, header.features?.isVariable);
|
||||
}
|
||||
if (body) {
|
||||
this.#lifecycle.unpin(body.id, bodyWeight, body.features?.isVariable);
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds dedup'd font-load configs for every resolvable candidate font at its
|
||||
* role weight (header fonts at header weight, body fonts at body weight).
|
||||
* Unresolved fonts (metadata not yet fetched) are skipped.
|
||||
*/
|
||||
#candidateConfigs(): FontLoadRequestConfig[] {
|
||||
const configs: FontLoadRequestConfig[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (fontId: string, weight: number) => {
|
||||
const font = this.fontById(fontId);
|
||||
if (!font) {
|
||||
return;
|
||||
}
|
||||
const url = getFontUrl(font, weight);
|
||||
if (!url || seen.has(url)) {
|
||||
return;
|
||||
}
|
||||
seen.add(url);
|
||||
configs.push({ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable });
|
||||
};
|
||||
for (const pairing of this.#pairings) {
|
||||
add(pairing.headerFontId, this.#typo.header.weight);
|
||||
add(pairing.bodyFontId, this.#typo.body.weight);
|
||||
}
|
||||
return configs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes current state back to the persistent store. The persistent store's
|
||||
* own effect flushes to localStorage; `destroy()` forces that flush so
|
||||
* synchronous rehydration (and test teardown) never loses a write.
|
||||
*/
|
||||
#persist() {
|
||||
this.#storage.value = {
|
||||
v: BOARD_SCHEMA_VERSION,
|
||||
pairings: this.#pairings.map(p => ({ id: p.id, h: p.headerFontId, b: p.bodyFontId })),
|
||||
focalId: this.#focalId,
|
||||
specimen: { ...this.#specimen },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All pairings in board order (reactive).
|
||||
*/
|
||||
get pairings(): readonly Pairing[] {
|
||||
return this.#pairings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The focal pairing's id, or null when empty (reactive).
|
||||
*/
|
||||
get focalId(): string | null {
|
||||
return this.#focalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The focal pairing, or undefined when empty (reactive).
|
||||
*/
|
||||
get focal(): Pairing | undefined {
|
||||
return this.#pairings.find(p => p.id === this.#focalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Board-global specimen text (reactive).
|
||||
*/
|
||||
get specimen(): { header: string; body: string } {
|
||||
return this.#specimen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-role typography values (reactive). Fed by the widget via `setTypo`.
|
||||
*/
|
||||
get typo(): { header: RoleTypography; body: RoleTypography } {
|
||||
return this.#typo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one role's typography values. Called by `widgets/Board` whenever
|
||||
* the corresponding AdjustTypography store changes (the inversion seam).
|
||||
*
|
||||
* @param role - Which role's typography to set.
|
||||
* @param values - The new typography values for that role.
|
||||
*/
|
||||
setTypo(role: Role, values: RoleTypography) {
|
||||
this.#typo = { ...this.#typo, [role]: { ...values } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every distinct font id referenced by any pairing (header or body). The
|
||||
* preload set — kept in sync with the batch font resolver.
|
||||
*/
|
||||
get candidateFontIds(): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const pairing of this.#pairings) {
|
||||
ids.add(pairing.headerFontId);
|
||||
ids.add(pairing.bodyFontId);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a font id to its loaded metadata, or undefined if not yet fetched.
|
||||
*
|
||||
* @param id - Font entity id.
|
||||
* @returns The font metadata, or undefined while loading.
|
||||
*/
|
||||
fontById(id: string): UnifiedFont | undefined {
|
||||
return this.#fontsByIds.fonts.find(f => f.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves both fonts of a pairing for the UI.
|
||||
*
|
||||
* @param pairing - The pairing to resolve.
|
||||
* @returns Header and body font metadata (each undefined while loading).
|
||||
*/
|
||||
resolvePairingFonts(pairing: Pairing): { header?: UnifiedFont; body?: UnifiedFont } {
|
||||
return {
|
||||
header: this.fontById(pairing.headerFontId),
|
||||
body: this.fontById(pairing.bodyFontId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The focal frame's measured height at the given content width.
|
||||
*
|
||||
* @param contentWidth - The frame's content width in px.
|
||||
* @returns Height in px (0 when the board is empty).
|
||||
*/
|
||||
focalFrameHeight(contentWidth: number): number {
|
||||
return this.#focalId ? this.frameHeight(this.#focalId, contentWidth) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-measures a (typically next-up) pairing so cycling to it never reflows.
|
||||
*
|
||||
* @param pairingId - The pairing to measure ahead of time.
|
||||
* @param contentWidth - The frame's content width in px.
|
||||
* @returns Height in px (fallback while fonts load/warm).
|
||||
*/
|
||||
peekFrameHeight(pairingId: string, contentWidth: number): number {
|
||||
return this.frameHeight(pairingId, contentWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measured height of a pairing's frame (header block + gap + body block) at a
|
||||
* content width, via Pretext's pure line-count arithmetic. Returns the
|
||||
* last-known height (or 0) until both fonts are resolved AND the canvas is
|
||||
* warm — never measures cold, which would poison Pretext's width cache
|
||||
* forever. Results are memoized per (combo, width, specimen, typography).
|
||||
*
|
||||
* @param pairingId - The pairing to measure.
|
||||
* @param contentWidth - The frame's content width in px.
|
||||
* @returns Height in px.
|
||||
*/
|
||||
frameHeight(pairingId: string, contentWidth: number): number {
|
||||
const pairing = this.#pairings.find(p => p.id === pairingId);
|
||||
if (!pairing) {
|
||||
return 0;
|
||||
}
|
||||
const { header, body } = this.resolvePairingFonts(pairing);
|
||||
const fallback = this.#lastHeight.get(pairingId) ?? 0;
|
||||
if (!header || !body) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const headerFont = getPretextFontString(this.#typo.header.weight, this.#typo.header.size, header.name);
|
||||
const bodyFont = getPretextFontString(this.#typo.body.weight, this.#typo.body.size, body.name);
|
||||
|
||||
this.#ensureWarm([headerFont, bodyFont]);
|
||||
// SvelteSet read is reactive: a completed warm re-runs height readers.
|
||||
if (!this.#warmed.has(headerFont) || !this.#warmed.has(bodyFont)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const key = `${comboKey(pairing)}|${contentWidth}|${this.#specimen.header}|${this.#specimen.body}|`
|
||||
+ this.#typoSignature();
|
||||
const cached = this.#heightCache.get(key);
|
||||
if (cached !== undefined) {
|
||||
this.#lastHeight.set(pairingId, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const headerHeight = measureRoleHeight({
|
||||
prepared: prepareWithSegments(this.#specimen.header, headerFont, {
|
||||
letterSpacing: this.#typo.header.tracking,
|
||||
}),
|
||||
maxWidth: contentWidth,
|
||||
sizePx: this.#typo.header.size,
|
||||
lineHeight: this.#typo.header.leading,
|
||||
});
|
||||
const bodyHeight = measureRoleHeight({
|
||||
prepared: prepareWithSegments(this.#specimen.body, bodyFont, {
|
||||
letterSpacing: this.#typo.body.tracking,
|
||||
}),
|
||||
maxWidth: contentWidth,
|
||||
sizePx: this.#typo.body.size,
|
||||
lineHeight: this.#typo.body.leading,
|
||||
});
|
||||
const height = combineFrameHeight({ headerHeight, bodyHeight, gap: FRAME_ROLE_GAP });
|
||||
this.#heightCache.set(key, height);
|
||||
this.#lastHeight.set(pairingId, height);
|
||||
return height;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the focal pairing's fonts are resolved and canvas-warm — the UI
|
||||
* gates the first paint of the focal frame on this to avoid a cold-measure
|
||||
* flash.
|
||||
*/
|
||||
get measureReady(): boolean {
|
||||
const focal = this.focal;
|
||||
if (!focal) {
|
||||
return false;
|
||||
}
|
||||
const { header, body } = this.resolvePairingFonts(focal);
|
||||
if (!header || !body) {
|
||||
return false;
|
||||
}
|
||||
const headerFont = getPretextFontString(this.#typo.header.weight, this.#typo.header.size, header.name);
|
||||
const bodyFont = getPretextFontString(this.#typo.body.weight, this.#typo.body.size, body.name);
|
||||
return this.#warmed.has(headerFont) && this.#warmed.has(bodyFont);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kicks off canvas warming for any cold font strings (dedup'd). Fire-and-
|
||||
* forget: on resolution the strings join `#warmed`, re-running height readers.
|
||||
*/
|
||||
#ensureWarm(fontStrings: string[]) {
|
||||
const cold = fontStrings.filter(s => !this.#warmed.has(s) && !this.#warming.has(s));
|
||||
if (cold.length === 0) {
|
||||
return;
|
||||
}
|
||||
cold.forEach(s => this.#warming.add(s));
|
||||
void ensureCanvasFonts(cold)
|
||||
.then(() => {
|
||||
cold.forEach(s => {
|
||||
this.#warming.delete(s);
|
||||
this.#warmed.add(s);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
cold.forEach(s => this.#warming.delete(s));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature of both roles' typography, for the height memo key.
|
||||
*/
|
||||
#typoSignature(): string {
|
||||
const h = this.#typo.header;
|
||||
const b = this.#typo.body;
|
||||
return `${h.size},${h.weight},${h.leading},${h.tracking};${b.size},${b.weight},${b.leading},${b.tracking}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a pairing to the end of the board. The first pairing added becomes
|
||||
* focal.
|
||||
*
|
||||
* @param headerFontId - Font id for the header role.
|
||||
* @param bodyFontId - Font id for the body role.
|
||||
* @returns The created pairing.
|
||||
*/
|
||||
addPairing(headerFontId: string, bodyFontId: string): Pairing {
|
||||
const pairing = createPairing(headerFontId, bodyFontId);
|
||||
this.#pairings = [...this.#pairings, pairing];
|
||||
if (this.#focalId === null) {
|
||||
this.#focalId = pairing.id;
|
||||
}
|
||||
this.#persist();
|
||||
return pairing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones a pairing as a distinct card inserted directly after the source, and
|
||||
* makes the clone focal so the user can immediately swap one side.
|
||||
*
|
||||
* @param id - Source pairing id.
|
||||
* @returns The new pairing.
|
||||
*/
|
||||
duplicate(id: string): Pairing {
|
||||
const index = this.#pairings.findIndex(p => p.id === id);
|
||||
const source = this.#pairings[index];
|
||||
const dup = createPairing(source.headerFontId, source.bodyFontId);
|
||||
this.#pairings = [
|
||||
...this.#pairings.slice(0, index + 1),
|
||||
dup,
|
||||
...this.#pairings.slice(index + 1),
|
||||
];
|
||||
this.#focalId = dup.id;
|
||||
this.#persist();
|
||||
return dup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a pairing. If the removed pairing was focal, focal moves to a
|
||||
* neighbour so exactly one focal always exists on a non-empty board.
|
||||
*
|
||||
* @param id - Pairing id to remove.
|
||||
*/
|
||||
removePairing(id: string) {
|
||||
let nextFocal = this.#focalId;
|
||||
if (this.#focalId === id) {
|
||||
// Pick a neighbour from the still-full ordered list; if the only
|
||||
// candidate is the one being removed, the board becomes empty.
|
||||
const candidate = nextFocalId(this.#pairings.map(p => p.id), id, 1);
|
||||
nextFocal = candidate === id ? null : candidate;
|
||||
}
|
||||
this.#pairings = this.#pairings.filter(p => p.id !== id);
|
||||
this.#focalId = nextFocal;
|
||||
this.#persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the focal pairing.
|
||||
*
|
||||
* @param id - Pairing id to focus.
|
||||
*/
|
||||
setFocal(id: string) {
|
||||
this.#focalId = id;
|
||||
this.#persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps focal one pairing in board order, wrapping at both ends.
|
||||
*
|
||||
* @param direction - +1 for next, -1 for previous.
|
||||
*/
|
||||
cycle(direction: 1 | -1) {
|
||||
if (this.#focalId === null) {
|
||||
return;
|
||||
}
|
||||
const next = nextFocalId(this.#pairings.map(p => p.id), this.#focalId, direction);
|
||||
if (next !== null) {
|
||||
this.#focalId = next;
|
||||
this.#persist();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the font filling one role of a pairing.
|
||||
*
|
||||
* @param id - Pairing id.
|
||||
* @param role - Which role to swap.
|
||||
* @param fontId - New font id for that role.
|
||||
*/
|
||||
swapFont(id: string, role: Role, fontId: string) {
|
||||
this.#pairings = this.#pairings.map(p => {
|
||||
if (p.id !== id) {
|
||||
return p;
|
||||
}
|
||||
return role === 'header' ? { ...p, headerFontId: fontId } : { ...p, bodyFontId: fontId };
|
||||
});
|
||||
this.#persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the board-global specimen for a role.
|
||||
*
|
||||
* @param role - Which role's text to set.
|
||||
* @param text - New specimen text.
|
||||
*/
|
||||
setSpecimen(role: Role, text: string) {
|
||||
this.#specimen = { ...this.#specimen, [role]: text };
|
||||
this.#persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes the pending persist write, then disposes the persistent store.
|
||||
* Call on teardown.
|
||||
*/
|
||||
destroy() {
|
||||
flushSync();
|
||||
this.#disposeEffects();
|
||||
this.#fontsByIds.destroy();
|
||||
this.#storage.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const board = createSingleton(
|
||||
() => new BoardStore(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getBoard = board.get;
|
||||
|
||||
// test-only reset, so specs don't share live state or persisted blobs
|
||||
export const __resetBoard = board.reset;
|
||||
@@ -1 +0,0 @@
|
||||
export { FontSampler } from './ui';
|
||||
@@ -1,3 +0,0 @@
|
||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||
|
||||
export { FontSampler };
|
||||
+11
-10
@@ -23,7 +23,10 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createFilter } from '$shared/lib';
|
||||
import {
|
||||
createFilter,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
import { createDebouncedState } from '$shared/lib/helpers';
|
||||
import type {
|
||||
FilterConfig,
|
||||
@@ -129,8 +132,6 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
|
||||
|
||||
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
|
||||
|
||||
let _appliedFilterStore: AppliedFilterStore | undefined;
|
||||
|
||||
/**
|
||||
* App-wide filter manager, created on first access.
|
||||
*
|
||||
@@ -138,14 +139,14 @@ let _appliedFilterStore: AppliedFilterStore | undefined;
|
||||
* lives in `./bindings.svelte` and populates groups once backend filter
|
||||
* metadata arrives.
|
||||
*/
|
||||
export function getAppliedFilterStore(): AppliedFilterStore {
|
||||
return (_appliedFilterStore ??= createAppliedFilterStore<string>({
|
||||
const appliedFilterStore = createSingleton(() =>
|
||||
createAppliedFilterStore<string>({
|
||||
queryValue: '',
|
||||
groups: [],
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export const getAppliedFilterStore = appliedFilterStore.get;
|
||||
|
||||
// test-only reset, so specs don't share filter/selection state
|
||||
export function __resetAppliedFilterStore() {
|
||||
_appliedFilterStore = undefined;
|
||||
}
|
||||
export const __resetAppliedFilterStore = appliedFilterStore.reset;
|
||||
|
||||
+10
-11
@@ -20,8 +20,9 @@ import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/fi
|
||||
import {
|
||||
DEFAULT_QUERY_GC_TIME_MS,
|
||||
DEFAULT_QUERY_STALE_TIME_MS,
|
||||
queryClient,
|
||||
getQueryClient,
|
||||
} from '$shared/api/queryClient';
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
import {
|
||||
type QueryKey,
|
||||
QueryObserver,
|
||||
@@ -49,7 +50,7 @@ export class AvailableFilterStore {
|
||||
/**
|
||||
* Shared query client
|
||||
*/
|
||||
protected qc = queryClient;
|
||||
protected qc = getQueryClient();
|
||||
|
||||
/**
|
||||
* Creates a new filters store
|
||||
@@ -126,18 +127,16 @@ export class AvailableFilterStore {
|
||||
}
|
||||
}
|
||||
|
||||
let _availableFilterStore: AvailableFilterStore | undefined;
|
||||
|
||||
/**
|
||||
* App-wide filter-metadata store, created on first access. Lazy so the
|
||||
* QueryObserver isn't constructed at module load.
|
||||
*/
|
||||
export function getAvailableFilterStore(): AvailableFilterStore {
|
||||
return (_availableFilterStore ??= new AvailableFilterStore());
|
||||
}
|
||||
const availableFilterStore = createSingleton(
|
||||
() => new AvailableFilterStore(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getAvailableFilterStore = availableFilterStore.get;
|
||||
|
||||
// test-only reset, so specs don't share a live observer
|
||||
export function __resetAvailableFilterStore() {
|
||||
_availableFilterStore?.destroy();
|
||||
_availableFilterStore = undefined;
|
||||
}
|
||||
export const __resetAvailableFilterStore = availableFilterStore.reset;
|
||||
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
|
||||
/**
|
||||
* Sort store — manages the current sort option for font listings.
|
||||
*
|
||||
@@ -46,16 +48,12 @@ export function createSortStore(initial: SortOption = 'Popularity') {
|
||||
|
||||
export type SortStore = ReturnType<typeof createSortStore>;
|
||||
|
||||
let _sortStore: SortStore | undefined;
|
||||
|
||||
/**
|
||||
* App-wide sort store, created on first access.
|
||||
*/
|
||||
export function getSortStore(): SortStore {
|
||||
return (_sortStore ??= createSortStore());
|
||||
}
|
||||
const sortStore = createSingleton(() => createSortStore());
|
||||
|
||||
export const getSortStore = sortStore.get;
|
||||
|
||||
// test-only reset, so specs don't share selection state
|
||||
export function __resetSortStore() {
|
||||
_sortStore = undefined;
|
||||
}
|
||||
export const __resetSortStore = sortStore.reset;
|
||||
|
||||
@@ -27,11 +27,16 @@ export const QUERY_RETRY_BASE_DELAY_MS = 1000;
|
||||
*/
|
||||
export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
||||
|
||||
let queryClientInstance: QueryClient | undefined;
|
||||
|
||||
/**
|
||||
* TanStack Query client instance
|
||||
* Shared TanStack Query client (lazy singleton).
|
||||
*
|
||||
* Configured for optimal caching and refetching behavior.
|
||||
* Used by all font stores for data fetching and caching.
|
||||
* Construction is deferred to the first call so importing this module is inert:
|
||||
* module eval runs no `new QueryClient()`, so the module is genuinely
|
||||
* side-effect-free and needs no `sideEffects` allowlist exception. The
|
||||
* app-layer `QueryProvider` is the first caller; every store reuses the same
|
||||
* instance. Matches the lazy-accessor pattern used by the font stores.
|
||||
*
|
||||
* Cache behavior:
|
||||
* - Data stays fresh for 5 minutes (staleTime)
|
||||
@@ -39,7 +44,8 @@ export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
||||
* - No refetch on window focus (reduces unnecessary network requests)
|
||||
* - 3 retries with exponential backoff on failure
|
||||
*/
|
||||
export const queryClient = new QueryClient({
|
||||
export function getQueryClient(): QueryClient {
|
||||
return (queryClientInstance ??= new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
||||
@@ -65,4 +71,5 @@ export const queryClient = new QueryClient({
|
||||
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import {
|
||||
QueryObserver,
|
||||
type QueryObserverOptions,
|
||||
@@ -20,7 +20,7 @@ export abstract class BaseQueryStore<TData, TError = Error> {
|
||||
#unsubscribe: () => void;
|
||||
|
||||
constructor(options: QueryObserverOptions<TData, TError, TData, any, any>) {
|
||||
this.#observer = new QueryObserver(queryClient, options);
|
||||
this.#observer = new QueryObserver(getQueryClient(), options);
|
||||
this.#unsubscribe = this.#observer.subscribe(result => {
|
||||
this.#result = result;
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
|
||||
@@ -1,66 +1,15 @@
|
||||
/**
|
||||
* Persistent localStorage-backed reactive state
|
||||
* Reactive localStorage-backed state. Loads on init, saves on change via an
|
||||
* $effect.root. Falls back to the default on SSR (no localStorage) and on JSON
|
||||
* parse errors; swallows quota/write errors with a warning.
|
||||
*
|
||||
* Creates reactive state that automatically syncs with localStorage.
|
||||
* Values persist across browser sessions and are restored on page load.
|
||||
* Owners that create this outside a component must call destroy() to dispose
|
||||
* the save effect.
|
||||
*
|
||||
* Handles edge cases:
|
||||
* - SSR safety (no localStorage on server)
|
||||
* - JSON parse errors (falls back to default)
|
||||
* - Storage quota errors (logs warning, doesn't crash)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Store user preferences
|
||||
* const preferences = createPersistentStore('user-prefs', {
|
||||
* theme: 'dark',
|
||||
* fontSize: 16,
|
||||
* sidebarOpen: true
|
||||
* });
|
||||
*
|
||||
* // Access reactive state
|
||||
* $: currentTheme = preferences.value.theme;
|
||||
*
|
||||
* // Update (auto-saves to localStorage)
|
||||
* preferences.value.theme = 'light';
|
||||
*
|
||||
* // Clear stored value
|
||||
* preferences.clear();
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a reactive store backed by localStorage
|
||||
*
|
||||
* The value is loaded from localStorage on initialization and automatically
|
||||
* saved whenever it changes. Uses Svelte 5's $effect for reactive sync.
|
||||
*
|
||||
* @param key - localStorage key for storing the value
|
||||
* @param defaultValue - Default value if no stored value exists
|
||||
* @returns Persistent store with getter/setter and clear method
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Simple value
|
||||
* const counter = createPersistentStore('counter', 0);
|
||||
* counter.value++;
|
||||
*
|
||||
* // Complex object
|
||||
* interface Settings {
|
||||
* theme: 'light' | 'dark';
|
||||
* fontSize: number;
|
||||
* }
|
||||
* const settings = createPersistentStore<Settings>('app-settings', {
|
||||
* theme: 'light',
|
||||
* fontSize: 16
|
||||
* });
|
||||
* ```
|
||||
* @param key - localStorage key
|
||||
* @param defaultValue - value used when nothing is stored
|
||||
*/
|
||||
export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||
/**
|
||||
* Load value from localStorage or return default
|
||||
* Safely handles missing keys, parse errors, and SSR
|
||||
*/
|
||||
const loadFromStorage = (): T => {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultValue;
|
||||
@@ -76,9 +25,13 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||
|
||||
let value = $state<T>(loadFromStorage());
|
||||
|
||||
// Sync to storage whenever value changes
|
||||
// Wrapped in $effect.root to prevent memory leaks
|
||||
$effect.root(() => {
|
||||
/**
|
||||
* Sync to storage whenever value changes. The effect lives in an
|
||||
* $effect.root so it outlives any component; the returned disposer is kept
|
||||
* and run by destroy(), because an $effect.root with no disposer leaks for
|
||||
* the life of the process.
|
||||
*/
|
||||
const dispose = $effect.root(() => {
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -113,6 +66,15 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||
}
|
||||
value = defaultValue;
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispose the storage-sync effect. Owners that create a store outside a
|
||||
* component (e.g. a singleton store class) must call this to avoid
|
||||
* leaking the underlying $effect.root.
|
||||
*/
|
||||
destroy() {
|
||||
dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { flushSync } from 'svelte';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
@@ -376,4 +377,39 @@ describe('createPersistentStore', () => {
|
||||
expect(store.value[0].name).toBe('First');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lifecycle', () => {
|
||||
it('persists value changes via the sync effect', () => {
|
||||
const store = createPersistentStore(testKey, 'a');
|
||||
const spy = vi.spyOn(mockLocalStorage, 'setItem');
|
||||
|
||||
store.value = 'b';
|
||||
flushSync();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(testKey, JSON.stringify('b'));
|
||||
});
|
||||
|
||||
it('stops persisting after destroy()', () => {
|
||||
const store = createPersistentStore(testKey, 'a');
|
||||
flushSync();
|
||||
store.destroy();
|
||||
|
||||
const spy = vi.spyOn(mockLocalStorage, 'setItem');
|
||||
store.value = 'c';
|
||||
flushSync();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
// reading still works after disposal
|
||||
expect(store.value).toBe('c');
|
||||
});
|
||||
|
||||
it('destroy() is safe to call repeatedly', () => {
|
||||
const store = createPersistentStore(testKey, 'a');
|
||||
|
||||
expect(() => {
|
||||
store.destroy();
|
||||
store.destroy();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { createSingleton } from './createSingleton';
|
||||
|
||||
describe('createSingleton', () => {
|
||||
it('does not call the factory until the first get (lazy)', () => {
|
||||
const factory = vi.fn(() => ({ id: 1 }));
|
||||
createSingleton(factory);
|
||||
expect(factory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('constructs on first get and memoizes the instance', () => {
|
||||
const factory = vi.fn(() => ({ id: 1 }));
|
||||
const singleton = createSingleton(factory);
|
||||
|
||||
const a = singleton.get();
|
||||
const b = singleton.get();
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('rebuilds a fresh instance after reset', () => {
|
||||
let count = 0;
|
||||
const singleton = createSingleton(() => ({ id: ++count }));
|
||||
|
||||
const first = singleton.get();
|
||||
singleton.reset();
|
||||
const second = singleton.get();
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(second.id).toBe(2);
|
||||
});
|
||||
|
||||
it('runs teardown once, with the live instance, on reset', () => {
|
||||
const teardown = vi.fn();
|
||||
const singleton = createSingleton(() => ({ id: 1 }), teardown);
|
||||
|
||||
const instance = singleton.get();
|
||||
singleton.reset();
|
||||
|
||||
expect(teardown).toHaveBeenCalledTimes(1);
|
||||
expect(teardown).toHaveBeenCalledWith(instance);
|
||||
});
|
||||
|
||||
it('treats reset before any get as a no-op (no teardown, no throw)', () => {
|
||||
const teardown = vi.fn();
|
||||
const singleton = createSingleton(() => ({ id: 1 }), teardown);
|
||||
|
||||
expect(() => singleton.reset()).not.toThrow();
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not run teardown again on a second consecutive reset', () => {
|
||||
const teardown = vi.fn();
|
||||
const singleton = createSingleton(() => ({ id: 1 }), teardown);
|
||||
|
||||
singleton.get();
|
||||
singleton.reset();
|
||||
singleton.reset();
|
||||
|
||||
expect(teardown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('works without a teardown', () => {
|
||||
const singleton = createSingleton(() => ({ id: 1 }));
|
||||
|
||||
singleton.get();
|
||||
expect(() => singleton.reset()).not.toThrow();
|
||||
expect(singleton.get().id).toBe(1);
|
||||
});
|
||||
|
||||
it('caches a falsy instance value without re-running the factory', () => {
|
||||
const factory = vi.fn(() => undefined);
|
||||
const singleton = createSingleton<undefined>(factory);
|
||||
|
||||
singleton.get();
|
||||
singleton.get();
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* A lazily-constructed singleton accessor pair.
|
||||
*/
|
||||
export interface Singleton<T> {
|
||||
/**
|
||||
* Returns the instance, constructing it on the first call and reusing it
|
||||
* thereafter.
|
||||
*/
|
||||
get: () => T;
|
||||
/**
|
||||
* Tears down the current instance (if built) and clears it, so the next
|
||||
* `get()` rebuilds. Used by specs to avoid shared state between tests.
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardizes the lazy `getX()` / `__resetX()` singleton pattern used by the
|
||||
* app's stores.
|
||||
*
|
||||
* The instance is built on the first `get()` and reused afterwards; `reset()`
|
||||
* runs the optional teardown against the live instance and clears it. Building
|
||||
* lazily keeps the owning module inert at import — construction happens only on
|
||||
* first access, never at module eval.
|
||||
*
|
||||
* @param factory - Builds the instance on first access.
|
||||
* @param teardown - Optional cleanup run against the live instance on reset
|
||||
* (e.g. disposing an `$effect.root` via the instance's `destroy()`).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const catalog = createSingleton(() => new FontCatalogStore({ limit: 50 }), c => c.destroy());
|
||||
* export const getFontCatalog = catalog.get;
|
||||
* export const __resetFontCatalog = catalog.reset;
|
||||
* ```
|
||||
*/
|
||||
export function createSingleton<T>(factory: () => T, teardown?: (instance: T) => void): Singleton<T> {
|
||||
let instance: T | undefined;
|
||||
let initialized = false;
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
if (!initialized) {
|
||||
instance = factory();
|
||||
initialized = true;
|
||||
}
|
||||
return instance as T;
|
||||
},
|
||||
reset: () => {
|
||||
if (initialized) {
|
||||
teardown?.(instance as T);
|
||||
}
|
||||
instance = undefined;
|
||||
initialized = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -137,6 +137,20 @@ export {
|
||||
type PerspectiveManager,
|
||||
} from './createPerspectiveManager/createPerspectiveManager.svelte';
|
||||
|
||||
/**
|
||||
* Lazy singletons
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Lazy `getX()` / `__resetX()` singleton accessor factory
|
||||
*/
|
||||
createSingleton,
|
||||
/**
|
||||
* Singleton accessor pair type
|
||||
*/
|
||||
type Singleton,
|
||||
} from './createSingleton/createSingleton';
|
||||
|
||||
/*
|
||||
* BaseQueryStore is intentionally NOT re-exported here.
|
||||
* It pulls @tanstack/query-core, so routing it through this leaf barrel would
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
createPersistentStore,
|
||||
createPerspectiveManager,
|
||||
createResponsiveManager,
|
||||
createSingleton,
|
||||
createVirtualizer,
|
||||
type Entity,
|
||||
type EntityStore,
|
||||
@@ -21,6 +22,7 @@ export {
|
||||
type Property,
|
||||
type ResponsiveManager,
|
||||
responsiveManager,
|
||||
type Singleton,
|
||||
type VirtualItem,
|
||||
type Virtualizer,
|
||||
type VirtualizerOptions,
|
||||
@@ -31,7 +33,9 @@ export {
|
||||
clampNumber,
|
||||
cn,
|
||||
debounce,
|
||||
ensureCanvasFonts,
|
||||
getDecimalPlaces,
|
||||
getPretextFontString,
|
||||
roundToStepPrecision,
|
||||
smoothScroll,
|
||||
splitArray,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* STORYBOOK HELPERS
|
||||
* ============================================================================
|
||||
*
|
||||
* Helper components and utilities for Storybook stories.
|
||||
* Storybook helpers: components and utilities for stories.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Ensures a set of fonts is usable in a `<canvas>` measurement context.
|
||||
*
|
||||
* `document.fonts.load()` resolves once the FontFace bytes are fetched and
|
||||
* parsed, but Chrome lazily registers fonts with the canvas measurement engine
|
||||
* after that — `measureText` keeps returning a fallback width for some frames
|
||||
* even though `document.fonts.check()` reports the font as loaded.
|
||||
*
|
||||
* Pretext caches measurements per font string forever, so a single fallback
|
||||
* measurement during initial mount permanently poisons the cache and the
|
||||
* rendered text drifts visibly from its measured box. This helper polls canvas
|
||||
* measurement until each font reports a width that differs from the "unknown
|
||||
* font family" fallback, guaranteeing the next `measureText` call sees the real
|
||||
* glyph metrics.
|
||||
*
|
||||
* ponytail: deliberate copy of widgets/ComparisonView/lib's version — ADR-0002
|
||||
* keeps the shelved morph tool untouched, so we don't move its util. The poll
|
||||
* logic is the proven fix for Pretext's fallback-width cache poisoning; copying
|
||||
* it is cheaper than refactoring frozen code.
|
||||
*
|
||||
* @param fontStrings - Pretext/canvas font strings (`weight sizepx "family"`) to warm.
|
||||
*/
|
||||
import { getPretextFontString } from '../getPretextFontString/getPretextFontString';
|
||||
|
||||
const PROBE_TEXT = 'mmmmmmmmmm';
|
||||
const MAX_WAIT_MS = 1000;
|
||||
const DEFAULT_PROBE_SIZE_PX = 16;
|
||||
// Family unlikely to exist in any system — gives canvas's "unknown font" fallback width.
|
||||
const FALLBACK_PROBE_FAMILY = '__glyphdiff_no_such_font_42__';
|
||||
|
||||
export async function ensureCanvasFonts(fontStrings: string[]): Promise<void> {
|
||||
await Promise.all(fontStrings.map(f => document.fonts.load(f)));
|
||||
|
||||
// Pretext uses OffscreenCanvas when available; DOM canvas has separate font
|
||||
// registration timing, so we MUST poll using the same canvas type pretext does.
|
||||
const ctx = typeof OffscreenCanvas !== 'undefined'
|
||||
? new OffscreenCanvas(1, 1).getContext('2d')
|
||||
: document.createElement('canvas').getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Measure each font's "unknown font" fallback width (different per browser, per OS).
|
||||
// Canvas uses this same fallback for any font family it can't resolve, so when the
|
||||
// requested font finally registers, measureText will return a non-fallback width.
|
||||
const fallbackWidths = new Map<string, number>();
|
||||
for (const font of fontStrings) {
|
||||
const sizeMatch = font.match(/(\d+(?:\.\d+)?)px/);
|
||||
const sizePx = sizeMatch ? parseFloat(sizeMatch[1]) : DEFAULT_PROBE_SIZE_PX;
|
||||
ctx.font = getPretextFontString(400, sizePx, FALLBACK_PROBE_FAMILY);
|
||||
fallbackWidths.set(font, ctx.measureText(PROBE_TEXT).width);
|
||||
}
|
||||
|
||||
const deadline = performance.now() + MAX_WAIT_MS;
|
||||
const pending = new Set(fontStrings);
|
||||
while (pending.size > 0 && performance.now() < deadline) {
|
||||
for (const font of Array.from(pending)) {
|
||||
ctx.font = font;
|
||||
const w = ctx.measureText(PROBE_TEXT).width;
|
||||
if (Math.abs(w - fallbackWidths.get(font)!) > 0.5) {
|
||||
pending.delete(font);
|
||||
}
|
||||
}
|
||||
if (pending.size === 0) {
|
||||
break;
|
||||
}
|
||||
// Sequential by design: poll once per animation frame until fonts register.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { getPretextFontString } from './getPretextFontString';
|
||||
|
||||
describe('getPretextFontString', () => {
|
||||
it('formats weight, px size and quoted family for pretext/canvas', () => {
|
||||
expect(getPretextFontString(400, 48, 'Inter')).toBe('400 48px "Inter"');
|
||||
});
|
||||
it('preserves fractional sizes and quotes multi-word family names', () => {
|
||||
expect(getPretextFontString(700, 12.5, 'PT Serif')).toBe('700 12.5px "PT Serif"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Formats a font config into the string `@chenglou/pretext` and the Canvas 2D
|
||||
* `font` property both expect: `weight sizepx "family"`.
|
||||
*
|
||||
* ponytail: deliberate copy of widgets/ComparisonView/lib's version — ADR-0002
|
||||
* keeps the shelved morph tool untouched, so we don't move its util. Three lines
|
||||
* is cheaper to duplicate than to refactor frozen code.
|
||||
*
|
||||
* @param weight - Numeric font weight (e.g. 400).
|
||||
* @param sizePx - Font size in pixels.
|
||||
* @param fontName - The font family name.
|
||||
* @returns A formatted font string: `weight sizepx "fontName"`.
|
||||
*/
|
||||
export function getPretextFontString(weight: number, sizePx: number, fontName: string): string {
|
||||
return `${weight} ${sizePx}px "${fontName}"`;
|
||||
}
|
||||
@@ -17,7 +17,9 @@ export {
|
||||
export { clampNumber } from './clampNumber/clampNumber';
|
||||
export { cn } from './cn';
|
||||
export { debounce } from './debounce/debounce';
|
||||
export { ensureCanvasFonts } from './ensureCanvasFonts/ensureCanvasFonts';
|
||||
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
||||
export { getPretextFontString } from './getPretextFontString/getPretextFontString';
|
||||
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
|
||||
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
||||
export { smoothScroll } from './smoothScroll/smoothScroll';
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import {
|
||||
type LabelSize,
|
||||
labelSizeConfig,
|
||||
} from '$shared/ui/Label/config';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
} from '../labelConfig';
|
||||
|
||||
type BadgeVariant = 'default' | 'accent' | 'success' | 'warning' | 'info';
|
||||
|
||||
|
||||
@@ -84,9 +84,11 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
{formattedValue()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ── FULL MODE ──────────────────────────────────────────────────────────────── -->
|
||||
{:else}
|
||||
<!--
|
||||
FULL MODE
|
||||
+/- buttons flanking a slider popover.
|
||||
-->
|
||||
<div class={cn('flex items-center px-1 relative', className)}>
|
||||
<!-- Decrease button -->
|
||||
<Button
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as Input } from './Input.svelte';
|
||||
export { inputIconSize } from './types';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type LabelVariant,
|
||||
labelSizeConfig,
|
||||
labelVariantConfig,
|
||||
} from './config';
|
||||
} from '../labelConfig';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
They cannot pass leftIcon — it's owned by this wrapper.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Input } from '$shared/ui/Input';
|
||||
import { inputIconSize } from '$shared/ui/Input/types';
|
||||
import {
|
||||
Input,
|
||||
inputIconSize,
|
||||
} from '$shared/ui/Input';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ function close() {
|
||||
|
||||
{#if responsive.isMobile}
|
||||
<!--
|
||||
── MOBILE: fixed overlay ─────────────────────────────────────────────
|
||||
MOBILE: fixed overlay.
|
||||
Only rendered when open. Both backdrop and panel use Svelte transitions
|
||||
so they animate in and out independently.
|
||||
-->
|
||||
@@ -70,7 +70,7 @@ function close() {
|
||||
{/if}
|
||||
{:else}
|
||||
<!--
|
||||
── DESKTOP: collapsible column ───────────────────────────────────────
|
||||
DESKTOP: collapsible column.
|
||||
Always in the DOM — width transitions between 320px and 0.
|
||||
overflow-hidden clips the w-80 inner div during the collapse.
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
type LabelSize,
|
||||
type LabelVariant,
|
||||
labelSizeConfig,
|
||||
labelVariantConfig,
|
||||
} from '$shared/ui/Label/config';
|
||||
import type { Snippet } from 'svelte';
|
||||
} from '../labelConfig';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { swipeDirection } from './cycleGestures';
|
||||
|
||||
describe('swipeDirection', () => {
|
||||
it('maps a leftward swipe past threshold to next', () => {
|
||||
expect(swipeDirection(-80, 50)).toBe(1);
|
||||
});
|
||||
it('maps a rightward swipe past threshold to previous', () => {
|
||||
expect(swipeDirection(80, 50)).toBe(-1);
|
||||
});
|
||||
it('ignores sub-threshold movement', () => {
|
||||
expect(swipeDirection(20, 50)).toBe(0);
|
||||
expect(swipeDirection(-20, 50)).toBe(0);
|
||||
});
|
||||
it('treats the exact threshold as a swipe (inclusive)', () => {
|
||||
expect(swipeDirection(-50, 50)).toBe(1);
|
||||
expect(swipeDirection(50, 50)).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Maps a horizontal swipe delta to a cycle direction. A leftward swipe (negative
|
||||
* dx) advances to the next pairing (+1); a rightward swipe (positive dx) goes to
|
||||
* the previous (-1). Movement below the threshold is ignored (0).
|
||||
*
|
||||
* @param dx - Horizontal travel in px (end minus start).
|
||||
* @param threshold - Minimum absolute travel in px to count as a swipe.
|
||||
* @returns +1 (next), -1 (previous), or 0 (no cycle).
|
||||
*/
|
||||
export function swipeDirection(dx: number, threshold: number): -1 | 0 | 1 {
|
||||
if (dx <= -threshold) {
|
||||
return 1;
|
||||
}
|
||||
if (dx >= threshold) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<!--
|
||||
Component: Board
|
||||
Shell of the pairing board widget. Reads the board singleton, renders the
|
||||
focal frame, and wires focal cycling (keyboard arrows + touch swipe). Cycling
|
||||
swaps the focal in place (no remount) so the reserved frame height holds — the
|
||||
zero-shift invariant. Rail / sidebar / side-by-side compose in here later.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getBoard } from '$features/CompareBoard';
|
||||
import { swipeDirection } from '../../lib/cycleGestures/cycleGestures';
|
||||
import FocalFrame from '../FocalFrame/FocalFrame.svelte';
|
||||
|
||||
const board = getBoard();
|
||||
|
||||
/**
|
||||
* Minimum horizontal travel (px) to register a swipe as a cycle.
|
||||
*/
|
||||
const SWIPE_THRESHOLD = 50;
|
||||
|
||||
// Arrow-key cycling, suppressed while a specimen field is being edited.
|
||||
$effect(() => {
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement && active.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowRight') {
|
||||
board.cycle(1);
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
board.cycle(-1);
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
let touchStartX = 0;
|
||||
|
||||
function onTouchStart(event: TouchEvent) {
|
||||
touchStartX = event.touches[0]?.clientX ?? 0;
|
||||
}
|
||||
|
||||
function onTouchEnd(event: TouchEvent) {
|
||||
const endX = event.changedTouches[0]?.clientX ?? touchStartX;
|
||||
const direction = swipeDirection(endX - touchStartX, SWIPE_THRESHOLD);
|
||||
if (direction !== 0) {
|
||||
board.cycle(direction);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="w-full"
|
||||
role="group"
|
||||
aria-label="Pairing board — swipe or use arrow keys to cycle"
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchend={onTouchEnd}
|
||||
>
|
||||
<FocalFrame />
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import CandidateCard from './CandidateCard.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Widgets/Board/CandidateCard',
|
||||
component: CandidateCard,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Compact rail switcher for one pairing: the two font names in their own fonts. Click makes the pairing focal (aria-current). Not an evaluation surface — no real-length specimen.',
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte';
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
args={{
|
||||
pairing: { id: 'demo-1', headerFontId: 'Playfair Display', bodyFontId: 'Source Sans Pro' },
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof CandidateCard>)}
|
||||
<div style="max-width: 220px;">
|
||||
<CandidateCard {...args} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -0,0 +1,62 @@
|
||||
<!--
|
||||
Component: CandidateCard
|
||||
Compact switcher for one pairing in the rail — NOT an evaluation surface. Shows
|
||||
the two font names rendered in their own fonts at a small decorative size
|
||||
(clamp/cqi is fine here: chrome, not the honest-measure specimen). Click makes
|
||||
the pairing focal. Container-query driven so the same card works anywhere.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontApplicator,
|
||||
type UnifiedFont,
|
||||
getFontLifecycleManager,
|
||||
} from '$entities/Font';
|
||||
import type {
|
||||
Pairing,
|
||||
Role,
|
||||
} from '$entities/Pairing';
|
||||
import { getBoard } from '$features/CompareBoard';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* The pairing this card switches to.
|
||||
*/
|
||||
pairing: Pairing;
|
||||
}
|
||||
|
||||
let { pairing }: Props = $props();
|
||||
|
||||
const board = getBoard();
|
||||
const lifecycle = getFontLifecycleManager();
|
||||
|
||||
const isFocal = $derived(board.focalId === pairing.id);
|
||||
const fonts = $derived(board.resolvePairingFonts(pairing));
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="
|
||||
@container flex w-full flex-col gap-1 rounded-lg border p-3 text-left transition-colors
|
||||
aria-current:border-indigo-500 aria-current:bg-indigo-50
|
||||
border-slate-200 hover:border-slate-300
|
||||
"
|
||||
aria-current={isFocal ? 'true' : undefined}
|
||||
onclick={() => board.setFocal(pairing.id)}
|
||||
>
|
||||
{@render name('header', fonts.header?.name ?? pairing.headerFontId, fonts.header)}
|
||||
{@render name('body', fonts.body?.name ?? pairing.bodyFontId, fonts.body)}
|
||||
</button>
|
||||
|
||||
{#snippet name(role: Role, label: string, font: UnifiedFont | undefined)}
|
||||
{@const size = role === 'header' ? 'clamp(0.9rem, 5cqi, 1.25rem)' : 'clamp(0.75rem, 4cqi, 1rem)'}
|
||||
{#if font}
|
||||
<FontApplicator
|
||||
{font}
|
||||
status={lifecycle.getFontStatus(font.id, board.typo[role].weight, font.features?.isVariable)}
|
||||
>
|
||||
<span class="block truncate" style:font-size={size}>{label}</span>
|
||||
</FontApplicator>
|
||||
{:else}
|
||||
<span class="block truncate text-slate-500" style:font-size={size}>{label}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -0,0 +1,26 @@
|
||||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import FocalFrame from './FocalFrame.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Widgets/Board/FocalFrame',
|
||||
component: FocalFrame,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The constant-size focal pairing (header over body, each in its own font). Height is reserved from the board store's Pretext measurement before paint. Reads the board singleton, which self-seeds a curated pairing from the catalog.",
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story name="Default">
|
||||
{#snippet template()}
|
||||
<div style="max-width: 900px; margin: 2rem auto; padding: 0 1rem;">
|
||||
<FocalFrame />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -0,0 +1,74 @@
|
||||
<!--
|
||||
Component: FocalFrame
|
||||
The constant-size focal pairing: header RoleField over body RoleField, each in
|
||||
its own font. The frame's height is reserved from the board's Pretext-measured
|
||||
`focalFrameHeight` BEFORE content paints — this is the zero-shift mechanism, so
|
||||
cycling candidates of equal typography never reflows. Sizes are absolute px
|
||||
(honest measure), never cqi/clamp.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontApplicator,
|
||||
type UnifiedFont,
|
||||
getFontLifecycleManager,
|
||||
} from '$entities/Font';
|
||||
import type { Role } from '$entities/Pairing';
|
||||
import {
|
||||
FRAME_ROLE_GAP,
|
||||
getBoard,
|
||||
} from '$features/CompareBoard';
|
||||
import RoleField from '../RoleField/RoleField.svelte';
|
||||
|
||||
const board = getBoard();
|
||||
const lifecycle = getFontLifecycleManager();
|
||||
|
||||
let frameWidth = $state(0);
|
||||
|
||||
const focal = $derived(board.focal);
|
||||
const fonts = $derived(focal ? board.resolvePairingFonts(focal) : { header: undefined, body: undefined });
|
||||
// Reserve the measured height up front; 0 (unmeasured) leaves the frame to grow
|
||||
// naturally until the warm measurement lands.
|
||||
const reservedHeight = $derived(board.focalFrameHeight(frameWidth));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex w-full flex-col"
|
||||
style:gap="{FRAME_ROLE_GAP}px"
|
||||
style:min-height={reservedHeight > 0 ? `${reservedHeight}px` : undefined}
|
||||
bind:clientWidth={frameWidth}
|
||||
>
|
||||
{#if focal}
|
||||
{@render roleBlock('header', fonts.header)}
|
||||
{@render roleBlock('body', fonts.body)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet roleBlock(role: Role, font: UnifiedFont | undefined)}
|
||||
{@const typo = board.typo[role]}
|
||||
{#if font}
|
||||
<FontApplicator {font} status={lifecycle.getFontStatus(font.id, typo.weight, font.features?.isVariable)}>
|
||||
<RoleField
|
||||
{role}
|
||||
text={board.specimen[role]}
|
||||
fontName={font.name}
|
||||
size={typo.size}
|
||||
weight={typo.weight}
|
||||
leading={typo.leading}
|
||||
tracking={typo.tracking}
|
||||
oncommit={text => board.setSpecimen(role, text)}
|
||||
/>
|
||||
</FontApplicator>
|
||||
{:else}
|
||||
<!-- Font not yet resolved: render in system font so the field stays live. -->
|
||||
<RoleField
|
||||
{role}
|
||||
text={board.specimen[role]}
|
||||
fontName="system-ui"
|
||||
size={typo.size}
|
||||
weight={typo.weight}
|
||||
leading={typo.leading}
|
||||
tracking={typo.tracking}
|
||||
oncommit={text => board.setSpecimen(role, text)}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -0,0 +1,83 @@
|
||||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import RoleField from './RoleField.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Widgets/Board/RoleField',
|
||||
component: RoleField,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Always-editable plain-text specimen field for one role. Uncontrolled while focused (no caret jump), commits on blur. Header blocks Enter (single-line); body allows line breaks. Paste inserts plain text only.',
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let headerText = $state('The Art of Harmonious Type');
|
||||
let bodyText = $state(
|
||||
'Good typography is invisible. It guides the eye without calling attention to itself, balancing rhythm, contrast, and proportion.',
|
||||
);
|
||||
let emptyText = $state('');
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Header (single-line)"
|
||||
args={{
|
||||
role: 'header',
|
||||
text: headerText,
|
||||
fontName: 'Georgia',
|
||||
size: 48,
|
||||
weight: 700,
|
||||
leading: 1.1,
|
||||
tracking: 0,
|
||||
oncommit: t => (headerText = t),
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof RoleField>)}
|
||||
<RoleField {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Body (multi-line)"
|
||||
args={{
|
||||
role: 'body',
|
||||
text: bodyText,
|
||||
fontName: 'Georgia',
|
||||
size: 18,
|
||||
weight: 400,
|
||||
leading: 1.5,
|
||||
tracking: 0,
|
||||
oncommit: t => (bodyText = t),
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof RoleField>)}
|
||||
<RoleField {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Empty (placeholder)"
|
||||
args={{
|
||||
role: 'body',
|
||||
text: emptyText,
|
||||
fontName: 'Georgia',
|
||||
size: 18,
|
||||
weight: 400,
|
||||
leading: 1.5,
|
||||
tracking: 0,
|
||||
oncommit: t => (emptyText = t),
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof RoleField>)}
|
||||
<RoleField {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!--
|
||||
Component: RoleField
|
||||
Always-live plain-text specimen field for one role (header/body). Edits stay
|
||||
uncontrolled while the field is focused (the prop never writes back over the
|
||||
caret), and commit to the board on blur. The editable node is wrapped so any
|
||||
frame transition animates the wrapper, never the node being typed in.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Role } from '$entities/Pairing';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Which role this field edits — drives Enter behaviour and the placeholder.
|
||||
*/
|
||||
role: Role;
|
||||
/**
|
||||
* Current committed specimen text for the role.
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Font family the specimen renders in.
|
||||
*/
|
||||
fontName: string;
|
||||
/**
|
||||
* Font size in px.
|
||||
*/
|
||||
size: number;
|
||||
/**
|
||||
* Numeric font weight.
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Unitless line-height multiplier.
|
||||
*/
|
||||
leading: number;
|
||||
/**
|
||||
* Letter spacing in px.
|
||||
*/
|
||||
tracking: number;
|
||||
/**
|
||||
* Called with the field's text when it commits (on blur).
|
||||
*/
|
||||
oncommit: (text: string) => void;
|
||||
/**
|
||||
* Extra CSS classes for the wrapper.
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
role,
|
||||
text,
|
||||
fontName,
|
||||
size,
|
||||
weight,
|
||||
leading,
|
||||
tracking,
|
||||
oncommit,
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
|
||||
let element = $state<HTMLDivElement>();
|
||||
let focused = $state(false);
|
||||
|
||||
const placeholder = $derived(role === 'header' ? 'Header text…' : 'Body text…');
|
||||
|
||||
/**
|
||||
* Sync the prop into the DOM only while unfocused. External updates (cycling,
|
||||
* reset, another field's commit) must never move the caret mid-edit, so we skip
|
||||
* the write whenever the field has focus. innerText keeps the content plain.
|
||||
*/
|
||||
$effect(() => {
|
||||
const next = text;
|
||||
if (element && !focused && element.innerText !== next) {
|
||||
element.innerText = next;
|
||||
}
|
||||
});
|
||||
|
||||
function handleBlur() {
|
||||
focused = false;
|
||||
if (element) {
|
||||
oncommit(element.innerText);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
// Strip formatting: insert the clipboard's plain text only.
|
||||
event.preventDefault();
|
||||
const plain = event.clipboardData?.getData('text/plain') ?? '';
|
||||
document.execCommand('insertText', false, plain);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
// Header is single-line: Enter commits (blur) instead of inserting a break.
|
||||
if (role === 'header' && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
element?.blur();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
<div
|
||||
bind:this={element}
|
||||
contenteditable="plaintext-only"
|
||||
spellcheck="false"
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
aria-label="{role} specimen"
|
||||
data-placeholder={placeholder}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={handleBlur}
|
||||
onpaste={handlePaste}
|
||||
onkeydown={handleKeydown}
|
||||
class="
|
||||
w-full min-h-[1.4em] outline-none whitespace-pre-wrap break-words
|
||||
empty:before:content-[attr(data-placeholder)] empty:before:text-slate-400
|
||||
focus:outline-none
|
||||
"
|
||||
style:font-family={`"${fontName}"`}
|
||||
style:font-size="{size}px"
|
||||
style:font-weight={weight}
|
||||
style:line-height={leading}
|
||||
style:letter-spacing="{tracking}px"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/svelte';
|
||||
import { tick } from 'svelte';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import RoleField from './RoleField.svelte';
|
||||
|
||||
const baseProps = { fontName: 'Georgia', size: 24, weight: 400, leading: 1.4, tracking: 0 };
|
||||
|
||||
describe('RoleField', () => {
|
||||
it('renders the initial text', async () => {
|
||||
render(RoleField, { props: { role: 'header', text: 'Hello', oncommit: () => {}, ...baseProps } });
|
||||
await tick();
|
||||
expect(screen.getByRole('textbox').textContent).toBe('Hello');
|
||||
});
|
||||
|
||||
it('commits the field text on blur (not on input)', async () => {
|
||||
const oncommit = vi.fn();
|
||||
render(RoleField, { props: { role: 'body', text: 'Start', oncommit, ...baseProps } });
|
||||
await tick();
|
||||
const field = screen.getByRole('textbox');
|
||||
field.textContent = 'Edited';
|
||||
await fireEvent.blur(field);
|
||||
expect(oncommit).toHaveBeenCalledWith('Edited');
|
||||
});
|
||||
|
||||
it('prevents Enter on the header role (single-line)', async () => {
|
||||
render(RoleField, { props: { role: 'header', text: 'Title', oncommit: () => {}, ...baseProps } });
|
||||
await tick();
|
||||
const field = screen.getByRole('textbox');
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true });
|
||||
field.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('allows Enter on the body role (multi-line)', async () => {
|
||||
render(RoleField, { props: { role: 'body', text: 'Para', oncommit: () => {}, ...baseProps } });
|
||||
await tick();
|
||||
const field = screen.getByRole('textbox');
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true });
|
||||
field.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export { computeStrutHeight } from './utils/computeStrutHeight/computeStrutHeight';
|
||||
export {
|
||||
createDotCrossfade,
|
||||
getDotTransitionParams,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { computeStrutHeight } from './computeStrutHeight';
|
||||
|
||||
describe('computeStrutHeight', () => {
|
||||
it('uses the centering height when the line-height is generous', () => {
|
||||
// centering = 40/2 + 16*0.34 = 25.44; floor = 16*1.1 = 17.6 → centering wins.
|
||||
expect(computeStrutHeight(40, 16)).toBeCloseTo(25.44, 5);
|
||||
});
|
||||
|
||||
it('falls back to the ascent floor when the line-height is tight', () => {
|
||||
// centering = 16/2 + 16*0.34 = 13.44; floor = 16*1.1 = 17.6 → floor wins.
|
||||
expect(computeStrutHeight(16, 16)).toBeCloseTo(17.6, 5);
|
||||
});
|
||||
|
||||
it('treats the floor and centering height as equal at the crossover line-height', () => {
|
||||
// centering == floor when lineHeight = 1.52 * fontSize → 24.32 for 16px.
|
||||
expect(computeStrutHeight(24.32, 16)).toBeCloseTo(17.6, 5);
|
||||
});
|
||||
|
||||
it('scales with font size', () => {
|
||||
// centering = 60/2 + 32*0.34 = 40.88; floor = 32*1.1 = 35.2 → centering wins.
|
||||
expect(computeStrutHeight(60, 32)).toBeCloseTo(40.88, 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Fraction of the font size added to half the line height to drop the strut's
|
||||
* baseline from the line box's vertical middle down to the text's optical
|
||||
* center. Empirical: ~0.34em approximates a Latin font's midline-to-baseline
|
||||
* offset, so glyphs sit centered rather than riding high in the line.
|
||||
*/
|
||||
const BASELINE_OFFSET_RATIO = 0.34;
|
||||
|
||||
/**
|
||||
* Minimum strut height as a multiple of the font size. Floors the strut above
|
||||
* the fonts' ascent (~1em) so that at tight line-heights it stays the tallest
|
||||
* inline box and keeps ownership of the line baseline. Empirical: 1.1 clears the
|
||||
* tallest ascenders in the catalog's Latin fonts.
|
||||
*/
|
||||
const MIN_HEIGHT_RATIO = 1.1;
|
||||
|
||||
/**
|
||||
* Pixel height for a slider line's invisible baseline strut.
|
||||
*
|
||||
* The slider renders each line with a zero-width strut span whose box is
|
||||
* deliberately the tallest inline box on the line. The browser pins a line box's
|
||||
* baseline to its tallest inline box; fixing the strut's height independent of
|
||||
* which bulk runs or window chars are currently mounted keeps the baseline (and
|
||||
* every glyph) from jumping as the slider sweeps runs in and out. With
|
||||
* `overflow: hidden` the strut's baseline sits at its bottom edge, so this height
|
||||
* also sets the text's vertical position within the line box.
|
||||
*
|
||||
* The result is `max(centeringHeight, ascentFloor)`:
|
||||
* - `centeringHeight = lineHeightPx / 2 + fontSizePx * BASELINE_OFFSET_RATIO`
|
||||
* centers the text — half the line box places the strut's bottom edge at the
|
||||
* vertical middle, and the offset term nudges the baseline down to the glyphs'
|
||||
* optical center.
|
||||
* - `ascentFloor = fontSizePx * MIN_HEIGHT_RATIO` keeps the strut taller than the
|
||||
* fonts' ascent when the line-height is tight (where `centeringHeight` would
|
||||
* shrink below a real glyph box and let another box steal the baseline).
|
||||
*
|
||||
* @param lineHeightPx Line height in pixels (typography line-height × font size).
|
||||
* @param fontSizePx Rendered font size in pixels.
|
||||
* @returns Strut height in pixels.
|
||||
*/
|
||||
export function computeStrutHeight(lineHeightPx: number, fontSizePx: number): number {
|
||||
const centeringHeight = lineHeightPx / 2 + fontSizePx * BASELINE_OFFSET_RATIO;
|
||||
const ascentFloor = fontSizePx * MIN_HEIGHT_RATIO;
|
||||
return Math.max(centeringHeight, ascentFloor);
|
||||
}
|
||||
@@ -26,8 +26,11 @@ import {
|
||||
import {
|
||||
type TypographySettingsStore,
|
||||
getTypographySettingsStore,
|
||||
} from '$features/AdjustTypography/model';
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
} from '$features/AdjustTypography';
|
||||
import {
|
||||
createPersistentStore,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
import { untrack } from 'svelte';
|
||||
import { getPretextFontString } from '../../lib';
|
||||
|
||||
@@ -56,12 +59,6 @@ const STORAGE_KEY = 'glyphdiff:comparison';
|
||||
*/
|
||||
const FONT_READY_FALLBACK_MS = 1000;
|
||||
|
||||
// Persistent storage for selected comparison fonts
|
||||
const storage = createPersistentStore<ComparisonState>(STORAGE_KEY, {
|
||||
fontAId: null,
|
||||
fontBId: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Store for managing font comparison state.
|
||||
*
|
||||
@@ -100,22 +97,39 @@ export class ComparisonStore {
|
||||
* TanStack Query-backed store for efficient batch font retrieval
|
||||
*/
|
||||
#fontsByIdsStore: FontsByIdsStore;
|
||||
|
||||
/**
|
||||
* Paginated font catalog — source of fonts for default seeding.
|
||||
*/
|
||||
#fontCatalog: FontCatalogStore;
|
||||
|
||||
/**
|
||||
* Typography settings applied to the rendered comparison.
|
||||
*/
|
||||
#typography: TypographySettingsStore;
|
||||
|
||||
/**
|
||||
* Font load/cache/eviction manager; pinned to keep compared fonts resident.
|
||||
*/
|
||||
#lifecycle: FontLifecycleManager;
|
||||
/**
|
||||
* Per-instance persistent storage for the selected comparison fonts.
|
||||
*/
|
||||
#storage = createPersistentStore<ComparisonState>(STORAGE_KEY, {
|
||||
fontAId: null,
|
||||
fontBId: null,
|
||||
});
|
||||
/**
|
||||
* Disposes the constructor's $effect.root. Must be run on teardown.
|
||||
*/
|
||||
#disposeEffects: () => void;
|
||||
|
||||
constructor() {
|
||||
// Synchronously seed the batch store with any IDs already in storage
|
||||
const { fontAId, fontBId } = storage.value;
|
||||
const { fontAId, fontBId } = this.#storage.value;
|
||||
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
||||
this.#fontCatalog = getFontCatalog();
|
||||
this.#typography = getTypographySettingsStore();
|
||||
this.#lifecycle = getFontLifecycleManager();
|
||||
|
||||
$effect.root(() => {
|
||||
this.#disposeEffects = $effect.root(() => {
|
||||
// Sync batch results → fontA / fontB
|
||||
$effect(() => {
|
||||
const fonts = this.#fontsByIdsStore.fonts;
|
||||
@@ -123,7 +137,7 @@ export class ComparisonStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const { fontAId: aId, fontBId: bId } = storage.value;
|
||||
const { fontAId: aId, fontBId: bId } = this.#storage.value;
|
||||
if (aId) {
|
||||
const fa = fonts.find(f => f.id === aId);
|
||||
if (fa) {
|
||||
@@ -178,7 +192,7 @@ export class ComparisonStore {
|
||||
// Untracked: only the catalog load should drive this effect, not the
|
||||
// user's storage writes that happen as a result of normal selection.
|
||||
const hasStoredSelection = untrack(() => {
|
||||
return storage.value.fontAId !== null || storage.value.fontBId !== null;
|
||||
return this.#storage.value.fontAId !== null || this.#storage.value.fontBId !== null;
|
||||
});
|
||||
|
||||
if (hasStoredSelection) {
|
||||
@@ -194,7 +208,7 @@ export class ComparisonStore {
|
||||
untrack(() => {
|
||||
const id1 = fonts[0].id;
|
||||
const id2 = fonts[fonts.length - 1].id;
|
||||
storage.value = { fontAId: id1, fontBId: id2 };
|
||||
this.#storage.value = { fontAId: id1, fontBId: id2 };
|
||||
this.#fontsByIdsStore.setIds([id1, id2]);
|
||||
});
|
||||
});
|
||||
@@ -279,7 +293,7 @@ export class ComparisonStore {
|
||||
* Updates persistent storage with the current font selection.
|
||||
*/
|
||||
private updateStorage() {
|
||||
storage.value = {
|
||||
this.#storage.value = {
|
||||
fontAId: this.#fontA?.id ?? null,
|
||||
fontBId: this.#fontB?.id ?? null,
|
||||
};
|
||||
@@ -363,19 +377,28 @@ export class ComparisonStore {
|
||||
this.#fontA = undefined;
|
||||
this.#fontB = undefined;
|
||||
this.#fontsByIdsStore.setIds([]);
|
||||
storage.clear();
|
||||
this.#storage.clear();
|
||||
this.#typography.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes reactive effects and the persistent store. Call on teardown.
|
||||
*/
|
||||
destroy() {
|
||||
this.#disposeEffects();
|
||||
this.#storage.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
let _comparisonStore: ComparisonStore | undefined;
|
||||
const comparisonStore = createSingleton(
|
||||
() => new ComparisonStore(),
|
||||
instance => {
|
||||
instance.resetAll();
|
||||
instance.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
export function getComparisonStore(): ComparisonStore {
|
||||
return (_comparisonStore ??= new ComparisonStore());
|
||||
}
|
||||
export const getComparisonStore = comparisonStore.get;
|
||||
|
||||
// test-only reset, so specs don't share a live observer
|
||||
export function __resetComparisonStore() {
|
||||
_comparisonStore?.resetAll();
|
||||
_comparisonStore = undefined;
|
||||
}
|
||||
// test-only reset, so specs don't share a live observer or persisted state
|
||||
export const __resetComparisonStore = comparisonStore.reset;
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
|
||||
import type { UnifiedFont } from '$entities/Font';
|
||||
import { UNIFIED_FONTS } from '$entities/Font/testing';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -44,6 +46,8 @@ const mockStorage = vi.hoisted(() => {
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
storage.destroy = vi.fn();
|
||||
|
||||
return storage;
|
||||
});
|
||||
|
||||
@@ -82,6 +86,12 @@ vi.mock('$entities/Font/model', async importOriginal => {
|
||||
};
|
||||
});
|
||||
|
||||
const mockTypography = vi.hoisted(() => ({
|
||||
weight: 400,
|
||||
renderedSize: 48,
|
||||
reset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$features/AdjustTypography', () => ({
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
|
||||
createTypographyControlManager: vi.fn(() => ({
|
||||
@@ -89,15 +99,6 @@ vi.mock('$features/AdjustTypography', () => ({
|
||||
renderedSize: 48,
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockTypography = vi.hoisted(() => ({
|
||||
weight: 400,
|
||||
renderedSize: 48,
|
||||
reset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$features/AdjustTypography/model', () => ({
|
||||
getTypographySettingsStore: () => mockTypography,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<!--
|
||||
Component: Line
|
||||
Renders one laid-out line as three regions: a fontA bulk run (past the
|
||||
slider), an N-char crossfade window straddling it, and a fontB bulk run (not
|
||||
slider), a crossfade window straddling it (its size derived per line from
|
||||
the line's grapheme count via `windowSizeForLine`), and a fontB bulk run (not
|
||||
yet past). Bulk runs are native shaped text (kerning, ligatures); only the
|
||||
window uses per-char DOM. `split` is a primitive so the render-model
|
||||
`$derived` skips recomputation on ticks that leave it unchanged.
|
||||
@@ -10,10 +11,13 @@
|
||||
import {
|
||||
type ComparisonLine,
|
||||
computeLineRenderModel,
|
||||
windowSizeForLine,
|
||||
} from '$entities/Font';
|
||||
import { getTypographySettingsStore } from '$features/AdjustTypography';
|
||||
import { computeStrutHeight } from '../../lib';
|
||||
import { getComparisonStore } from '../../model';
|
||||
import Character from '../Character/Character.svelte';
|
||||
import SettledText from '../SettledText/SettledText.svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -24,16 +28,14 @@ interface Props {
|
||||
* Count of chars the slider has passed, from `findSplitIndex`.
|
||||
*/
|
||||
split: number;
|
||||
/**
|
||||
* Number of chars in the crossfade window around the split.
|
||||
*/
|
||||
windowSize: number;
|
||||
}
|
||||
|
||||
let { line, split, windowSize }: Props = $props();
|
||||
let { line, split }: Props = $props();
|
||||
|
||||
const comparisonStore = getComparisonStore();
|
||||
|
||||
const windowSize = $derived(windowSizeForLine(line.chars.length));
|
||||
|
||||
const model = $derived(computeLineRenderModel(line, split, windowSize));
|
||||
|
||||
const typography = getTypographySettingsStore();
|
||||
@@ -44,36 +46,9 @@ const fontSizePx = $derived(typography.renderedSize);
|
||||
const lineHeightPx = $derived(typography.height * typography.renderedSize);
|
||||
const letterSpacingPx = $derived(typography.spacing * typography.renderedSize);
|
||||
|
||||
/**
|
||||
* Class and style are single short bindings so the formatter keeps
|
||||
* `<span ...>{text}</span>` on one line. A wrapped text expression would leak
|
||||
* its indentation into the span content under `white-space: pre`.
|
||||
*/
|
||||
const BULK_LEFT_CLASS =
|
||||
'inline-block align-baseline leading-none text-swiss-black/75 dark:text-brand/75 transition-colors duration-300';
|
||||
const BULK_RIGHT_CLASS =
|
||||
'inline-block align-baseline leading-none text-neutral-950 dark:text-white transition-colors duration-300';
|
||||
|
||||
const leftStyle = $derived(`font-family:${fontA?.name ?? ''};font-size:${fontSizePx}px`);
|
||||
const rightStyle = $derived(`font-family:${fontB?.name ?? ''};font-size:${fontSizePx}px`);
|
||||
|
||||
/**
|
||||
* Stops the whole line from jumping up or down as the slider moves. The browser
|
||||
* pins a line box's baseline to its tallest inline box, so without a fixed
|
||||
* reference the baseline (and every glyph) shifts the moment a bulk run appears
|
||||
* or disappears, or the last window char morphs to a font with a taller ascent.
|
||||
* This invisible strut is always the tallest box — `overflow: hidden` puts its
|
||||
* baseline at its bottom edge — so it owns the line baseline and holds it still.
|
||||
* Its height also sets the text's vertical position (the container is block, so
|
||||
* nothing else centers it).
|
||||
*
|
||||
* Height factors are empirical: the first term centers the text, the `* 1.1`
|
||||
* floor keeps the strut above the fonts' ascent at tight line-heights.
|
||||
*/
|
||||
const strutHeightPx = $derived(Math.max(lineHeightPx / 2 + fontSizePx * 0.34, fontSizePx * 1.1));
|
||||
const strutStyle = $derived(
|
||||
`display:inline-block;width:0;overflow:hidden;vertical-align:baseline;height:${strutHeightPx}px`,
|
||||
);
|
||||
// Invisible strut that pins the line baseline so glyphs don't jump as the
|
||||
// slider moves; `computeStrutHeight` explains the why and the formula.
|
||||
const strutHeightPx = $derived(computeStrutHeight(lineHeightPx, fontSizePx));
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@@ -91,15 +66,19 @@ const strutStyle = $derived(
|
||||
style:letter-spacing="{letterSpacingPx}px"
|
||||
style:font-weight={typography.weight}
|
||||
>
|
||||
<span style={strutStyle} aria-hidden="true"></span>
|
||||
<span
|
||||
class="inline-block w-0 overflow-hidden align-baseline"
|
||||
style:height="{strutHeightPx}px"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{#if model.leftText}
|
||||
<span class={BULK_LEFT_CLASS} style={leftStyle}>{model.leftText}</span>
|
||||
<SettledText text={model.leftText} fontFamily={fontA.name} fontSize={fontSizePx} side="left" />
|
||||
{/if}
|
||||
{#each model.windowChars as wc (wc.key)}
|
||||
<Character char={wc.char} {fontA} {fontB} isPast={wc.isPast} fontSize={fontSizePx} />
|
||||
{/each}
|
||||
{#if model.rightText}
|
||||
<span class={BULK_RIGHT_CLASS} style={rightStyle}>{model.rightText}</span>
|
||||
<SettledText text={model.rightText} fontFamily={fontB.name} fontSize={fontSizePx} side="right" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<!--
|
||||
Component: SettledText
|
||||
One side's text settled in a single font — left is fontA the slider has
|
||||
passed, right is fontB not yet reached. A native shaped run (kerning,
|
||||
ligatures); the crossfading middle uses per-char Character cells instead.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/**
|
||||
* Run text.
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* CSS font-family name.
|
||||
*/
|
||||
fontFamily: string;
|
||||
/**
|
||||
* Font size in px.
|
||||
*/
|
||||
fontSize: number;
|
||||
/**
|
||||
* Window side — selects the color treatment.
|
||||
*/
|
||||
side: 'left' | 'right';
|
||||
}
|
||||
|
||||
let { text, fontFamily, fontSize, side }: Props = $props();
|
||||
|
||||
// Left (fontA, passed) is dimmed; right (fontB, pending) is full-strength.
|
||||
const SIDE_CLASS: Record<Props['side'], string> = {
|
||||
left:
|
||||
'inline-block align-baseline leading-none text-swiss-black/75 dark:text-brand/75 transition-colors duration-300',
|
||||
right: 'inline-block align-baseline leading-none text-neutral-950 dark:text-white transition-colors duration-300',
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class={SIDE_CLASS[side]} style:font-family="'{fontFamily}'" style:font-size="{fontSize}px">{text}</span>
|
||||
@@ -52,7 +52,6 @@ const side = $derived<Side>(comparisonStore.side);
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<!-- ── Header: title + A/B toggle ────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
p-6 shrink-0
|
||||
@@ -96,14 +95,13 @@ const side = $derived<Side>(comparisonStore.side);
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
<!-- ── Main: content area (no scroll - VirtualList handles scrolling) ─────────────────────────────── -->
|
||||
<!-- No scroll here; VirtualList handles scrolling -->
|
||||
<div class="flex-1 min-h-0 surface-canvas">
|
||||
{#if main}
|
||||
{@render main()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Bottom: fixed controls ─────────────────────────────────────── -->
|
||||
{#if controls}
|
||||
<div
|
||||
class="
|
||||
|
||||
@@ -107,12 +107,6 @@ const layout = new DualFontLayout();
|
||||
|
||||
let layoutResult = $state<ComparisonResult>({ lines: [], totalHeight: 0 });
|
||||
|
||||
/**
|
||||
* N-window size for the per-char crossfade zone around the slider split.
|
||||
* Tuned so chars complete their 100ms opacity crossfade before exiting the window.
|
||||
*/
|
||||
const WINDOW_SIZE = 5;
|
||||
|
||||
// Track container width changes (window resize, sidebar toggle, etc.)
|
||||
$effect(() => {
|
||||
if (!container) {
|
||||
@@ -344,7 +338,7 @@ $effect(() => {
|
||||
>
|
||||
{#each layoutResult.lines as line, lineIdx (lineIdx)}
|
||||
{@const split = findSplitIndex(line, sliderPos, containerWidth)}
|
||||
<Line {line} {split} windowSize={WINDOW_SIZE} />
|
||||
<Line {line} {split} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
* - Desktop Large (>= 1280px): 4 columns
|
||||
*/
|
||||
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
import { responsiveManager } from '$shared/lib';
|
||||
import {
|
||||
createPersistentStore,
|
||||
createSingleton,
|
||||
responsiveManager,
|
||||
} from '$shared/lib';
|
||||
|
||||
export type LayoutMode = 'list' | 'grid';
|
||||
|
||||
@@ -144,22 +147,25 @@ class LayoutManager {
|
||||
this.#mode = DEFAULT_CONFIG.mode;
|
||||
this.#store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
let _layoutManager: LayoutManager | undefined;
|
||||
/**
|
||||
* Dispose the persistent store's save effect. Call on store disposal.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.#store.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App-wide layout manager, created on first access. Lazy so its persisted
|
||||
* layout preference isn't read at module load.
|
||||
*/
|
||||
export function getLayoutManager(): LayoutManager {
|
||||
return (_layoutManager ??= new LayoutManager());
|
||||
}
|
||||
const layoutManager = createSingleton(() => new LayoutManager(), instance => instance.destroy());
|
||||
|
||||
export const getLayoutManager = layoutManager.get;
|
||||
|
||||
// test-only reset, so specs don't share persisted layout state
|
||||
export function __resetLayoutManager() {
|
||||
_layoutManager = undefined;
|
||||
}
|
||||
export const __resetLayoutManager = layoutManager.reset;
|
||||
|
||||
// Export class for testing purposes
|
||||
export { LayoutManager };
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
// Helper to flush Svelte effects (they run in microtasks)
|
||||
@@ -69,12 +70,17 @@ describe('layoutStore', () => {
|
||||
});
|
||||
|
||||
it('should default to list mode when localStorage has invalid data', async () => {
|
||||
// createPersistentStore logs and swallows the parse error; silence
|
||||
// the expected warn so it doesn't pollute test output, and assert it.
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
localStorage.setItem(STORAGE_KEY, 'invalid json');
|
||||
|
||||
const { LayoutManager } = await import('./layoutStore.svelte');
|
||||
const manager = new LayoutManager();
|
||||
|
||||
expect(manager.mode).toBe('list');
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should default to list mode when localStorage has empty object', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontSampler,
|
||||
FontVirtualList,
|
||||
createFontRowSizeResolver,
|
||||
getFontCatalog,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
TypographyMenu,
|
||||
getTypographySettingsStore,
|
||||
} from '$features/AdjustTypography';
|
||||
import { FontSampler } from '$features/DisplayFont';
|
||||
import { throttle } from '$shared/lib/utils';
|
||||
import { Skeleton } from '$shared/ui';
|
||||
import { getLayoutManager } from '../../model';
|
||||
@@ -127,7 +127,7 @@ const fontRowHeight = $derived.by(() =>
|
||||
getFontStatus reads a $state SvelteMap, so the row stays reactive.
|
||||
-->
|
||||
{@const status = fontLifecycleManager.getFontStatus(font.id, typographySettingsStore.weight, font.features?.isVariable)}
|
||||
<FontSampler bind:text {font} {index} {status} />
|
||||
<FontSampler bind:text {font} {index} {status} typography={typographySettingsStore} />
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
|
||||
/* Path Aliases */
|
||||
"paths": {
|
||||
"$lib/*": ["./src/lib/*"],
|
||||
"$app/*": ["./src/app/*"],
|
||||
"$widgets/*": ["./src/widgets/*"],
|
||||
"$shared/*": ["./src/shared/*"],
|
||||
|
||||
@@ -6,7 +6,6 @@ export default defineConfig({
|
||||
plugins: [svelte(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib: '/src/lib',
|
||||
$app: '/src/app',
|
||||
$shared: '/src/shared',
|
||||
$entities: '/src/entities',
|
||||
|
||||
@@ -23,7 +23,6 @@ export default defineConfig({
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib: path.resolve(__dirname, './src/lib'),
|
||||
$app: path.resolve(__dirname, './src/app'),
|
||||
$shared: path.resolve(__dirname, './src/shared'),
|
||||
$entities: path.resolve(__dirname, './src/entities'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user