Merge pull request 'Refactor/reacrhitecture to fsd+' (#49) from refactor/reacrhitecture-to-fsd+ into main
Workflow / build (push) Successful in 1m6s
Workflow / e2e (push) Successful in 58s
Workflow / publish (push) Successful in 24s

Reviewed-on: #49
This commit was merged in pull request #49.
This commit is contained in:
2026-06-03 09:55:46 +00:00
167 changed files with 3011 additions and 1946 deletions
+195
View File
@@ -0,0 +1,195 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["import"],
"categories": {
"correctness": "error",
"suspicious": "warn",
"perf": "warn",
// style/restriction off: opt-in, contradictory grab-bags. Wanted rules enabled individually below.
"style": "off",
"restriction": "off"
},
"env": {
"browser": true,
"es2021": true
},
"ignorePatterns": [
"node_modules",
"dist",
"build",
".svelte-kit",
".vercel",
"*.config.js",
"*.config.ts"
],
"rules": {
"no-console": "warn",
"no-debugger": "error",
"no-alert": "warn",
// no-cycle resolves $-aliases via tsconfig auto-discovery (no resolver config in oxlint)
"import/no-cycle": "error",
"import/no-duplicates": "warn",
"import/no-unassigned-import": "off", // CSS/side-effect imports are intentional
"no-sequences": "error",
"no-underscore-dangle": "off",
"no-shadow": "warn",
"no-implicit-coercion": "warn",
"no-await-in-loop": "warn",
"no-return-assign": "warn",
"no-new": "warn",
"no-unneeded-ternary": "warn"
},
// FSD boundaries. oxlint has no zone rule, so layer/segment direction is enforced
// with no-restricted-imports patterns scoped per glob. Layer order (high->low):
// app(exempt top shell) > routes > widgets > features > entities > shared.
// A layer bans imports from itself (cross-slice via alias) and every layer above.
// Overrides are LAST-WINS, not merged: a file matching two overrides keeps only the
// last rule config. So the domain override (below) is a self-contained superset, and
// the test/story override (last) fully disables boundary checks for those files.
"overrides": [
// shared = lowest layer: imports nothing above it
{
"files": ["src/shared/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": [
"$app",
"$app/*",
"$routes",
"$routes/*",
"$widgets",
"$widgets/*",
"$features",
"$features/*",
"$entities",
"$entities/*"
],
"message": "FSD layer violation: `shared` is the lowest layer and may not import from any layer above it."
}
]
}]
}
},
// entities: import shared only; no other entity via alias; interior ui<-only-ui
{
"files": ["src/entities/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*", "$features", "$features/*"],
"message": "FSD layer violation: `entities` may only import from `shared`."
},
{
"group": ["$entities", "$entities/*"],
"message": "FSD cross-slice violation: do not import another entity via its alias. Use relative imports inside your own slice; invert the dependency through a higher layer for cross-slice needs."
},
{
"group": ["../ui", "../ui/*", "../../ui/*"],
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
}
]
}]
}
},
// features: import entities/shared only; no other feature via alias
{
"files": ["src/features/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*"],
"message": "FSD layer violation: `features` may only import from `entities` and `shared`."
},
{
"group": ["$features", "$features/*"],
"message": "FSD cross-slice violation: do not import another feature via its alias. Invert the dependency through a higher layer (widget/route)."
},
{
"group": ["../ui", "../ui/*", "../../ui/*"],
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
}
]
}]
}
},
// widgets: import features/entities/shared only; no other widget via alias
{
"files": ["src/widgets/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": ["$app", "$app/*", "$routes", "$routes/*"],
"message": "FSD layer violation: `widgets` may only import from `features`, `entities`, and `shared`."
},
{
"group": ["$widgets", "$widgets/*"],
"message": "FSD cross-slice violation: do not import another widget via its alias. Invert the dependency through the route layer."
},
{
"group": ["../ui", "../ui/*", "../../ui/*"],
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
}
]
}]
}
},
// routes: top of the FSD list, imports any layer below; only app is above it
{
"files": ["src/routes/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{ "group": ["$app", "$app/*"], "message": "FSD layer violation: `routes` may not import from `app`." }
]
}]
}
},
// domain (FSD+): pure logic. Imports NO layer (not even shared) and no sibling
// model/ui segment. Superset: wins over the layer override above for these files.
{
"files": ["src/**/domain/**"],
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{
"group": [
"$app",
"$app/*",
"$routes",
"$routes/*",
"$widgets",
"$widgets/*",
"$features",
"$features/*",
"$entities",
"$entities/*",
"$shared",
"$shared/*"
],
"message": "FSD+ domain isolation: `domain` is pure business logic and may not import any layer (including `shared`). Allowed: relative imports within `domain` and framework-agnostic npm packages."
},
{
"group": ["../model", "../model/*", "../../model/*", "../ui", "../ui/*", "../../ui/*"],
"message": "FSD+ domain isolation: `domain` may not import sibling `model` or `ui` segments. Dependency flows ui -> model -> domain, never back."
}
]
}]
}
},
// tests/stories/fixtures legitimately cross-import (e.g. $entities/Font/testing).
// Must be LAST so last-wins disables boundary checks for them.
{
"files": ["**/*.test.ts", "**/*.spec.ts", "**/*.stories.svelte", "src/**/testing/**"],
"rules": {
"no-restricted-imports": "off"
}
}
]
}
+3 -3
View File
@@ -8,9 +8,9 @@ test.describe('preview text', () => {
await comparison.pickPair('Inter', 'Roboto');
await comparison.setPreviewText('Sphinx');
// 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.
// With WINDOW_SIZE=5, "Sphinx" (6 chars) fits 5 in the window.
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(5);
});
test('preserves the typed value in the input', async ({ comparison }) => {
-27
View File
@@ -1,27 +0,0 @@
{
"categories": {
"correctness": "error",
"suspicious": "warn",
"perf": "warn",
"style": "warn",
"restriction": "error"
},
"env": {
"browser": true,
"es2021": true
},
"ignore": [
"node_modules",
"dist",
"build",
".svelte-kit",
".vercel",
"*.config.js",
"*.config.ts"
],
"rules": {
"no-console": "off",
"no-debugger": "error",
"no-alert": "warn"
}
}
+6 -1
View File
@@ -4,6 +4,10 @@
"version": "0.0.1",
"packageManager": "yarn@4.11.0",
"type": "module",
"sideEffects": [
"*.css",
"**/router.ts"
],
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -65,6 +69,7 @@
},
"dependencies": {
"@chenglou/pretext": "0.0.6",
"@tanstack/svelte-query": "6.1.28"
"@tanstack/svelte-query": "6.1.28",
"sv-router": "^0.16.3"
}
}
+13 -7
View File
@@ -6,21 +6,27 @@
/**
* App Component
*
* Application entry point component. Wraps the main page route within the shared
* Application entry point component. Wraps the active route within the shared
* layout shell. This is the root component mounted by the application.
*
* Structure:
* - QueryProvider provides TanStack Query client for data fetching
* - Layout provides sidebar, header/footer, and page container
* - Page renders the current route content
* - Router renders the matched route component
*/
import Page from '$routes/Page.svelte';
import { QueryProvider } from './providers';
import '$routes/router';
import { Router } from 'sv-router';
import {
AppBindingsProvider,
QueryProvider,
} from './providers';
import Layout from './ui/Layout.svelte';
</script>
<QueryProvider>
<Layout>
<Page />
</Layout>
<AppBindingsProvider>
<Layout>
<Router />
</Layout>
</AppBindingsProvider>
</QueryProvider>
+24
View File
@@ -0,0 +1,24 @@
<!--
Component: AppBindings
Provider that starts app-wide store bindings (filters → sort → font catalog)
for its subtree. Mount-scoped so the bindings' lifetime tracks the app tree.
-->
<script lang="ts">
import { startFilterBindings } from '$features/FilterAndSortFonts';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
/**
* Content snippet
*/
children?: Snippet;
}
let { children }: Props = $props();
// startFilterBindings returns its $effect.root cleanup; onMount runs it on unmount.
onMount(() => startFilterBindings());
</script>
{@render children?.()}
+4 -1
View File
@@ -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}>
+1
View File
@@ -1 +1,2 @@
export { default as AppBindingsProvider } from './AppBindings.svelte';
export { default as QueryProvider } from './QueryProvider.svelte';
+3 -1
View File
@@ -3,7 +3,7 @@
Application shell with providers and page wrapper
-->
<script lang="ts">
import { themeManager } from '$features/ChangeAppTheme';
import { getThemeManager } from '$features/ChangeAppTheme';
import G from '$shared/assets/G.svg';
import { ResponsiveProvider } from '$shared/lib';
import { cn } from '$shared/lib';
@@ -32,6 +32,8 @@ interface Props {
let { children }: Props = $props();
let fontsReady = $state(true);
const themeManager = getThemeManager();
const theme = $derived(themeManager.value);
onMount(() => themeManager.init());
-2
View File
@@ -1,2 +0,0 @@
export * from './store/scrollBreadcrumbsStore.svelte';
export * from './types/types.ts';
@@ -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 {
+2 -2
View File
@@ -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);
});
}
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
import { clearCache } from '@chenglou/pretext';
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import { DualFontLayout } from './DualFontLayout';
// FontA: 10px per character. FontB: 15px per character.
// The mock dispatches on whether the font string contains 'FontA' or 'FontB'.
const FONT_A_WIDTH = 10;
const FONT_B_WIDTH = 15;
function fontWidthFactory(font: string, text: string): number {
const perChar = font.includes('FontA') ? FONT_A_WIDTH : FONT_B_WIDTH;
return text.length * perChar;
}
describe('DualFontLayout', () => {
let layout: DualFontLayout;
beforeEach(() => {
installCanvasMock(fontWidthFactory);
clearCache();
layout = new DualFontLayout();
});
it('returns empty result for empty string', () => {
const result = layout.layout('', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(result.lines).toHaveLength(0);
expect(result.totalHeight).toBe(0);
});
it('uses worst-case width across both fonts to determine line breaks', () => {
// 'AB CD' — two 2-char words separated by a space.
// FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total.
// FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '.
// Unified must use FontB widths — so it must wrap at the same place FontB wraps.
const result = layout.layout('AB CD', '400 16px "FontA"', '400 16px "FontB"', 35, 20);
expect(result.lines.length).toBeGreaterThan(1);
// First line text must not include both words.
expect(result.lines[0].text).not.toContain('CD');
});
it('provides xA and xB offsets for both fonts on a single line', () => {
// 'ABC' fits in 500px for both fonts.
// FontA: A@0(w=10), B@10(w=10), C@20(w=10)
// FontB: A@0(w=15), B@15(w=15), C@30(w=15)
const result = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const chars = result.lines[0].chars;
expect(chars).toHaveLength(3);
expect(chars[0].xA).toBe(0);
expect(chars[0].widthA).toBe(FONT_A_WIDTH);
expect(chars[0].xB).toBe(0);
expect(chars[0].widthB).toBe(FONT_B_WIDTH);
expect(chars[1].xA).toBe(FONT_A_WIDTH); // 10
expect(chars[1].widthA).toBe(FONT_A_WIDTH);
expect(chars[1].xB).toBe(FONT_B_WIDTH); // 15
expect(chars[1].widthB).toBe(FONT_B_WIDTH);
expect(chars[2].xA).toBe(FONT_A_WIDTH * 2); // 20
expect(chars[2].xB).toBe(FONT_B_WIDTH * 2); // 30
});
it('returns cached result when called again with same arguments', () => {
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).toBe(r1); // strict reference equality — same object
});
it('re-computes when text changes', () => {
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('DEF', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).not.toBe(r1);
expect(r2.lines[0].text).not.toBe(r1.lines[0].text);
});
it('re-computes when width changes', () => {
const r1 = layout.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 60, 20);
expect(r2).not.toBe(r1);
});
it('re-computes when fontA changes', () => {
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
const r2 = layout.layout('ABC', '400 24px "FontA"', '400 16px "FontB"', 500, 20);
expect(r2).not.toBe(r1);
});
});
@@ -0,0 +1,278 @@
import {
type PreparedTextWithSegments,
layoutWithLines,
prepareWithSegments,
} from '@chenglou/pretext';
/**
* Default render size in px when callers omit the `size` arg on `layout()`.
*/
const DEFAULT_RENDER_SIZE_PX = 16;
/**
* Per-grapheme data computed during dual-font layout. Internal to the engine;
* consumed by computeLineRenderModel to derive the per-frame render model.
*/
export interface ComparisonChar {
/**
* Grapheme cluster (may be >1 code unit for emoji, combining marks).
*/
char: string;
/**
* X offset from line start in fontA, pixels.
*/
xA: number;
/**
* Advance width of this grapheme in fontA, pixels.
*/
widthA: number;
/**
* X offset from line start in fontB, pixels.
*/
xB: number;
/**
* Advance width of this grapheme in fontB, pixels.
*/
widthB: number;
}
/**
* A single laid-out line. `chars` carries the per-grapheme data needed by
* computeLineRenderModel. Consumers should not iterate it directly.
*/
export interface ComparisonLine {
/**
* Full text of this line as returned by pretext.
*/
text: string;
/**
* Rendered width in pixels — maximum across fontA and fontB.
*/
width: number;
/**
* Per-grapheme metadata for both fonts.
*/
chars: ComparisonChar[];
}
/**
* Aggregated output of a dual-font layout pass.
*/
export interface ComparisonResult {
/**
* Per-line grapheme data. Empty when input text is empty.
*/
lines: ComparisonLine[];
/**
* Total height in pixels.
*/
totalHeight: number;
}
/**
* Dual-font text layout engine backed by `@chenglou/pretext`.
*
* Computes identical line breaks for two fonts simultaneously by constructing a
* "unified" prepared-text object whose per-glyph widths are the worst-case maximum
* of font A and font B. This guarantees that both fonts wrap at exactly the same
* positions, making side-by-side or slider comparison visually coherent.
*
* Relies on pretext's published structural fields on `PreparedTextWithSegments`
* (`widths`, `breakableFitAdvances`, `lineEndFitAdvances`, `lineEndPaintAdvances`)
* which are exposed via the `PreparedCore` intersection in `@chenglou/pretext@0.0.6`.
*
* **Two-level caching strategy**
* 1. Font-change cache (`#preparedA`, `#preparedB`, `#unifiedPrepared`): rebuilt only
* when `text`, `fontA`, or `fontB` changes. `prepareWithSegments` is expensive
* (canvas measurement), so this avoids re-measuring during slider interaction.
* 2. Layout cache (`#lastResult`): rebuilt when `width` or `lineHeight` changes but
* the fonts have not changed. Line-breaking is cheap relative to measurement, but
* still worth skipping on every render tick.
*
* Per-frame slider state derivation lives in `computeLineRenderModel`, not on the
* class. This class is pure layout + caching; it holds no reactive state.
*/
export class DualFontLayout {
#segmenter: Intl.Segmenter;
// Cached prepared data
#preparedA: PreparedTextWithSegments | null = null;
#preparedB: PreparedTextWithSegments | null = null;
#unifiedPrepared: PreparedTextWithSegments | null = null;
#lastText = '';
#lastFontA = '';
#lastFontB = '';
#lastSpacing = 0;
#lastSize = 0;
// Cached layout results
#lastWidth = -1;
#lastLineHeight = -1;
#lastResult: ComparisonResult | null = null;
constructor(locale?: string) {
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
}
/**
* Lay out `text` using both fonts within `width` pixels.
*
* Line breaks are determined by the worst-case (maximum) glyph widths across
* both fonts, so both fonts always wrap at identical positions.
*
* @param text Raw text to lay out.
* @param fontA CSS font string for the first font: `"weight sizepx \"family\""`.
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
* @param width Available line width in pixels.
* @param lineHeight Line height in pixels (passed directly to pretext).
* @param spacing Letter spacing in em (from typography settings).
* @param size Current font size in pixels (used to convert spacing em to px).
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
*/
layout(
text: string,
fontA: string,
fontB: string,
width: number,
lineHeight: number,
spacing: number = 0,
size: number = DEFAULT_RENDER_SIZE_PX,
): ComparisonResult {
if (!text) {
return { lines: [], totalHeight: 0 };
}
const spacingPx = spacing * size;
const isFontChange = text !== this.#lastText
|| fontA !== this.#lastFontA
|| fontB !== this.#lastFontB
|| spacing !== this.#lastSpacing
|| size !== this.#lastSize;
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
if (!isFontChange && !isLayoutChange && this.#lastResult) {
return this.#lastResult;
}
// 1. Prepare (or use cache)
if (isFontChange) {
this.#preparedA = prepareWithSegments(text, fontA);
this.#preparedB = prepareWithSegments(text, fontB);
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB, spacingPx);
this.#lastText = text;
this.#lastFontA = fontA;
this.#lastFontB = fontB;
this.#lastSpacing = spacing;
this.#lastSize = size;
}
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
return { lines: [], totalHeight: 0 };
}
const { lines, height } = layoutWithLines(this.#unifiedPrepared, width, lineHeight);
// 3. Map results back to both fonts
const preparedA = this.#preparedA;
const preparedB = this.#preparedB;
const resultLines: ComparisonLine[] = lines.map(line => {
const chars: ComparisonChar[] = [];
let currentXA = 0;
let currentXB = 0;
const start = line.start;
const end = line.end;
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
const segmentText = preparedA.segments[sIdx];
if (segmentText === undefined) {
continue;
}
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
const advA = preparedA.breakableFitAdvances[sIdx];
const advB = preparedB.breakableFitAdvances[sIdx];
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
const char = graphemes[gIdx];
let wA = advA != null ? advA[gIdx]! : preparedA.widths[sIdx]!;
let wB = advB != null ? advB[gIdx]! : preparedB.widths[sIdx]!;
// Apply letter spacing (tracking) to the width of each character
wA += spacingPx;
wB += spacingPx;
chars.push({
char,
xA: currentXA,
widthA: wA,
xB: currentXB,
widthB: wB,
});
currentXA += wA;
currentXB += wB;
}
}
return {
text: line.text,
width: line.width,
chars,
};
});
this.#lastWidth = width;
this.#lastLineHeight = lineHeight;
this.#lastResult = {
lines: resultLines,
totalHeight: height,
};
return this.#lastResult;
}
/**
* Merge two prepared texts into a worst-case unified version so both fonts
* wrap at identical positions. Per-segment widths are the elementwise max
* across both fonts, with `spacingPx` added to model letter-spacing.
*/
#createUnifiedPrepared(
a: PreparedTextWithSegments,
b: PreparedTextWithSegments,
spacingPx: number = 0,
): PreparedTextWithSegments {
const unified: PreparedTextWithSegments = { ...a };
unified.widths = a.widths.map((w, i) => Math.max(w, b.widths[i]) + spacingPx);
unified.lineEndFitAdvances = a.lineEndFitAdvances.map((w, i) =>
Math.max(w, b.lineEndFitAdvances[i]) + spacingPx
);
unified.lineEndPaintAdvances = a.lineEndPaintAdvances.map((w, i) =>
Math.max(w, b.lineEndPaintAdvances[i]) + spacingPx
);
unified.breakableFitAdvances = a.breakableFitAdvances.map((advA, i) => {
const advB = b.breakableFitAdvances[i];
if (!advA && !advB) {
return null;
}
if (!advA) {
return advB!.map(w => w + spacingPx);
}
if (!advB) {
return advA.map(w => w + spacingPx);
}
return advA.map((w, j) => Math.max(w, advB[j]) + spacingPx);
});
return unified;
}
}
@@ -0,0 +1,220 @@
import {
describe,
expect,
it,
} from 'vitest';
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
import {
type LineRenderModel,
computeLineRenderModel,
findSplitIndex,
} from './computeLineRenderModel';
/**
* Build a ComparisonLine fixture with given per-char widths. xA/xB are
* cumulative prefix sums of widthA/widthB respectively.
*/
function makeLine(
chars: { char: string; widthA: number; widthB: number }[],
): ComparisonLine {
let xA = 0;
let xB = 0;
const out: ComparisonLine = {
text: chars.map(c => c.char).join(''),
width: chars.reduce((s, c) => s + Math.max(c.widthA, c.widthB), 0),
chars: chars.map(c => {
const entry = {
char: c.char,
xA,
xB,
widthA: c.widthA,
widthB: c.widthB,
};
xA += c.widthA;
xB += c.widthB;
return entry;
}),
};
return out;
}
/**
* Test helper: compute split + render model in one step, matching the
* SliderArea call site shape.
*/
function compute(
line: ComparisonLine,
sliderPos: number,
containerWidth: number,
windowSize: number,
): LineRenderModel {
const split = findSplitIndex(line, sliderPos, containerWidth);
return computeLineRenderModel(line, split, windowSize);
}
describe('computeLineRenderModel', () => {
it('returns empty model for an empty line', () => {
const line = makeLine([]);
const model = compute(line, 50, 500, 5);
expect(model.leftText).toBe('');
expect(model.windowChars).toEqual([]);
expect(model.rightText).toBe('');
});
it('places entire line in rightText when slider is at 0', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
const model = compute(line, 0, 500, 0);
expect(model.leftText).toBe('');
expect(model.windowChars).toEqual([]);
expect(model.rightText).toBe('ABC');
});
it('places entire line in leftText when slider is at 100', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
const model = compute(line, 100, 500, 0);
expect(model.leftText).toBe('ABC');
expect(model.windowChars).toEqual([]);
expect(model.rightText).toBe('');
});
it('splits line correctly with slider mid-line (window=0)', () => {
// Equal widths → line is centered. Container=300, total=30 → xOffset=135.
// Char thresholds (per the threshold formula in the design):
// threshold[i] = xOffset + prefA[i] + widthA[i]/2
// i=0: 135 + 0 + 5 = 140 → 140/300 = 46.67%
// i=1: 135 + 10 + 5 = 150 → 150/300 = 50.00%
// i=2: 135 + 20 + 5 = 160 → 160/300 = 53.33%
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
// Slider just past B's threshold (50%) but not C's (53.33%).
const model = compute(line, 51, 300, 0);
expect(model.leftText).toBe('AB');
expect(model.rightText).toBe('C');
});
it('centers window of size 3 on the split index', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
// Slider past A and B (~thresholds 43.33%, 46.67%); not past C (50%).
// split = 2 → halfWindow = 1 → windowStart = 1, windowEnd = 4
const model = compute(line, 48, 300, 3);
expect(model.leftText).toBe('A');
expect(model.windowChars.map(w => w.char)).toEqual(['B', 'C', 'D']);
expect(model.rightText).toBe('E');
});
it('clamps window at line start when slider is near 0', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
const model = compute(line, 0, 300, 3);
expect(model.leftText).toBe('');
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B', 'C']);
expect(model.rightText).toBe('DE');
});
it('clamps window at line end when slider is near 100', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
const model = compute(line, 100, 300, 3);
expect(model.leftText).toBe('AB');
expect(model.windowChars.map(w => w.char)).toEqual(['C', 'D', 'E']);
expect(model.rightText).toBe('');
});
it('treats whole line as window when line is shorter than windowSize', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
]);
const model = compute(line, 50, 300, 5);
expect(model.leftText).toBe('');
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B']);
expect(model.rightText).toBe('');
});
it('produces stable keys across slider movement within the same line', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
const a = compute(line, 40, 300, 3);
const b = compute(line, 60, 300, 3);
// Chars that appear in both windows must carry identical keys.
for (const charA of a.windowChars) {
const charB = b.windowChars.find(w => w.char === charA.char);
if (charB !== undefined) {
expect(charB.key).toBe(charA.key);
}
}
});
it('marks isPast=true for chars before the split and false for chars after', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
{ char: 'D', widthA: 10, widthB: 10 },
{ char: 'E', widthA: 10, widthB: 10 },
]);
// split = 2 → A,B past; C,D,E not
const model = compute(line, 48, 300, 5);
const expected = new Map([['A', true], ['B', true], ['C', false], ['D', false], ['E', false]]);
for (const wc of model.windowChars) {
expect(wc.isPast).toBe(expected.get(wc.char));
}
});
});
describe('findSplitIndex', () => {
it('returns 0 for empty line', () => {
const line = makeLine([]);
expect(findSplitIndex(line, 50, 500)).toBe(0);
});
it('returns 0 when slider is before all char thresholds', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
expect(findSplitIndex(line, 0, 300)).toBe(0);
});
it('returns chars.length when slider is past all char thresholds', () => {
const line = makeLine([
{ char: 'A', widthA: 10, widthB: 10 },
{ char: 'B', widthA: 10, widthB: 10 },
{ char: 'C', widthA: 10, widthB: 10 },
]);
expect(findSplitIndex(line, 100, 300)).toBe(3);
});
});
@@ -0,0 +1,133 @@
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
/**
* Per-line render slice consumed by Line.svelte. The window is centered on the
* slider's split index and clamps at line boundaries.
*/
export interface LineRenderModel {
/**
* Chars before the window joined into a single string, rendered as one fontA text run.
*/
leftText: string;
/**
* Window chars — each rendered as its own Character element with crossfade slots.
*/
windowChars: Array<{
/**
* Stable key for Svelte keyed each — survives slider movement within the same line.
*/
key: string;
/**
* Grapheme cluster to render.
*/
char: string;
/**
* True once the slider has crossed this char's threshold.
*/
isPast: boolean;
}>;
/**
* Chars after the window joined into a single string, rendered as one fontB text run.
*/
rightText: string;
}
/**
* Returns the count of chars whose flip threshold the slider has crossed.
*
* Exposed as a separate step so consumers can pass the resulting primitive
* `split` across component boundaries: when split is unchanged tick-to-tick,
* downstream `$derived` reads of `computeLineRenderModel(line, split, ...)`
* short-circuit on value equality and skip re-rendering.
*
* For each candidate split `i`, the line's hypothetical width at that moment is
* `prefA[i] + widthA[i] + sufB[i+1]` (past chars in fontA, char `i` flipping, future
* chars in fontB). The threshold is the x of char `i`'s center in the centered line.
* Thresholds are monotonically non-decreasing in `i`, so the scan short-circuits on
* the first miss.
*/
export function findSplitIndex(
line: ComparisonLine,
sliderPos: number,
containerWidth: number,
): number {
const chars = line.chars;
const n = chars.length;
if (n === 0) {
return 0;
}
const sliderX = (sliderPos / 100) * containerWidth;
const prefA = new Float64Array(n + 1);
const sufB = new Float64Array(n + 1);
for (let i = 0, j = n - 1; i < n; i++, j--) {
prefA[i + 1] = prefA[i] + chars[i].widthA;
sufB[j] = sufB[j + 1] + chars[j].widthB;
}
let split = 0;
for (let i = 0; i < n; i++) {
const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1];
const xOffset = (containerWidth - totalWidth) / 2;
const threshold = xOffset + prefA[i] + chars[i].widthA / 2;
if (sliderX > threshold) {
split = i + 1;
} else {
break;
}
}
return split;
}
/**
* Slices a laid-out line into three regions around a precomputed split index:
* a fontA bulk run, an N-char crossfade window, and a fontB bulk run.
*
* Pure and allocation-bounded: two strings plus a `windowSize`-length array per call.
* Takes `split` as a primitive so callers can feed it into a `$derived` and
* skip re-evaluation on ticks where the split index is unchanged.
*
* @param line Line from `DualFontLayout.layout()`. Empty `chars` yields an empty model.
* @param split Count of chars the slider has passed, in `[0, line.chars.length]`.
* @param windowSize Number of chars in the crossfade window. Clamped to `[0, line.chars.length]`.
* At line edges the window is shifted (not shrunk) to keep its size.
*/
export function computeLineRenderModel(
line: ComparisonLine,
split: number,
windowSize: number,
): LineRenderModel {
const chars = line.chars;
const n = chars.length;
if (n === 0) {
return { leftText: '', windowChars: [], rightText: '' };
}
const halfWindow = Math.floor(Math.max(0, windowSize) / 2);
let windowStart = clamp(split - halfWindow, 0, n);
let windowEnd = clamp(windowStart + Math.max(0, windowSize), 0, n);
windowStart = Math.max(0, windowEnd - Math.max(0, windowSize));
const leftText = chars.slice(0, windowStart).map(c => c.char).join('');
const rightText = chars.slice(windowEnd).map(c => c.char).join('');
const windowChars = chars.slice(windowStart, windowEnd).map((c, idx) => ({
key: `${windowStart + idx}-${c.char}`,
char: c.char,
isPast: (windowStart + idx) < split,
}));
return { leftText, windowChars, rightText };
}
/**
* Clamps `value` into the inclusive range `[lo, hi]`. Assumes `lo <= hi`.
*/
function clamp(value: number, lo: number, hi: number): number {
if (value < lo) {
return lo;
}
if (value > hi) {
return hi;
}
return value;
}
+10
View File
@@ -0,0 +1,10 @@
export {
type ComparisonLine,
type ComparisonResult,
DualFontLayout,
} from './DualFontLayout/DualFontLayout';
export {
computeLineRenderModel,
findSplitIndex,
type LineRenderModel,
} from './computeLineRenderModel/computeLineRenderModel';
+92 -4
View File
@@ -1,4 +1,92 @@
export * from './api';
export * from './lib';
export * from './model';
export * from './ui';
export {
computeLineRenderModel,
DualFontLayout,
findSplitIndex,
} from './domain';
export type {
ComparisonLine,
ComparisonResult,
LineRenderModel,
} from './domain';
export {
createFontRowSizeResolver,
FontNetworkError,
FontResponseError,
getFontUrl,
} from './lib';
export type { FontRowSizeResolverOptions } from './lib';
export {
FontApplicator,
FontSampler,
FontVirtualList,
} from './ui';
// Pure model surface (types + constants).
export {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LETTER_SPACING_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LETTER_SPACING,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LETTER_SPACING,
MIN_LINE_HEIGHT,
VIRTUAL_INDEX_NOT_LOADED,
} from './model/const/const';
export type {
FilterGroup,
FilterType,
FontCategory,
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
FontFeatures,
FontFilters,
FontLoadRequestConfig,
FontLoadStatus,
FontMetadata,
FontProvider,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
UnifiedFont,
UnifiedFontVariant,
} from './model/types';
/*
* Stores are exposed as lazy accessors / classes (not eager singletons): the
* entity's public API is complete, so consumers go through this barrel instead
* of deep-importing `./model` (FSD public-API boundary). Construction happens on
* first call, so this is inert at import. The slice root already transitively
* loads `@tanstack/query-core` via `./ui` (FontVirtualList), so surfacing the
* stores here adds no new eager cost.
*/
export {
FontLifecycleManager,
FontsByIdsStore,
getFontCatalog,
getFontLifecycleManager,
} from './model';
export type { FontCatalogStore } from './model';
/*
* `./api` (proxy clients: `fetchProxyFonts`, `seedFontCache`, …) is intentionally
* NOT re-exported here — those are not part of the entity's consumed surface and
* importing them eagerly constructs the TanStack `queryClient`. Import via the
* segment: `import { fetchProxyFonts } from '$entities/Font/api'`.
*/
// `./testing` is intentionally not re-exported: fixtures must not leak into the
// production public API. Import them via `$entities/Font/testing`.
@@ -0,0 +1,111 @@
import {
describe,
expect,
it,
} from 'vitest';
import type { UnifiedFont } from '../../model/types';
import { createFontLoadRequestContfig } from './createFontLoadRequestContfig';
/**
* Minimal UnifiedFont mock — override only the fields a case exercises.
*/
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
const baseFont: UnifiedFont = {
id: 'test-font',
name: 'Test Font',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: [],
styles: {},
metadata: {
cachedAt: Date.now(),
},
features: {
isVariable: false,
tags: [],
},
};
return { ...baseFont, ...overrides };
}
describe('createFontLoadRequestContfig', () => {
it('builds a single-element config when a URL resolves', () => {
const font = createMockFont({
id: 'roboto',
name: 'Roboto',
styles: { variants: { '400': 'https://example.com/roboto-400.woff2' } },
});
const result = createFontLoadRequestContfig(font, 400);
expect(result).toEqual([
{
id: 'roboto',
name: 'Roboto',
weight: 400,
url: 'https://example.com/roboto-400.woff2',
isVariable: false,
},
]);
});
it('returns an empty array when no URL resolves (flatMap drops the font)', () => {
const font = createMockFont({ styles: {} });
expect(createFontLoadRequestContfig(font, 400)).toEqual([]);
});
it('forwards isVariable from font features', () => {
const font = createMockFont({
features: { isVariable: true, tags: [] },
styles: { variants: { '700': 'https://example.com/inter-vf.woff2' } },
});
const [config] = createFontLoadRequestContfig(font, 700);
expect(config.isVariable).toBe(true);
});
it('sets isVariable to undefined when features is absent', () => {
// features is non-optional on UnifiedFont, but upstream data can be partial —
// the optional chain must not throw, and isVariable stays undefined.
const font = createMockFont({
styles: { variants: { '400': 'https://example.com/font.woff2' } },
});
// @ts-expect-error — deliberately drop the guaranteed field to exercise the optional chain
font.features = undefined;
const [config] = createFontLoadRequestContfig(font, 400);
expect(config.isVariable).toBeUndefined();
});
it('uses the resolved fallback URL, not just exact matches', () => {
// getFontUrl falls back to styles.regular when the exact weight is missing;
// the config must carry whatever URL actually resolved.
const font = createMockFont({
styles: { regular: 'https://example.com/font-regular.woff2' },
});
const [config] = createFontLoadRequestContfig(font, 900);
expect(config.url).toBe('https://example.com/font-regular.woff2');
expect(config.weight).toBe(900);
});
it('carries the requested weight even when the URL is a shared fallback', () => {
const font = createMockFont({
styles: { variants: { '400': 'https://example.com/shared.woff2' } },
});
expect(createFontLoadRequestContfig(font, 700)[0].weight).toBe(700);
});
it('propagates the invalid-weight error from getFontUrl', () => {
const font = createMockFont();
expect(() => createFontLoadRequestContfig(font, 450)).toThrow('Invalid weight: 450');
});
});
@@ -0,0 +1,33 @@
import type {
FontLoadRequestConfig,
UnifiedFont,
} from '../../model';
import { getFontUrl } from '../getFontUrl/getFontUrl';
/**
* Build the font-lifecycle load request for a single font at a given weight.
*
* Returns a 0-or-1 element array rather than `FontLoadRequestConfig | undefined`
* so call sites can `flatMap` over a font list — resolve the URL and drop fonts
* that have none in a single pass, with no separate filter step. An empty array
* means the font has no loadable asset for this weight (or its fallbacks) and is
* silently skipped.
*
* `isVariable` is forwarded from the font's features so the lifecycle manager can
* dedupe variable fonts per ID (they load once regardless of weight) while still
* loading static fonts per weight.
*
* @param font - Unified font to load
* @param weight - Numeric weight (100-900)
* @returns Single-element config array, or `[]` when no URL resolves
* @throws Error when weight is outside the valid 100-900 range (propagated from `getFontUrl`)
*/
export function createFontLoadRequestContfig(font: UnifiedFont, weight: number): FontLoadRequestConfig[] {
const url = getFontUrl(font, weight);
if (!url) {
return [];
}
return [{ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable }];
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { NonRetryableError } from '$shared/api/queryClient';
import { NonRetryableError } from '$shared/api/nonRetryableError';
/**
* Thrown when the network request to the proxy API fails.
-44
View File
@@ -1,49 +1,5 @@
export { getFontUrl } from './getFontUrl/getFontUrl';
// Mock data helpers for Storybook and testing
export {
createCategoriesFilter,
createErrorState,
createGenericFilter,
createLoadingState,
createMockComparisonStore,
// Filter mocks
createMockFilter,
createMockFontApiResponse,
createMockFontStoreState,
// Store mocks
createMockQueryState,
createMockReactiveState,
createMockStore,
createProvidersFilter,
createSubsetsFilter,
createSuccessState,
generateMixedCategoryFonts,
generateMockFonts,
generatePaginatedFonts,
generateSequentialFilter,
GENERIC_FILTERS,
getAllMockFonts,
getFontsByCategory,
getFontsByProvider,
MOCK_FILTERS,
MOCK_FILTERS_ALL_SELECTED,
MOCK_FILTERS_EMPTY,
MOCK_FILTERS_SELECTED,
MOCK_FONT_STORE_STATES,
MOCK_STORES,
type MockFilterOptions,
type MockFilters,
type MockFontStoreState,
// Font mocks
// Types
type MockQueryObserverResult,
type MockQueryState,
mockUnifiedFont,
type MockUnifiedFontOptions,
UNIFIED_FONTS,
} from './mocks';
export {
FontNetworkError,
FontResponseError,
@@ -14,6 +14,7 @@ vi.mock('@chenglou/pretext', async () => {
layout: vi.fn(actual.layout),
};
});
import { mockUnifiedFont } from '$entities/Font/testing';
import {
beforeEach,
describe,
@@ -22,7 +23,6 @@ import {
vi,
} from 'vitest';
import type { FontLoadStatus } from '../../model/types';
import { mockUnifiedFont } from '../mocks';
import { createFontRowSizeResolver } from './createFontRowSizeResolver';
// Fixed-width canvas mock: every character is 10px wide regardless of font.
-57
View File
@@ -1,6 +1,3 @@
import type { ControlModel } from '$shared/lib';
import type { ControlId } from '../types/typography';
/**
* Font size constants
*/
@@ -33,60 +30,6 @@ export const MIN_LETTER_SPACING = -0.1;
export const MAX_LETTER_SPACING = 0.5;
export const LETTER_SPACING_STEP = 0.01;
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
step: FONT_SIZE_STEP,
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Size',
},
{
id: 'font_weight',
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Weight',
},
{
id: 'line_height',
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Leading',
},
{
id: 'letter_spacing',
value: DEFAULT_LETTER_SPACING,
max: MAX_LETTER_SPACING,
min: MIN_LETTER_SPACING,
step: LETTER_SPACING_STEP,
increaseLabel: 'Increase Letter Spacing',
decreaseLabel: 'Decrease Letter Spacing',
controlLabel: 'Tracking',
},
];
/**
* Font size multipliers
*/
export const MULTIPLIER_S = 0.5;
export const MULTIPLIER_M = 0.75;
export const MULTIPLIER_L = 1;
/**
* Index value for items not yet loaded in a virtualized list.
* Treated as being at the very bottom of the infinite scroll.
+51 -3
View File
@@ -1,3 +1,51 @@
export * from './const/const';
export * from './store';
export * from './types';
export {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LETTER_SPACING_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LETTER_SPACING,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LETTER_SPACING,
MIN_LINE_HEIGHT,
VIRTUAL_INDEX_NOT_LOADED,
} from './const/const';
// Stores (lazy accessors + classes)
export {
__resetFontLifecycleManager,
FontLifecycleManager,
FontsByIdsStore,
getFontCatalog,
getFontLifecycleManager,
} from './store';
export type { FontCatalogStore } from './store';
export type {
FilterGroup,
FilterType,
FontCategory,
FontCollectionFilters,
FontCollectionSort,
FontCollectionState,
FontFeatures,
FontFilters,
FontLoadRequestConfig,
FontLoadStatus,
FontMetadata,
FontProvider,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
UnifiedFont,
UnifiedFontVariant,
} from './types';
@@ -1,4 +1,7 @@
import { QueryClient } from '@tanstack/query-core';
import {
generateMixedCategoryFonts,
generateMockFonts,
} from '$entities/Font/testing';
import { flushSync } from 'svelte';
import {
afterEach,
@@ -12,27 +15,33 @@ import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import {
generateMixedCategoryFonts,
generateMockFonts,
} from '../../../lib/mocks/fonts.mock';
import type { UnifiedFont } from '../../types';
import { FontCatalogStore } from './fontCatalogStore.svelte';
vi.mock('$shared/api/queryClient', async importOriginal => {
/**
* Import QueryClient inside the factory rather than referencing the top-level binding.
* A hoisted vi.mock factory that touches a module-level import can hit that import
* before it is initialized (ReferenceError) when the import sits in a circular/eager
* barrel chain — which it now does via $shared/lib → BaseQueryStore → query-core.
*/
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,8 +484,12 @@ export class FontCatalogStore {
}
}
export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
return new FontCatalogStore(params);
}
const catalog = createSingleton(
() => new FontCatalogStore({ limit: 50 }),
instance => instance.destroy(),
);
export const fontCatalogStore = new FontCatalogStore({ limit: 50 });
export const getFontCatalog = catalog.get;
// test-only reset, so specs don't share a live observer
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,
@@ -420,6 +421,15 @@ export class FontLifecycleManager {
}
/**
* Singleton instance — use throughout the application for unified font loading state.
* App-wide font lifecycle manager, created on first access. Lazy so its
* AbortController / FontFace bookkeeping isn't set up at module load.
*/
export const 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 const __resetFontLifecycleManager = fontLifecycleManager.reset;
@@ -71,7 +71,7 @@ describe('loadFont', () => {
it('throws FontParseError when font.load() rejects', async () => {
const loadError = new Error('parse failed');
const MockFontFace = vi.fn(
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) {
function(this: any, _name: string, _buffer: BufferSource, _options: FontFaceDescriptors) {
this.load = vi.fn().mockRejectedValue(loadError);
},
);
@@ -1,14 +1,14 @@
import { fontKeys } from '$shared/api/queryKeys';
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore/BaseQueryStore.svelte';
import {
fetchFontsByIds,
seedFontCache,
} from '$entities/Font/api/proxy/proxyFonts';
} from '../../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '$entities/Font/lib/errors/errors';
import type { UnifiedFont } from '$entities/Font/model/types';
import { fontKeys } from '$shared/api/queryKeys';
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
} from '../../../lib/errors/errors';
import type { UnifiedFont } from '../../types';
/**
* Internal fetcher that seeds the cache and handles error wrapping.
@@ -1,9 +1,6 @@
import * as api from '$entities/Font/api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '$entities/Font/lib/errors/errors';
import { queryClient } from '$shared/api/queryClient';
import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import { fontKeys } from '$shared/api/queryKeys';
import {
beforeEach,
@@ -12,6 +9,11 @@ import {
it,
vi,
} from 'vitest';
import * as api from '../../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
describe('FontsByIdsStore', () => {
+10 -6
View File
@@ -1,9 +1,13 @@
// Font lifecycle manager (browser-side load + cache + eviction)
export * from './fontLifecycleManager/fontLifecycleManager.svelte';
export {
__resetFontLifecycleManager,
FontLifecycleManager,
getFontLifecycleManager,
} from './fontLifecycleManager/fontLifecycleManager.svelte';
// Paginated catalog
export {
createFontCatalogStore,
FontCatalogStore,
fontCatalogStore,
} from './fontCatalogStore/fontCatalogStore.svelte';
export { getFontCatalog } from './fontCatalogStore/fontCatalogStore.svelte';
export type { FontCatalogStore } from './fontCatalogStore/fontCatalogStore.svelte';
// Batch fetch by IDs (detail-cache seeding)
export { FontsByIdsStore } from './fontsByIdsStore/fontsByIdsStore.svelte';
+4 -2
View File
@@ -23,5 +23,7 @@ export type {
FontCollectionState,
} from './store';
export * from './store/fontLifecycle';
export * from './typography';
export type {
FontLoadRequestConfig,
FontLoadStatus,
} from './store/fontLifecycle';
@@ -1 +0,0 @@
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
@@ -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
@@ -16,7 +12,7 @@
* GOOGLE_FONTS,
* FONTHARE_FONTS,
* UNIFIED_FONTS,
* } from '$entities/Font/lib/mocks';
* } from '$entities/Font/testing';
*
* // Create a mock Google Font
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
@@ -28,7 +24,7 @@
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
*
* // Use preset fonts
* import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
* import { UNIFIED_FONTS } from '$entities/Font/testing';
* ```
*/
@@ -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
@@ -13,7 +10,7 @@
* UNIFIED_FONTS,
* MOCK_FILTERS,
* createMockFontStoreState,
* } from '$entities/Font/lib/mocks';
* } from '$entities/Font/testing';
*
* // Use in stories
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
@@ -8,7 +8,7 @@
* import {
* createMockQueryState,
* MOCK_STORES,
* } from '$entities/Font/lib/mocks';
* } from '$entities/Font/testing';
*
* // Create a mock query state
* const loadingState = createMockQueryState({ status: 'pending' });
@@ -21,11 +21,7 @@
*/
import type { UnifiedFont } from '$entities/Font/model/types';
import type {
QueryKey,
QueryObserverResult,
QueryStatus,
} from '@tanstack/svelte-query';
import type { QueryStatus } from '@tanstack/svelte-query';
import {
UNIFIED_FONTS,
generateMockFonts,
@@ -10,20 +10,20 @@ const { Story } = defineMeta({
docs: {
description: {
component:
'Loads a font and applies it to children. Shows blur/scale loading state until font is ready, then reveals with a smooth transition.',
'Applies a font to its children based on the supplied load `status`. Renders the skeleton (or system font) until status is `loaded`/`error`, then reveals the font. The status is provided by the composing widget — the component does not read the lifecycle store itself.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
weight: { control: 'number' },
status: { control: 'select', options: ['loading', 'loaded', 'error'] },
},
});
</script>
<script lang="ts">
import { mockUnifiedFont } from '$entities/Font/lib/mocks';
import { mockUnifiedFont } from '$entities/Font/testing';
import type { ComponentProps } from 'svelte';
const fontUnknown = mockUnifiedFont({ id: 'nonexistent-font-xk92z', name: 'Nonexistent Font Xk92z' });
@@ -39,11 +39,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: {
description: {
story:
'Font that has never been loaded by fontLifecycleManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
'Status is `loading`: the font file has not resolved yet, so children render in the skeleton (or system font) fallback rather than the target font.',
},
},
}}
args={{ font: fontUnknown, weight: 400 }}
args={{ font: fontUnknown, status: 'loading' }}
>
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}>
@@ -58,11 +58,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
docs: {
description: {
story:
'Uses Arial, a system font available in all browsers. Because fontLifecycleManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
'Status is `loaded`: the component reveals the font, applying it to its children (Arial here, available in all browsers).',
},
},
}}
args={{ font: fontArial, weight: 400 }}
args={{ font: fontArial, status: 'loaded' }}
>
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}>
@@ -72,16 +72,16 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
</Story>
<Story
name="Custom Weight"
name="Error State"
parameters={{
docs: {
description: {
story:
'Demonstrates passing a custom weight (700). The weight is forwarded to fontLifecycleManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
'Status is `error`: the font failed to load. The component still reveals (it treats `error` like `loaded` for reveal purposes) so children are not stuck behind the skeleton — they fall back to the system font.',
},
},
}}
args={{ font: fontArialBold, weight: 700 }}
args={{ font: fontArialBold, status: 'error' }}
>
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}>
@@ -6,11 +6,10 @@
<script lang="ts">
import { cn } from '$shared/lib';
import type { Snippet } from 'svelte';
import {
DEFAULT_FONT_WEIGHT,
type UnifiedFont,
fontLifecycleManager,
} from '../../model';
import type {
FontLoadStatus,
UnifiedFont,
} from '../../model/types';
interface Props {
/**
@@ -18,10 +17,13 @@ interface Props {
*/
font: UnifiedFont;
/**
* Font weight
* @default 400
* Current load status for this font, supplied by the composing layer.
* Kept out of the component so it does not depend on (and import) the
* lifecycle store — the owning widget reads the manager and passes the
* resolved status down. `undefined` means the font is not tracked yet and
* is treated as not-yet-revealed (skeleton / system-font fallback).
*/
weight?: number;
status: FontLoadStatus | undefined;
/**
* CSS classes
*/
@@ -39,20 +41,12 @@ interface Props {
let {
font,
weight = DEFAULT_FONT_WEIGHT,
status,
className,
children,
skeleton,
}: Props = $props();
const status = $derived(
fontLifecycleManager.getFontStatus(
font.id,
weight,
font.features?.isVariable,
),
);
const shouldReveal = $derived(status === 'loaded' || status === 'error');
</script>
@@ -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: {
@@ -21,6 +21,11 @@ const { Story } = defineMeta({
control: 'object',
description: 'Font information object',
},
status: {
control: 'select',
options: ['loading', 'loaded', 'error'],
description: 'Font-load status, supplied by the composing widget and forwarded to FontApplicator',
},
text: {
control: 'text',
description: 'Editable sample text (two-way bindable)',
@@ -34,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 = {
@@ -79,14 +84,24 @@ 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
name="Default"
args={{
font: mockArial,
status: 'loaded',
text: 'The quick brown fox jumps over the lazy dog',
index: 0,
typography: mockTypography,
}}
>
{#snippet template(args: ComponentProps<typeof FontSampler>)}
@@ -101,9 +116,11 @@ const mockGeorgia: UnifiedFont = {
name="Long Text"
args={{
font: mockGeorgia,
status: 'loaded',
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>)}
@@ -4,11 +4,6 @@
Visual design matches FontCard: sharp corners, red hover accent, header stats.
-->
<script lang="ts">
import {
FontApplicator,
type UnifiedFont,
} from '$entities/Font';
import { typographySettingsStore } from '$features/AdjustTypography/model';
import {
Badge,
ContentEditable,
@@ -17,12 +12,47 @@ 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 {
/**
* Font info
*/
font: UnifiedFont;
/**
* Current font-load status, supplied by the composing widget so this
* component (and FontApplicator) stay decoupled from the lifecycle store.
* `undefined` means not tracked yet (treated as not-yet-revealed).
*/
status: FontLoadStatus | undefined;
/**
* Sample text
*/
@@ -32,12 +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, text = $bindable(), index = 0 }: Props = $props();
// Adjust the property name to match your UnifiedFont type
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
let { font, status, text = $bindable(), index = 0, typography }: Props = $props();
// Extract provider badge with fallback
const providerBadge = $derived(
@@ -46,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>
@@ -67,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
@@ -91,9 +123,9 @@ const stats = $derived([
{font.name}
</span>
{#if fontType}
{#if font?.category}
<Badge size="xs" variant="default" nowrap>
{fontType}
{font?.category}
</Badge>
{/if}
@@ -130,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} weight={typographySettingsStore.weight}>
<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' : ''}">
@@ -154,7 +185,6 @@ const stats = $derived([
{/each}
</div>
<!-- ── Red hover line ─────────────────────────────────────────────── -->
<div
class="
absolute bottom-0 left-0 right-0
@@ -5,21 +5,18 @@
-->
<script lang="ts">
import { debounce } from '$shared/lib/utils';
import {
Skeleton,
VirtualList,
} from '$shared/ui';
import { VirtualList } from '$shared/ui';
import type {
ComponentProps,
Snippet,
} from 'svelte';
import { fade } from 'svelte/transition';
import { getFontUrl } from '../../lib';
import { createFontLoadRequestContfig } from '../../lib/createFontLoadRequestContfig/createFontLoadRequestContfig';
import {
type FontLoadRequestConfig,
type UnifiedFont,
fontCatalogStore,
fontLifecycleManager,
getFontCatalog,
getFontLifecycleManager,
} from '../../model';
interface Props extends
@@ -55,17 +52,28 @@ let {
...rest
}: Props = $props();
const isLoading = $derived(
fontCatalogStore.isFetching || fontCatalogStore.isLoading,
);
const fontCatalog = getFontCatalog();
const fontLifecycleManager = getFontLifecycleManager();
const isLoading = $derived<boolean>(fontCatalog?.isLoading);
const isFetching = $derived<boolean>(fontCatalog.isFetching);
const hasMore = $derived<boolean>(fontCatalog?.pagination?.hasMore);
const fonts = $derived<UnifiedFont[]>(fontCatalog.fonts);
const total = $derived<number>(fontCatalog?.pagination.total);
let visibleFonts = $state<UnifiedFont[]>([]);
let isCatchingUp = $state(false);
let isCatchingUp = $state<boolean>(false);
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0);
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
const showInitialSkeleton = $derived.by(() => (
!!skeleton && (isLoading || isFetching) && fontCatalog.fonts.length === 0
));
const showCatchupSkeleton = $derived.by(() => (
!!skeleton && isCatchingUp
));
// Settled query with no matches — empty state replaces the (otherwise blank) list.
const showEmpty = $derived(!!empty && !isLoading && !isCatchingUp && fontCatalogStore.fonts.length === 0);
const showEmpty = $derived.by(() => (
!!empty && !(isLoading || isFetching) && !isCatchingUp && fontCatalog.fonts.length === 0
));
function handleInternalVisibleChange(items: UnifiedFont[]) {
visibleFonts = items;
@@ -79,12 +87,12 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
* font files for thousands of intermediate fonts.
*/
async function handleJump(targetIndex: number) {
if (isCatchingUp || !fontCatalogStore.pagination.hasMore) {
if (isCatchingUp || !hasMore) {
return;
}
isCatchingUp = true;
try {
await fontCatalogStore.fetchAllPagesTo(targetIndex);
await fontCatalog.fetchAllPagesTo(targetIndex);
} finally {
isCatchingUp = false;
}
@@ -105,13 +113,7 @@ $effect(() => {
if (isCatchingUp) {
return;
}
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
const url = getFontUrl(item, weight);
if (!url) {
return [];
}
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
});
const configs = visibleFonts.flatMap(item => createFontLoadRequestContfig(item, weight));
if (configs.length > 0) {
debouncedTouch(configs);
}
@@ -137,13 +139,11 @@ $effect(() => {
* Load more fonts by moving to the next page
*/
function loadMore() {
if (
!fontCatalogStore.pagination.hasMore
|| fontCatalogStore.isFetching
) {
if (!hasMore || isFetching) {
return;
}
fontCatalogStore.nextPage();
fontCatalog.nextPage();
}
/**
@@ -153,12 +153,10 @@ function loadMore() {
* of the loaded items. Only fetches if there are more pages available.
*/
function handleNearBottom(_lastVisibleIndex: number) {
const { hasMore } = fontCatalogStore.pagination;
// VirtualList already checks if we're near the bottom of loaded items.
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
// during batch catch-up, which would otherwise let nextPage() race with it.
if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) {
if (hasMore && !isFetching && !isCatchingUp) {
loadMore();
}
}
@@ -177,9 +175,9 @@ function handleNearBottom(_lastVisibleIndex: number) {
{:else}
<!-- VirtualList persists during pagination - no destruction/recreation -->
<VirtualList
items={fontCatalogStore.fonts}
total={fontCatalogStore.pagination.total}
isLoading={isLoading || isCatchingUp}
items={fonts}
{total}
isLoading={isLoading || isFetching || isCatchingUp}
onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom}
onJump={handleJump}
+2
View File
@@ -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,
};
+4 -1
View File
@@ -1,6 +1,9 @@
export {
createTypographySettingsStore,
getTypographySettingsStore,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
type TypographySettingsStore,
typographySettingsStore,
} from './model';
export { TypographyMenu } from './ui';
@@ -0,0 +1,76 @@
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LETTER_SPACING_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LETTER_SPACING,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LETTER_SPACING,
MIN_LINE_HEIGHT,
} from '$entities/Font';
import type {
ControlId,
ControlModel,
} from '../types/typography';
/**
* Responsive font-size scaling factors applied by typographySettingsStore.
*/
export const MULTIPLIER_S = 0.5;
export const MULTIPLIER_M = 0.75;
export const MULTIPLIER_L = 1;
/**
* Default control definitions seeding the typography settings store.
* Composed from the font-render ranges/defaults owned by the Font entity.
*/
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
step: FONT_SIZE_STEP,
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Size',
},
{
id: 'font_weight',
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Weight',
},
{
id: 'line_height',
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Leading',
},
{
id: 'letter_spacing',
value: DEFAULT_LETTER_SPACING,
max: MAX_LETTER_SPACING,
min: MIN_LETTER_SPACING,
step: LETTER_SPACING_STEP,
increaseLabel: 'Increase Letter Spacing',
decreaseLabel: 'Decrease Letter Spacing',
controlLabel: 'Tracking',
},
];
+6 -1
View File
@@ -1,5 +1,10 @@
export {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from './const/const';
export {
createTypographySettingsStore,
getTypographySettingsStore,
type TypographySettingsStore,
typographySettingsStore,
} from './store/typographySettingsStore/typographySettingsStore.svelte';
@@ -11,22 +11,27 @@
*/
import {
type ControlId,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} 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 ControlDataModel,
type ControlModel,
type PersistentStore,
type TypographyControl,
createPersistentStore,
createTypographyControl,
createSingleton,
} from '$shared/lib';
import type { NumericControl } from '$shared/ui';
import { SvelteMap } from 'svelte/reactivity';
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../../const/const';
import type {
ControlId,
ControlModel,
} from '../../types/typography';
import { createTypographyControl } from '../../typographyControl/createTypographyControl.svelte';
/**
* Epsilon for detecting "significant" base-size changes when reconciling
@@ -36,7 +41,7 @@ import { SvelteMap } from 'svelte/reactivity';
*/
const BASE_SIZE_EPSILON = 0.01;
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, 'value' | 'min' | 'max' | 'step'>;
/**
* A control with its associated instance
@@ -45,7 +50,7 @@ export interface Control extends ControlOnlyFields<ControlId> {
/**
* The reactive typography control instance
*/
instance: TypographyControl;
instance: NumericControl;
}
/**
@@ -93,6 +98,12 @@ export class TypographySettingsStore {
* The underlying font size before responsive scaling is applied
*/
#baseSize = $state(DEFAULT_FONT_SIZE);
/**
* Disposes the $effect.root that backs the storage-sync effects.
* $effect.root lives outside component lifecycle, so callers must invoke
* destroy() to avoid leaking the subscriptions.
*/
#disposeEffects: () => void;
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
this.#storage = storage;
@@ -116,7 +127,7 @@ export class TypographySettingsStore {
// The Sync Effect (UI -> Storage)
// We access .value explicitly to ensure Svelte 5 tracks the dependency
$effect.root(() => {
this.#disposeEffects = $effect.root(() => {
$effect(() => {
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
const fontSize = this.#baseSize;
@@ -154,6 +165,14 @@ export class TypographySettingsStore {
});
}
/**
* Tears down the storage-sync effects. Call on unmount / store disposal.
*/
destroy(): void {
this.#disposeEffects();
this.#storage.destroy();
}
/**
* Gets initial value for a control from storage or defaults
*/
@@ -288,9 +307,6 @@ export class TypographySettingsStore {
if (c.id === 'font_size') {
c.instance.value = defaults.fontSize * this.#multiplier;
} else {
// Map storage key to control id
const key = c.id.replace('_', '') as keyof TypographySettings;
// Simplified for brevity, you'd map these properly:
if (c.id === 'font_weight') {
c.instance.value = defaults.fontWeight;
}
@@ -335,10 +351,19 @@ export function createTypographySettingsStore(
return new TypographySettingsStore(configs, storage);
}
export type TypographySettingsStoreInstance = ReturnType<typeof createTypographySettingsStore>;
/**
* App-wide typography settings singleton, keyed for the comparison view.
* 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 const 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 const __resetTypographySettingsStore = typographySettingsStore.reset;
@@ -6,8 +6,7 @@ import {
DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
} from '$entities/Font';
} from '$entities/Font/model/const/const';
import {
beforeEach,
describe,
@@ -15,6 +14,7 @@ import {
it,
vi,
} from 'vitest';
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../../const/const';
import {
type TypographySettings,
TypographySettingsStore,
@@ -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(
@@ -0,0 +1,27 @@
import type {
ControlLabels,
NumericControl,
} from '$shared/ui';
/**
* Identifiers for the adjustable typography axes
*/
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
/**
* Static configuration for one typography control.
*
* Derived from the SSOT contract types — declares no fields of its own beyond
* the domain `id`. Bounds come from NumericControl, labels from ControlLabels.
*
* @template T - Control identifier type
*/
export type ControlModel<T extends string = string> =
& Pick<NumericControl, 'value' | 'min' | 'max' | 'step'>
& ControlLabels
& {
/**
* Unique identifier for the control
*/
id: T;
};
@@ -0,0 +1,67 @@
/**
* Bounded numeric control for typography settings.
*
* Produces a reactive control that clamps to [min, max] and rounds to step.
* Implements the NumericControl contract that ComboControl renders.
*/
import {
clampNumber,
roundToStepPrecision,
} from '$shared/lib/utils';
import type { NumericControl } from '$shared/ui';
/**
* Bounds + initial value seed for a control
*/
type ControlSeed = Pick<NumericControl, 'value' | 'min' | 'max' | 'step'>;
/**
* Create a reactive bounded numeric control.
*
* @param initialState - Initial value and bounds
* @returns A NumericControl whose value is always clamped and step-rounded
*/
export function createTypographyControl(initialState: ControlSeed): NumericControl {
let value = $state(initialState.value);
let max = $state(initialState.max);
let min = $state(initialState.min);
let step = $state(initialState.step);
const { isAtMax, isAtMin } = $derived({
isAtMax: value >= max,
isAtMin: value <= min,
});
return {
get value() {
return value;
},
set value(newValue) {
const rounded = roundToStepPrecision(clampNumber(newValue, min, max), step);
if (value !== rounded) {
value = rounded;
}
},
get max() {
return max;
},
get min() {
return min;
},
get step() {
return step;
},
get isAtMax() {
return isAtMax;
},
get isAtMin() {
return isAtMin;
},
increase() {
value = roundToStepPrecision(clampNumber(value + step, min, max), step);
},
decrease() {
value = roundToStepPrecision(clampNumber(value - step, min, max), step);
},
};
}
@@ -1,12 +1,10 @@
import {
type TypographyControl,
createTypographyControl,
} from '$shared/lib';
import type { NumericControl } from '$shared/ui';
import {
describe,
expect,
it,
} from 'vitest';
import { createTypographyControl } from './createTypographyControl.svelte';
/**
* Test Strategy for createTypographyControl Helper
@@ -34,7 +32,7 @@ describe('createTypographyControl - Unit Tests', () => {
min?: number;
max?: number;
step?: number;
}): TypographyControl {
}): NumericControl {
return createTypographyControl({
value: initialValue,
min: options?.min ?? 0,
@@ -5,11 +5,6 @@
Desktop: inline bar with combo controls.
-->
<script lang="ts">
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
} from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import {
@@ -24,7 +19,12 @@ import XIcon from '@lucide/svelte/icons/x';
import { getContext } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { typographySettingsStore } from '../../model';
import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
getTypographySettingsStore,
} from '../../model';
interface Props {
/**
@@ -46,6 +46,7 @@ interface Props {
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
const typographySettingsStore = getTypographySettingsStore();
/**
* Sets the common font size multiplier based on the current responsive state.
@@ -8,7 +8,7 @@
* @example
* ```svelte
* <script lang="ts">
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
* import { onMount } from 'svelte';
*
* onMount(() => {
@@ -26,8 +26,8 @@
*/
export {
getScrollBreadcrumbsStore,
type NavigationAction,
scrollBreadcrumbsStore,
} from './model';
export {
BreadcrumbHeader,
+7
View File
@@ -0,0 +1,7 @@
export {
__resetScrollBreadcrumbsStore,
createScrollBreadcrumbsStore,
getScrollBreadcrumbsStore,
} from './store/scrollBreadcrumbsStore.svelte';
export type { BreadcrumbItem } from './store/scrollBreadcrumbsStore.svelte';
export type { NavigationAction } from './types/types.ts';
@@ -1,3 +1,5 @@
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
/**
* Scroll-based breadcrumb tracking store
*
@@ -15,7 +17,7 @@
* @example
* ```svelte
* <script lang="ts">
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
*
* onMount(() => {
* scrollBreadcrumbsStore.add({
@@ -167,6 +169,13 @@ class ScrollBreadcrumbsStore {
this.#detachScrollListener();
}
/**
* Tears down the observer and scroll listener. Call on store disposal.
*/
destroy(): void {
this.#disconnect();
}
/**
* All tracked items sorted by index
*/
@@ -273,6 +282,14 @@ export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
}
/**
* Singleton scroll breadcrumbs store instance
* App-wide scroll breadcrumbs store, created on first access.
*/
export const 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 const __resetScrollBreadcrumbsStore = scrollBreadcrumbsStore.reset;
@@ -70,7 +70,6 @@ class MockIntersectionObserver implements IntersectionObserver {
describe('ScrollBreadcrumbsStore', () => {
let scrollListeners: Array<() => void> = [];
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
let scrollToSpy: ReturnType<typeof vi.spyOn>;
// Helper to create mock elements
@@ -111,7 +110,7 @@ describe('ScrollBreadcrumbsStore', () => {
// Track scroll event listeners
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
(event: string, listener: EventListenerOrEventListenerObject, options?: any) => {
(event: string, listener: EventListenerOrEventListenerObject, _options?: any) => {
if (event === 'scroll') {
scrollListeners.push(listener as () => void);
}
@@ -119,7 +118,7 @@ describe('ScrollBreadcrumbsStore', () => {
},
);
removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation(
vi.spyOn(window, 'removeEventListener').mockImplementation(
(event: string, listener: EventListenerOrEventListenerObject) => {
if (event === 'scroll') {
const index = scrollListeners.indexOf(listener as () => void);
@@ -14,9 +14,10 @@ import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import {
type BreadcrumbItem,
scrollBreadcrumbsStore,
getScrollBreadcrumbsStore,
} from '../../model';
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
const responsive = getContext<ResponsiveManager>('responsive');
@@ -1,18 +1,24 @@
<script>
import { onMount } from 'svelte';
import { scrollBreadcrumbsStore } from '../../model';
import { getScrollBreadcrumbsStore } from '../../model';
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
const sections = [
{ index: 100, title: 'Introduction' },
{ index: 101, title: 'Typography' },
{ index: 102, title: 'Spacing' },
];
/** @type {HTMLDivElement} */
let container;
/** @type {HTMLDivElement | undefined} */
let container = $state();
onMount(() => {
if (!container) {
return;
}
for (const section of sections) {
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
@@ -6,9 +6,11 @@
import { type Snippet } from 'svelte';
import {
type NavigationAction,
scrollBreadcrumbsStore,
getScrollBreadcrumbsStore,
} from '../../model';
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
interface Props {
/**
* Navigation index
+2 -2
View File
@@ -1,2 +1,2 @@
export * from './model';
export * from './ui';
export { getThemeManager } from './model';
export { ThemeSwitch } from './ui';
+1 -1
View File
@@ -1 +1 @@
export { themeManager } from './store/ThemeManager/ThemeManager.svelte';
export { getThemeManager } from './store/ThemeManager/ThemeManager.svelte';
@@ -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();
}
/**
@@ -195,14 +199,20 @@ class ThemeManager {
}
/**
* Singleton theme manager instance
* App-wide theme manager, created on first access.
*
* Use throughout the app for consistent theme state.
* Lazy so its persistent-store subscription isn't set up at module load.
* Call init() on mount and destroy() on unmount (see Layout).
*/
export const 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 const __resetThemeManager = themeManager.reset;
/**
* ThemeManager class exported for testing purposes
* Use the singleton `themeManager` in application code.
* Use the `getThemeManager()` accessor in application code.
*/
export { ThemeManager };
@@ -22,8 +22,9 @@ const { Story } = defineMeta({
</script>
<script lang="ts">
import { themeManager } from '$features/ChangeAppTheme';
import { getThemeManager } from '$features/ChangeAppTheme';
const themeManager = getThemeManager();
// Current theme state for display
const currentTheme = $derived(themeManager.value);
const themeSource = $derived(themeManager.source);
@@ -8,10 +8,11 @@ import { IconButton } from '$shared/ui';
import MoonIcon from '@lucide/svelte/icons/moon';
import SunIcon from '@lucide/svelte/icons/sun';
import { getContext } from 'svelte';
import { themeManager } from '../../model';
import { getThemeManager } from '../../model';
const responsive = getContext<ResponsiveManager>('responsive');
const themeManager = getThemeManager();
const theme = $derived(themeManager.value);
</script>
@@ -3,16 +3,25 @@ import {
render,
screen,
} from '@testing-library/svelte';
import { themeManager } from '../../model';
import { afterEach } from 'vitest';
import { getThemeManager } from '../../model';
import { __resetThemeManager } from '../../model/store/ThemeManager/ThemeManager.svelte';
import ThemeSwitch from './ThemeSwitch.svelte';
const context = new Map([['responsive', { isMobile: false }]]);
describe('ThemeSwitch', () => {
let themeManager: ReturnType<typeof getThemeManager>;
beforeEach(() => {
themeManager = getThemeManager();
themeManager.setTheme('light');
});
afterEach(() => {
__resetThemeManager();
});
describe('Rendering', () => {
it('renders an icon button', () => {
render(ThemeSwitch, { context });
-1
View File
@@ -1 +0,0 @@
export { FontSampler } from './ui';
-3
View File
@@ -1,3 +0,0 @@
import FontSampler from './FontSampler/FontSampler.svelte';
export { FontSampler };
-1
View File
@@ -1 +0,0 @@
export { FontsByIdsStore } from './model';
@@ -1 +0,0 @@
export { FontsByIdsStore } from './store/fontsByIdsStore/fontsByIdsStore.svelte';
@@ -9,7 +9,7 @@
import { api } from '$shared/api/api';
import { API_ENDPOINTS } from '$shared/api/endpoints';
import { NonRetryableError } from '$shared/api/queryClient';
import { NonRetryableError } from '$shared/api/nonRetryableError';
const PROXY_API_URL = API_ENDPOINTS.filters;
+6 -1
View File
@@ -1 +1,6 @@
export * from './filters/filters';
export { fetchProxyFilters } from './filters/filters';
export type {
FilterMetadata,
FilterOption,
ProxyFiltersResponse,
} from './filters/filters';
+13 -9
View File
@@ -1,24 +1,28 @@
export { mapAppliedFiltersToParams } from './lib';
export {
type AppliedFilterStore,
appliedFilterStore,
/**
* Filter Store
*/
availableFilterStore,
/**
* Filter Manager
*/
createAppliedFilterStore,
/**
* Lazy store accessors
*/
getAppliedFilterStore,
getAvailableFilterStore,
getSortStore,
/**
* Sort Store
*/
SORT_MAP,
SORT_OPTIONS,
type SortApiValue,
type SortOption,
sortStore,
startFilterBindings,
} from './model';
export type {
AppliedFilterStore,
SortApiValue,
SortOption,
} from './model';
export {
@@ -0,0 +1,83 @@
import {
describe,
expect,
it,
} from 'vitest';
import type {
FilterMetadata,
FilterOption,
} from '../../api/filters/filters';
import { mapFilterMetadataToGroups } from './mapFilterMetadataToGroups';
/**
* Build a FilterOption with a known value and count.
*/
function option(value: string, count: number): FilterOption {
return { id: value, name: value, value, count };
}
/**
* Build filter metadata for one group from (value, count) entries.
*/
function metadata(id: string, options: Array<[string, number]>): FilterMetadata {
return {
id,
name: id,
description: '',
type: 'array',
options: options.map(([value, count]) => option(value, count)),
};
}
describe('mapFilterMetadataToGroups', () => {
it('maps id and name onto group id and label', () => {
const [group] = mapFilterMetadataToGroups([metadata('categories', [['serif', 1]])]);
expect(group.id).toBe('categories');
expect(group.label).toBe('categories');
});
it('projects each option to a property with selected: false', () => {
const [group] = mapFilterMetadataToGroups([metadata('providers', [['google', 5]])]);
expect(group.properties).toEqual([
{ id: 'google', name: 'google', value: 'google', selected: false },
]);
});
it('orders properties by descending count', () => {
const [group] = mapFilterMetadataToGroups([
metadata('subsets', [['latin', 2], ['cyrillic', 9], ['greek', 5]]),
]);
expect(group.properties.map(p => p.value)).toEqual(['cyrillic', 'greek', 'latin']);
});
it('does not mutate the source options array (TanStack cache safety)', () => {
const source = metadata('subsets', [['latin', 2], ['cyrillic', 9]]);
const originalOrder = source.options.map(o => o.value);
mapFilterMetadataToGroups([source]);
expect(source.options.map(o => o.value)).toEqual(originalOrder);
});
it('maps every group, preserving group order', () => {
const groups = mapFilterMetadataToGroups([
metadata('providers', [['google', 1]]),
metadata('categories', [['serif', 1]]),
]);
expect(groups.map(g => g.id)).toEqual(['providers', 'categories']);
});
it('returns an empty group list for empty metadata', () => {
expect(mapFilterMetadataToGroups([])).toEqual([]);
});
it('yields an empty properties list when a group has no options', () => {
const [group] = mapFilterMetadataToGroups([metadata('providers', [])]);
expect(group.properties).toEqual([]);
});
});
@@ -0,0 +1,36 @@
import type { FilterMetadata } from '../../api/filters/filters';
import type { FilterGroupConfig } from '../../model';
/**
* Map backend filter metadata into the group configs `appliedFilterStore.setGroups`
* consumes.
*
* Inverse direction of `mapAppliedFiltersToParams`: that maps applied selections out
* to API params; this maps the API's available-filter catalog in to the UI model.
*
* Options are ordered by descending font count so the most populated values surface
* first. The source array is copied before sorting — `metadata` is TanStack-cached
* query data, and `.sort()` mutates in place; sorting the live cache both corrupts it
* and, when called from a reactive effect, writes into that effect's own read
* dependency (triggering an update loop).
*
* Every property starts unselected; selection state is owned by the store, not the
* backend catalog.
*
* @param metadata - Available-filter catalog from the filters endpoint
* @returns Group configs ready for `setGroups`
*/
export function mapFilterMetadataToGroups(metadata: FilterMetadata[]): FilterGroupConfig<string>[] {
return metadata.map(filter => ({
id: filter.id,
label: filter.name,
properties: [...filter.options]
.sort((a, b) => b.count - a.count)
.map(opt => ({
id: opt.id,
name: opt.name,
value: opt.value,
selected: false,
})),
}));
}
+11 -11
View File
@@ -14,9 +14,9 @@ export type {
*/
export {
/**
* Low-level property selection store
* Lazy accessor for the app-wide filter-metadata store
*/
availableFilterStore,
getAvailableFilterStore,
} from './store/availableFilterStore/availableFilterStore.svelte';
/**
@@ -27,26 +27,30 @@ export {
* Reactive interface returned by `createAppliedFilterStore`
*/
type AppliedFilterStore,
/**
* High-level manager for syncing search and filters
*/
appliedFilterStore,
/**
* Factory for constructing a filter manager instance
*/
createAppliedFilterStore,
/**
* Lazy accessor for the app-wide filter manager
*/
getAppliedFilterStore,
} from './store/appliedFilterStore/appliedFilterStore.svelte';
/**
* Side-effect import: installs the global appliedFilterStore+sortStore → fontCatalogStore
* bridge on first import of this feature barrel. No exports.
*/
import './store/bindings.svelte';
export { startFilterBindings } from './store/bindings.svelte';
/**
* Sorting logic
*/
export {
/**
* Lazy accessor for the app-wide sort store
*/
getSortStore,
/**
* Map of human-readable labels to API sort keys
*/
@@ -63,8 +67,4 @@ export {
* UI model for a single sort option
*/
type SortOption,
/**
* Reactive store for the current sort selection
*/
sortStore,
} from './store/sortStore/sortStore.svelte';
@@ -23,7 +23,10 @@
* ```
*/
import { createFilter } from '$shared/lib';
import {
createFilter,
createSingleton,
} from '$shared/lib';
import { createDebouncedState } from '$shared/lib/helpers';
import type {
FilterConfig,
@@ -42,8 +45,13 @@ import type {
export function createAppliedFilterStore<TValue extends string>(config: FilterConfig<TValue>) {
const search = createDebouncedState(config.queryValue ?? '');
// Create filter instances upfront
const groups = $state(
// Create filter instances upfront.
// `let` (not `const`) so setGroups can REASSIGN the whole array. In-place
// `groups.length = 0; groups.push(...)` is forbidden here: push reads the
// array's length signal, so a $effect that calls setGroups would both read
// and write `groups.length` in one run and re-trigger itself forever
// (effect_update_depth_exceeded).
let groups = $state(
config.groups.map(config => ({
id: config.id,
label: config.label,
@@ -62,14 +70,11 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
* Used when dynamic filter data loads from backend
*/
setGroups(newGroups: FilterGroupConfig<TValue>[]) {
groups.length = 0;
groups.push(
...newGroups.map(g => ({
id: g.id,
label: g.label,
instance: createFilter({ properties: g.properties }),
})),
);
groups = newGroups.map(g => ({
id: g.id,
label: g.label,
instance: createFilter({ properties: g.properties }),
}));
},
/**
* Current search query value (immediate, for UI binding)
@@ -128,13 +133,20 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
/**
* App-wide filter manager singleton.
* App-wide filter manager, created on first access.
*
* Constructed with empty groups; the availableFilterStore → appliedFilterStore wiring
* lives in `./bindings.svelte` and populates groups once backend filter
* metadata arrives.
*/
export const appliedFilterStore = createAppliedFilterStore({
queryValue: '',
groups: [],
});
const appliedFilterStore = createSingleton(() =>
createAppliedFilterStore<string>({
queryValue: '',
groups: [],
})
);
export const getAppliedFilterStore = appliedFilterStore.get;
// test-only reset, so specs don't share filter/selection state
export const __resetAppliedFilterStore = appliedFilterStore.reset;
@@ -29,12 +29,6 @@ import { createAppliedFilterStore } from './appliedFilterStore.svelte';
* testing Svelte 5 reactive code in Node.js.
*/
// Helper to flush Svelte effects (they run in microtasks)
async function flushEffects() {
await Promise.resolve();
await Promise.resolve();
}
// Helper to create test properties
function createTestProperties(count: number, selectedIndices: number[] = []): Property<string>[] {
return Array.from({ length: count }, (_, i) => ({
@@ -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
@@ -127,6 +128,15 @@ export class AvailableFilterStore {
}
/**
* Singleton instance
* App-wide filter-metadata store, created on first access. Lazy so the
* QueryObserver isn't constructed at module load.
*/
export const 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 const __resetAvailableFilterStore = availableFilterStore.reset;
@@ -1,4 +1,6 @@
import { queryClient } from '$shared/api/queryClient';
import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import {
afterEach,
beforeEach,
@@ -9,52 +9,34 @@
* observer, so it lives at module scope, not in any individual widget.
*/
import { fontCatalogStore } from '$entities/Font';
import { getFontCatalog } from '$entities/Font';
import { untrack } from 'svelte';
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
import { availableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
import { sortStore } from './sortStore/sortStore.svelte';
import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups';
import { getAppliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
import { getAvailableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
import { getSortStore } from './sortStore/sortStore.svelte';
$effect.root(() => {
/**
* Populate appliedFilterStore groups when backend filter metadata resolves.
* availableFilterStore is async; until it loads, appliedFilterStore has empty groups
* and the UI renders nothing for them.
*/
$effect(() => {
const dynamicFilters = availableFilterStore.filters;
export function startFilterBindings(): () => void {
const appliedFilterStore = getAppliedFilterStore();
const availableFilterStore = getAvailableFilterStore();
const sortStore = getSortStore();
if (dynamicFilters.length > 0) {
appliedFilterStore.setGroups(
dynamicFilters.map(filter => ({
id: filter.id,
label: filter.name,
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
id: opt.id,
name: opt.name,
value: opt.value,
selected: false,
})),
})),
);
}
const stop = $effect.root(() => {
$effect(() => {
const dynamicFilters = availableFilterStore.filters;
if (dynamicFilters.length > 0) {
appliedFilterStore.setGroups(mapFilterMetadataToGroups(dynamicFilters));
}
});
$effect(() => {
const params = mapAppliedFiltersToParams(appliedFilterStore);
const sort = sortStore.apiValue;
const catalog = getFontCatalog();
untrack(() => catalog.setParams({ ...params, sort }));
});
});
/**
* Mirror filter selections + debounced search query + sort into fontCatalogStore params.
*
* Filters and sort are merged into one setParams call to avoid a startup race:
* two separate effects each issued setOptions with a different queryKey on the
* first flush, producing an orphaned `?limit=50&offset=0` fetch immediately
* followed by the real `?limit=50&sort=popularity&offset=0` fetch.
*
* untrack the write so fontCatalogStore's internal $state reads don't feed back
* into this effect's dependency graph.
*/
$effect(() => {
const params = mapAppliedFiltersToParams(appliedFilterStore);
const sort = sortStore.apiValue;
untrack(() => fontCatalogStore.setParams({ ...params, sort }));
});
});
return stop; // hand the caller the cleanup
}
@@ -1,3 +1,5 @@
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
/**
* Sort store — manages the current sort option for font listings.
*
@@ -44,4 +46,14 @@ export function createSortStore(initial: SortOption = 'Popularity') {
};
}
export const sortStore = createSortStore();
export type SortStore = ReturnType<typeof createSortStore>;
/**
* App-wide sort store, created on first access.
*/
const sortStore = createSingleton(() => createSortStore());
export const getSortStore = sortStore.get;
// test-only reset, so specs don't share selection state
export const __resetSortStore = sortStore.reset;
@@ -1,4 +1,5 @@
import {
afterEach,
describe,
expect,
it,
@@ -7,8 +8,9 @@ import {
SORT_MAP,
SORT_OPTIONS,
type SortOption,
__resetSortStore,
createSortStore,
sortStore,
getSortStore,
} from './sortStore.svelte';
describe('createSortStore', () => {
@@ -51,14 +53,24 @@ describe('createSortStore', () => {
});
});
describe('sortStore singleton', () => {
describe('getSortStore singleton', () => {
afterEach(() => {
__resetSortStore();
});
it('returns the same instance across calls', () => {
expect(getSortStore()).toBe(getSortStore());
});
it('exposes the same shape as a factory instance', () => {
const sortStore = getSortStore();
expect(typeof sortStore.value).toBe('string');
expect(typeof sortStore.apiValue).toBe('string');
expect(typeof sortStore.set).toBe('function');
});
it('accepts all SORT_OPTIONS as valid set() inputs', () => {
const sortStore = getSortStore();
for (const option of SORT_OPTIONS) {
sortStore.set(option);
expect(sortStore.value).toBe(option);
@@ -4,10 +4,13 @@
-->
<script lang="ts">
import { FilterGroup } from '$shared/ui';
import { appliedFilterStore } from '../../model';
import { getAppliedFilterStore } from '../../model';
const appliedFilterStore = getAppliedFilterStore();
const groups = $derived(appliedFilterStore.groups);
</script>
{#each appliedFilterStore.groups as group (group.id)}
{#each groups as group (group.id)}
<FilterGroup
displayedLabel={group.label}
filter={group.instance}
@@ -1,6 +1,6 @@
import {
appliedFilterStore,
availableFilterStore,
getAppliedFilterStore,
getAvailableFilterStore,
} from '$features/FilterAndSortFonts';
import {
render,
@@ -12,8 +12,8 @@ import Filters from './Filters.svelte';
describe('Filters', () => {
beforeEach(() => {
// Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us
appliedFilterStore.setGroups([]);
vi.spyOn(availableFilterStore, 'filters', 'get').mockReturnValue([]);
getAppliedFilterStore().setGroups([]);
vi.spyOn(getAvailableFilterStore(), 'filters', 'get').mockReturnValue([]);
});
afterEach(() => {
@@ -28,7 +28,7 @@ describe('Filters', () => {
});
it('renders a label for each filter group', () => {
appliedFilterStore.setGroups([
getAppliedFilterStore().setGroups([
{ id: 'cat', label: 'Categories', properties: [] },
{ id: 'prov', label: 'Font Providers', properties: [] },
]);
@@ -38,7 +38,7 @@ describe('Filters', () => {
});
it('renders filter properties within groups', () => {
appliedFilterStore.setGroups([
getAppliedFilterStore().setGroups([
{
id: 'cat',
label: 'Category',
@@ -54,7 +54,7 @@ describe('Filters', () => {
});
it('renders multiple groups with their properties', () => {
appliedFilterStore.setGroups([
getAppliedFilterStore().setGroups([
{
id: 'cat',
label: 'Category',
@@ -12,8 +12,8 @@ import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { getContext } from 'svelte';
import {
SORT_OPTIONS,
appliedFilterStore,
sortStore,
getAppliedFilterStore,
getSortStore,
} from '../../model';
interface Props {
@@ -30,6 +30,10 @@ const {
const responsive = getContext<ResponsiveManager>('responsive');
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
const appliedFilterStore = getAppliedFilterStore();
const sortStore = getSortStore();
const sortValue = $derived(sortStore.value);
function handleReset() {
appliedFilterStore.deselectAllGlobal();
}
@@ -53,7 +57,7 @@ function handleReset() {
<Button
variant="ghost"
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
active={sortStore.value === option}
active={sortValue === option}
onclick={() => sortStore.set(option)}
class="tracking-wide px-0"
>
@@ -1,6 +1,6 @@
<!--
Component: Page
Description: The main page component of the application.
Component: Home
Root route — comparison workspace.
-->
<script lang="ts">
import { ComparisonView } from '$widgets/ComparisonView';
+10
View File
@@ -0,0 +1,10 @@
<!--
Component: Redirect
Mounts only when an unmatched route is hit; immediately replaces the URL
with the home route. Kept until additional routes exist.
-->
<script lang="ts">
import { navigate } from './router';
navigate('/', { replace: true });
</script>
+7
View File
@@ -0,0 +1,7 @@
export {
isActive,
navigate,
p,
preload,
route,
} from './router';
+24
View File
@@ -0,0 +1,24 @@
import { createRouter } from 'sv-router';
import Home from './Home.svelte';
/**
* Single-page router for glyphdiff.
*
* Currently exposes one route; structure exists so additional routes can be
* added without touching the app shell.
*/
export const {
isActive,
navigate,
p,
preload,
route,
} = createRouter({
'/': Home,
/**
* Any unmatched path redirects to home until additional routes exist.
* Lazy-loaded so `router` doesn't statically import `Redirect`, which
* imports `navigate` from here — breaks the import cycle.
*/
'*notfound': () => import('./Redirect.svelte'),
});
+14
View File
@@ -0,0 +1,14 @@
/**
* Marker base class for errors that retrying will never fix — schema-validation
* failures, unauthorized responses, contract violations, etc.
*
* The queryClient retry handler short-circuits when it sees this; without it,
* a non-transient backend bug pins the UI through the full retry budget
* (default 3× exponential backoff ≈ 7s).
*
* Lives in its own module — free of `@tanstack/query-core` — so error types that
* extend it (e.g. `FontResponseError`) can be imported without dragging the
* TanStack client and its eager `new QueryClient()` instantiation into the
* importer's module graph. See the barrel-coupling notes in the FSD audit.
*/
export class NonRetryableError extends Error {}
+36 -38
View File
@@ -1,14 +1,5 @@
import { QueryClient } from '@tanstack/query-core';
/**
* Marker base class for errors that retrying will never fix — schema-validation
* failures, unauthorized responses, contract violations, etc.
*
* The queryClient retry handler short-circuits when it sees this; without it,
* a non-transient backend bug pins the UI through the full retry budget
* (default 3× exponential backoff ≈ 7s).
*/
export class NonRetryableError extends Error {}
import { NonRetryableError } from './nonRetryableError';
/**
* Data remains fresh for this long after fetch. Stores that override
@@ -36,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)
@@ -48,30 +44,32 @@ 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({
defaultOptions: {
queries: {
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
gcTime: DEFAULT_QUERY_GC_TIME_MS,
/**
* Don't refetch when window regains focus
*/
refetchOnWindowFocus: false,
/**
* Refetch on mount if data is stale
*/
refetchOnMount: true,
retry: (failureCount, error) => {
if (error instanceof NonRetryableError) {
return false;
}
return failureCount < QUERY_RETRY_COUNT;
export function getQueryClient(): QueryClient {
return (queryClientInstance ??= new QueryClient({
defaultOptions: {
queries: {
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
gcTime: DEFAULT_QUERY_GC_TIME_MS,
/**
* Don't refetch when window regains focus
*/
refetchOnWindowFocus: false,
/**
* Refetch on mount if data is stale
*/
refetchOnMount: true,
retry: (failureCount, error) => {
if (error instanceof NonRetryableError) {
return false;
}
return failureCount < QUERY_RETRY_COUNT;
},
/**
* Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
*/
retryDelay: attemptIndex =>
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
},
/**
* Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
*/
retryDelay: attemptIndex =>
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,360 +0,0 @@
import {
type PreparedTextWithSegments,
layoutWithLines,
prepareWithSegments,
} from '@chenglou/pretext';
/**
* Width of the character morph "halo" around the slider thumb, in percent
* of container width. Characters within this window get partial blending
* instead of a hard A→B flip.
*/
const CHAR_PROXIMITY_RANGE_PCT = 5;
/**
* Default render size in px when callers omit the `size` arg on `layout()`.
* Kept as a local constant to avoid pulling `$entities/Font` into
* `$shared/lib` (would create an FSD-illegal upward import cycle).
*/
const DEFAULT_RENDER_SIZE_PX = 16;
/**
* A single laid-out line produced by dual-font comparison layout.
*
* Line breaking is determined by the unified worst-case widths, so both fonts
* always break at identical positions. Per-character `xA`/`xB` offsets reflect
* each font's actual advance widths independently.
*/
export interface ComparisonLine {
/**
* Full text of this line as returned by pretext.
*/
text: string;
/**
* Rendered width of this line in pixels — maximum across font A and font B.
*/
width: number;
/**
* Individual character metadata for both fonts in this line
*/
chars: Array<{
/**
* The grapheme cluster string (may be >1 code unit for emoji, etc.).
*/
char: string;
/**
* X offset from the start of the line in font A, in pixels.
*/
xA: number;
/**
* Advance width of this grapheme in font A, in pixels.
*/
widthA: number;
/**
* X offset from the start of the line in font B, in pixels.
*/
xB: number;
/**
* Advance width of this grapheme in font B, in pixels.
*/
widthB: number;
}>;
}
/**
* Aggregated output of a dual-font layout pass.
*/
export interface ComparisonResult {
/**
* Per-line grapheme data for both fonts. Empty when input text is empty.
*/
lines: ComparisonLine[];
/**
* Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee).
*/
totalHeight: number;
}
/**
* Dual-font text layout engine backed by `@chenglou/pretext`.
*
* Computes identical line breaks for two fonts simultaneously by constructing a
* "unified" prepared-text object whose per-glyph widths are the worst-case maximum
* of font A and font B. This guarantees that both fonts wrap at exactly the same
* positions, making side-by-side or slider comparison visually coherent.
*
* **Two-level caching strategy**
* 1. Font-change cache (`#preparedA`, `#preparedB`, `#unifiedPrepared`): rebuilt only
* when `text`, `fontA`, or `fontB` changes. `prepareWithSegments` is expensive
* (canvas measurement), so this avoids re-measuring during slider interaction.
* 2. Layout cache (`#lastResult`): rebuilt when `width` or `lineHeight` changes but
* the fonts have not changed. Line-breaking is cheap relative to measurement, but
* still worth skipping on every render tick.
*
* **`as any` casts:** `PreparedTextWithSegments` exposes only the `segments` field in
* its public TypeScript type. The numeric arrays (`widths`, `breakableFitAdvances`,
* `lineEndFitAdvances`, `lineEndPaintAdvances`) are internal implementation details of
* pretext that are not part of the published type signature. The casts are required to
* access these fields; they are verified against the pretext source at
* `node_modules/@chenglou/pretext/src/layout.ts`.
*/
export class CharacterComparisonEngine {
#segmenter: Intl.Segmenter;
// Cached prepared data
#preparedA: PreparedTextWithSegments | null = null;
#preparedB: PreparedTextWithSegments | null = null;
#unifiedPrepared: PreparedTextWithSegments | null = null;
#lastText = '';
#lastFontA = '';
#lastFontB = '';
#lastSpacing = 0;
#lastSize = 0;
// Cached layout results
#lastWidth = -1;
#lastLineHeight = -1;
#lastResult = $state<ComparisonResult | null>(null);
constructor(locale?: string) {
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
}
/**
* Lay out `text` using both fonts within `width` pixels.
*
* Line breaks are determined by the worst-case (maximum) glyph widths across
* both fonts, so both fonts always wrap at identical positions.
*
* @param text Raw text to lay out.
* @param fontA CSS font string for the first font: `"weight sizepx \"family\""`.
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
* @param width Available line width in pixels.
* @param lineHeight Line height in pixels (passed directly to pretext).
* @param spacing Letter spacing in em (from typography settings).
* @param size Current font size in pixels (used to convert spacing em to px).
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
*/
layout(
text: string,
fontA: string,
fontB: string,
width: number,
lineHeight: number,
spacing: number = 0,
size: number = DEFAULT_RENDER_SIZE_PX,
): ComparisonResult {
if (!text) {
return { lines: [], totalHeight: 0 };
}
const spacingPx = spacing * size;
const isFontChange = text !== this.#lastText
|| fontA !== this.#lastFontA
|| fontB !== this.#lastFontB
|| spacing !== this.#lastSpacing
|| size !== this.#lastSize;
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
if (!isFontChange && !isLayoutChange && this.#lastResult) {
return this.#lastResult;
}
// 1. Prepare (or use cache)
if (isFontChange) {
this.#preparedA = prepareWithSegments(text, fontA);
this.#preparedB = prepareWithSegments(text, fontB);
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB, spacingPx);
this.#lastText = text;
this.#lastFontA = fontA;
this.#lastFontB = fontB;
this.#lastSpacing = spacing;
this.#lastSize = size;
}
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
return { lines: [], totalHeight: 0 };
}
// 2. Layout using the unified widths.
// `PreparedTextWithSegments` only exposes `segments` in its public type; cast to `any`
// so pretext's layoutWithLines can read the internal numeric arrays at runtime.
const { lines, height } = layoutWithLines(this.#unifiedPrepared as any, width, lineHeight);
// 3. Map results back to both fonts
const resultLines: ComparisonLine[] = lines.map(line => {
const chars: ComparisonLine['chars'] = [];
let currentXA = 0;
let currentXB = 0;
const start = line.start;
const end = line.end;
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
const intA = this.#preparedA as any;
const intB = this.#preparedB as any;
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
const segmentText = this.#preparedA!.segments[sIdx];
if (segmentText === undefined) {
continue;
}
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
const advA = intA.breakableFitAdvances[sIdx];
const advB = intB.breakableFitAdvances[sIdx];
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
const char = graphemes[gIdx];
let wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
let wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
// Apply letter spacing (tracking) to the width of each character
wA += spacingPx;
wB += spacingPx;
chars.push({
char,
xA: currentXA,
widthA: wA,
xB: currentXB,
widthB: wB,
});
currentXA += wA;
currentXB += wB;
}
}
return {
text: line.text,
width: line.width,
chars,
};
});
this.#lastWidth = width;
this.#lastLineHeight = lineHeight;
this.#lastResult = {
lines: resultLines,
totalHeight: height,
};
return this.#lastResult;
}
/**
* Calculates character states for an entire line in a single sequential pass.
*
* Walks characters left-to-right, accumulating the running x position using
* each character's actual rendered width: `widthB` for already-morphed characters
* (isPast=true) and `widthA` for upcoming ones. This ensures thresholds stay
* aligned with the visual DOM layout even when the two fonts have different widths.
*
* @param line A single laid-out line from the last layout result.
* @param sliderPos Current slider position as a percentage (0100) of `containerWidth`.
* @param containerWidth Total container width in pixels.
* @returns Per-character `proximity` and `isPast` in the same order as `line.chars`.
*/
getLineCharStates(
line: ComparisonLine,
sliderPos: number,
containerWidth: number,
): Array<{ proximity: number; isPast: boolean }> {
if (!line) {
return [];
}
const chars = line.chars;
const n = chars.length;
const sliderX = (sliderPos / 100) * containerWidth;
const range = CHAR_PROXIMITY_RANGE_PCT;
// Prefix sums of widthA (left chars will be past → use widthA).
// Suffix sums of widthB (right chars will not be past → use widthB).
// This lets us compute, for each char i, what the total line width and
// char center would be at the exact moment the slider crosses that char:
// left side (0..i-1) already past → font A widths
// right side (i+1..n-1) not yet past → font B widths
const prefA = new Float64Array(n + 1);
const sufB = new Float64Array(n + 1);
for (let i = 0; i < n; i++) {
prefA[i + 1] = prefA[i] + chars[i].widthA;
}
for (let i = n - 1; i >= 0; i--) {
sufB[i] = sufB[i + 1] + chars[i].widthB;
}
// Per-char threshold: slider x at which this char should toggle isPast.
const thresholds = new Float64Array(n);
for (let i = 0; i < n; i++) {
const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1];
const xOffset = (containerWidth - totalWidth) / 2;
thresholds[i] = xOffset + prefA[i] + chars[i].widthA / 2;
}
// Determine isPast for each char at the current slider position.
const isPastArr = new Uint8Array(n);
for (let i = 0; i < n; i++) {
isPastArr[i] = sliderX > thresholds[i] ? 1 : 0;
}
// Compute visual positions based on actual rendered widths (font A if past, B if not).
const totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0);
const xOffset = (containerWidth - totalRendered) / 2;
let currentX = xOffset;
return chars.map((char, i) => {
const isPast = isPastArr[i] === 1;
const charWidth = isPast ? char.widthA : char.widthB;
const visualCenter = currentX + charWidth / 2;
const charGlobalPercent = (visualCenter / containerWidth) * 100;
const distance = Math.abs(sliderPos - charGlobalPercent);
const proximity = Math.max(0, 1 - distance / range);
currentX += charWidth;
return { proximity, isPast };
});
}
/**
* Internal helper to merge two prepared texts into a "worst-case" unified version
*/
#createUnifiedPrepared(
a: PreparedTextWithSegments,
b: PreparedTextWithSegments,
spacingPx: number = 0,
): PreparedTextWithSegments {
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
const intA = a as any;
const intB = b as any;
const unified = { ...intA };
unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]) + spacingPx);
unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) =>
Math.max(w, intB.lineEndFitAdvances[i]) + spacingPx
);
unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) =>
Math.max(w, intB.lineEndPaintAdvances[i]) + spacingPx
);
unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => {
const advB = intB.breakableFitAdvances[i];
if (!advA && !advB) {
return null;
}
if (!advA) {
return advB.map((w: number) => w + spacingPx);
}
if (!advB) {
return advA.map((w: number) => w + spacingPx);
}
return advA.map((w: number, j: number) => Math.max(w, advB[j]) + spacingPx);
});
return unified;
}
}

Some files were not shown because too many files have changed in this diff Show More