Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ace4aee07 | |||
| f3a2a6a7bd | |||
| 118c588859 | |||
| 59097ca9ad | |||
| 738ed3b4ed | |||
| 132d1327f5 | |||
| 92ea7b9dc4 | |||
| e55e713517 | |||
| f49180e83d | |||
| 2c3d88c81f | |||
| 0e9288c295 | |||
| dbd48b287d | |||
| f29e0b0c7c | |||
| 91bb046339 | |||
| f680fe01ea | |||
| d37d01e6d8 | |||
| c78b8e032e | |||
| 11d5ba0e63 | |||
| 99e9a1fb2c | |||
| 5084df3914 | |||
| a2ec025a65 | |||
| 8dbea97a33 | |||
| 744cdc9d19 | |||
| 600b905e01 | |||
| 4ad0fe4cfa | |||
| eafe89b313 | |||
| 724b00d3d5 | |||
| c09ca93f4e | |||
| 99ab7e9e08 | |||
| ec488cf1ce | |||
| fe07c60dd4 | |||
| 0aae710e35 | |||
| ded9606c30 | |||
| f0736f4d35 | |||
| 5eb458eabb | |||
| a428eac309 | |||
| 09869aed00 | |||
| 028853aff5 | |||
| 1c6427c586 | |||
| 60e115309c | |||
| b390efdabe | |||
| 771bda745c | |||
| c6c8497906 | |||
| f3a10e38df | |||
| 9788f07dec | |||
| deefb51b57 | |||
| 431fb41a7f | |||
| db6384110e | |||
| cbd95350bb | |||
| a8a985ee6a | |||
| be073286dc | |||
| 7798c4bbdf | |||
| 3ae22ad515 | |||
| ffa897ee54 | |||
| 93c52dd132 | |||
| 9e0c8f740b | |||
| b1b5177e02 | |||
| ef9cd33e48 | |||
| f3c76df2c5 | |||
| ae2d0e3c2f | |||
| 3f5151efa0 | |||
| 19d9b07c55 | |||
| 1209358d40 | |||
| d7decd7a00 | |||
| 9d6220d2ec | |||
| 4756682863 | |||
| 7ddf232e3a | |||
| b3bc40b76c | |||
| 839460726e | |||
| 6877807aaf | |||
| 3dca11fea8 | |||
| 0b675635b3 | |||
| 9780ff9358 | |||
| 1ad015aed6 | |||
| 10603d18bf | |||
| 39d1ce4c37 | |||
| fcd61be4fa | |||
| 28a8e49915 | |||
| 43e8507144 | |||
| 67af3d946a | |||
| c6d0270072 | |||
| a677dc6b0b | |||
| 17c022470e | |||
| a9f3b990ab | |||
| 36673597f7 | |||
| 42bcc915c7 | |||
| c72b51b1c7 | |||
| 6888f67f14 | |||
| a651d3d16f | |||
| 4d8dcf52e0 | |||
| 907145c655 | |||
| e49148008b | |||
| c613d4cf88 | |||
| 7834c7cbf2 | |||
| 4640d6e521 | |||
| 8adf5cd7b3 | |||
| b8edeff86f | |||
| 7d66b0bc92 | |||
| ecdb2f1b7f | |||
| 6a07b89773 | |||
| 02aa27dc48 | |||
| 4652857512 | |||
| d5f0814efc | |||
| 6153769317 | |||
| 3e568685b3 | |||
| 581ffb5887 | |||
| 2ece4c5559 | |||
| 1fa099bef5 | |||
| 50238e12c3 | |||
| f13dfe1caf | |||
| f4edb67acb | |||
| ccf51c645e | |||
| efbc464b14 | |||
| c5092a488b | |||
| ddadac8686 | |||
| f6911fbcca |
+195
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,28 @@
|
||||
:3000 {
|
||||
root * /usr/share/caddy
|
||||
file_server
|
||||
|
||||
# Compress text responses only. woff2/png and other binaries are already
|
||||
# compressed, so they're excluded — re-compressing them burns CPU for ~0%.
|
||||
encode {
|
||||
zstd
|
||||
gzip
|
||||
match {
|
||||
header Content-Type text/*
|
||||
header Content-Type application/javascript*
|
||||
header Content-Type application/json*
|
||||
header Content-Type image/svg+xml*
|
||||
}
|
||||
}
|
||||
|
||||
# Vite emits all build output under /assets/ with content-hashed filenames,
|
||||
# so those bytes never change for a given URL — cache them indefinitely.
|
||||
@assets path /assets/*
|
||||
header @assets Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
# The HTML shell is the un-hashed entry point; it must revalidate so a new
|
||||
# deploy is served immediately rather than from a stale cache.
|
||||
header /index.html Cache-Control "no-cache"
|
||||
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { windowSizeForLine } from '../src/entities/Font/domain/windowSizeForLine/windowSizeForLine';
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
@@ -5,12 +6,22 @@ import {
|
||||
|
||||
test.describe('preview text', () => {
|
||||
test('drives the slider character rendering', async ({ comparison }) => {
|
||||
/**
|
||||
* Must stay a single unwrapped line of ASCII: the assertion feeds
|
||||
* `text.length` (UTF-16 code units) to `windowSizeForLine`, but the
|
||||
* renderer feeds it the line's grapheme count. They match only for
|
||||
* plain ASCII — emoji/combining marks (length > graphemes) or wrapping
|
||||
* (one input string splitting into several lines) silently desync them.
|
||||
*/
|
||||
const text = 'Sphinx';
|
||||
await comparison.pickPair('Inter', 'Roboto');
|
||||
await comparison.setPreviewText('Sphinx');
|
||||
await comparison.setPreviewText(text);
|
||||
|
||||
// Each grapheme renders as a `.char-wrap` cell in the slider once
|
||||
// both fonts are loaded. Six glyphs → six cells.
|
||||
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(6);
|
||||
// Window chars render as `.char-wrap` cells for crossfade. The window
|
||||
// size is a pure function of the line's grapheme count — assert against
|
||||
// the rule, not a hardcoded constant, so tuning the policy can't silently
|
||||
// break this. "Sphinx" is one unwrapped line of 6 graphemes.
|
||||
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(windowSizeForLine(text.length));
|
||||
});
|
||||
|
||||
test('preserves the typed value in the input', async ({ comparison }) => {
|
||||
|
||||
-27
@@ -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
-2
@@ -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",
|
||||
@@ -44,7 +48,6 @@
|
||||
"@types/jsdom": "28.0.1",
|
||||
"@vitest/browser-playwright": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"bits-ui": "2.18.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dprint": "0.54.0",
|
||||
"jsdom": "29.1.1",
|
||||
@@ -66,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
@@ -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>
|
||||
|
||||
@@ -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?.()}
|
||||
@@ -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 +1,2 @@
|
||||
export { default as AppBindingsProvider } from './AppBindings.svelte';
|
||||
export { default as QueryProvider } from './QueryProvider.svelte';
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
type ComparisonLine,
|
||||
type ComparisonResult,
|
||||
DualFontLayout,
|
||||
} from './DualFontLayout/DualFontLayout';
|
||||
export {
|
||||
computeLineRenderModel,
|
||||
findSplitIndex,
|
||||
type LineRenderModel,
|
||||
} from './computeLineRenderModel/computeLineRenderModel';
|
||||
export { windowSizeForLine } from './windowSizeForLine/windowSizeForLine';
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { windowSizeForLine } from './windowSizeForLine';
|
||||
|
||||
describe('windowSizeForLine', () => {
|
||||
it('returns 0 for an empty or non-positive line', () => {
|
||||
expect(windowSizeForLine(0)).toBe(0);
|
||||
expect(windowSizeForLine(-3)).toBe(0);
|
||||
});
|
||||
|
||||
it('floors non-empty short lines at the minimum window of 1', () => {
|
||||
expect(windowSizeForLine(1)).toBe(1);
|
||||
expect(windowSizeForLine(2)).toBe(1);
|
||||
expect(windowSizeForLine(3)).toBe(1);
|
||||
});
|
||||
|
||||
it('scales with round(n / 3) in the mid range', () => {
|
||||
expect(windowSizeForLine(6)).toBe(2);
|
||||
expect(windowSizeForLine(12)).toBe(4);
|
||||
});
|
||||
|
||||
it('caps at the maximum window of 5', () => {
|
||||
expect(windowSizeForLine(15)).toBe(5);
|
||||
expect(windowSizeForLine(16)).toBe(5);
|
||||
expect(windowSizeForLine(100)).toBe(5);
|
||||
});
|
||||
|
||||
it('rounds to nearest at fractional boundaries', () => {
|
||||
// round(4/3)=1, round(5/3)=2, round(13/3)=4, round(14/3)=5
|
||||
expect(windowSizeForLine(4)).toBe(1);
|
||||
expect(windowSizeForLine(5)).toBe(2);
|
||||
expect(windowSizeForLine(13)).toBe(4);
|
||||
expect(windowSizeForLine(14)).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Crossfade-window sizing policy for the dual-font slider.
|
||||
*
|
||||
* The slider renders a band of per-char `Character` cells that opacity-crossfade
|
||||
* between the two fonts; everything outside the band is committed native bulk
|
||||
* text. A fixed band looked wrong on short lines — a 6-grapheme line left almost
|
||||
* no bulk, so nearly the whole line shimmered as per-char DOM. The band size
|
||||
* therefore scales with the line's grapheme count and caps so long lines don't
|
||||
* pay for an oversized per-char DOM band.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fraction of a line's graphemes that sit in the crossfade band.
|
||||
*/
|
||||
const WINDOW_RATIO = 1 / 3;
|
||||
/**
|
||||
* Smallest band for a non-empty line — guarantees at least one crossfading char.
|
||||
*
|
||||
* Accepted tradeoff: short lines now get a band of 1–2, so a fast slider drag
|
||||
* can unmount a char before its ~100ms opacity crossfade finishes, a slight pop.
|
||||
* Worth it for the "bulk committed, small band shimmering" look on short lines;
|
||||
* raising this trades that pop back for less committed bulk.
|
||||
*/
|
||||
const WINDOW_MIN = 1;
|
||||
/**
|
||||
* Largest band regardless of line length — bounds per-char DOM cost.
|
||||
*/
|
||||
const WINDOW_MAX = 5;
|
||||
|
||||
/**
|
||||
* Crossfade window size, in graphemes, for a line of `n` graphemes.
|
||||
* `clamp(round(n / 3), 1, 5)`; an empty/non-positive line gets no window.
|
||||
*/
|
||||
export function windowSizeForLine(n: number): number {
|
||||
if (n <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(WINDOW_MAX, Math.max(WINDOW_MIN, Math.round(n * WINDOW_RATIO)));
|
||||
}
|
||||
@@ -1,4 +1,93 @@
|
||||
export * from './api';
|
||||
export * from './lib';
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
export {
|
||||
computeLineRenderModel,
|
||||
DualFontLayout,
|
||||
findSplitIndex,
|
||||
windowSizeForLine,
|
||||
} 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`.
|
||||
|
||||
+111
@@ -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,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
+5
-5
@@ -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.
|
||||
+8
-6
@@ -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', () => {
|
||||
@@ -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';
|
||||
|
||||
@@ -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' });
|
||||
+2
-6
@@ -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>
|
||||
|
||||
|
||||
+19
-2
@@ -4,7 +4,7 @@ import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import FontSampler from './FontSampler.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Features/FontSampler',
|
||||
title: 'Entities/Font/FontSampler',
|
||||
component: FontSampler,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
@@ -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>)}
|
||||
+54
-24
@@ -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}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||
|
||||
export {
|
||||
FontApplicator,
|
||||
FontSampler,
|
||||
FontVirtualList,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { comboKey } from './comboKey';
|
||||
|
||||
describe('comboKey', () => {
|
||||
it('derives a key from the two font ids', () => {
|
||||
expect(comboKey({ id: 'x', headerFontId: 'Inter', bodyFontId: 'Lora' })).toBe('Inter|Lora');
|
||||
});
|
||||
it('ignores the surrogate id (content not identity)', () => {
|
||||
const a = comboKey({ id: 'a', headerFontId: 'Inter', bodyFontId: 'Lora' });
|
||||
const b = comboKey({ id: 'b', headerFontId: 'Inter', bodyFontId: 'Lora' });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
it('is order-sensitive on role', () => {
|
||||
expect(comboKey({ id: 'x', headerFontId: 'Lora', bodyFontId: 'Inter' })).toBe('Lora|Inter');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Pairing } from '../types';
|
||||
|
||||
/**
|
||||
* Natural key describing a Pairing's current fonts (not its identity).
|
||||
* Used for URL share-encoding and "is this combo already on the board" checks.
|
||||
* Recomputed on swap; two cards may share a comboKey but never an id.
|
||||
*
|
||||
* @param pairing - The pairing whose fonts form the key (its `id` is ignored).
|
||||
* @returns The `headerFontId|bodyFontId` key.
|
||||
*/
|
||||
export function comboKey(pairing: Pairing): string {
|
||||
return `${pairing.headerFontId}|${pairing.bodyFontId}`;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { createPairing } from './createPairing';
|
||||
|
||||
describe('createPairing', () => {
|
||||
it('builds a pairing from two font ids', () => {
|
||||
const p = createPairing('Inter', 'Lora');
|
||||
expect(p.headerFontId).toBe('Inter');
|
||||
expect(p.bodyFontId).toBe('Lora');
|
||||
});
|
||||
it('generates a unique id each call (duplicates stay distinct)', () => {
|
||||
const a = createPairing('Inter', 'Lora');
|
||||
const b = createPairing('Inter', 'Lora');
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
it('accepts an explicit id for rehydration', () => {
|
||||
expect(createPairing('Inter', 'Lora', 'fixed-id').id).toBe('fixed-id');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Pairing } from '../types';
|
||||
|
||||
/**
|
||||
* Creates a Pairing with a fresh surrogate id (or a supplied one when
|
||||
* rehydrating from storage). The id is identity, never content — two pairings
|
||||
* with the same fonts are still distinct cards.
|
||||
*
|
||||
* @param headerFontId - Font entity id for the header role.
|
||||
* @param bodyFontId - Font entity id for the body role.
|
||||
* @param id - Explicit id for rehydration; defaults to a fresh UUID.
|
||||
* @returns The new Pairing.
|
||||
*/
|
||||
export function createPairing(headerFontId: string, bodyFontId: string, id: string = crypto.randomUUID()): Pairing {
|
||||
return { id, headerFontId, bodyFontId };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { comboKey } from './comboKey/comboKey';
|
||||
export { createPairing } from './createPairing/createPairing';
|
||||
export { nextFocalId } from './nextFocalId/nextFocalId';
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { nextFocalId } from './nextFocalId';
|
||||
|
||||
const ids = ['a', 'b', 'c'];
|
||||
|
||||
describe('nextFocalId', () => {
|
||||
it('steps forward', () => {
|
||||
expect(nextFocalId(ids, 'a', 1)).toBe('b');
|
||||
});
|
||||
it('steps backward', () => {
|
||||
expect(nextFocalId(ids, 'b', -1)).toBe('a');
|
||||
});
|
||||
it('wraps forward at the end', () => {
|
||||
expect(nextFocalId(ids, 'c', 1)).toBe('a');
|
||||
});
|
||||
it('wraps backward at the start', () => {
|
||||
expect(nextFocalId(ids, 'a', -1)).toBe('c');
|
||||
});
|
||||
it('returns the only id when list has one', () => {
|
||||
expect(nextFocalId(['solo'], 'solo', 1)).toBe('solo');
|
||||
});
|
||||
it('returns current when focal id is absent', () => {
|
||||
expect(nextFocalId(ids, 'missing', 1)).toBe('missing');
|
||||
});
|
||||
it('returns null for an empty list', () => {
|
||||
expect(nextFocalId([], 'x', 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The id one step from `currentId` in board order, wrapping at both ends.
|
||||
*
|
||||
* @param orderedIds - Pairing ids in board order.
|
||||
* @param currentId - The currently focal id to step from.
|
||||
* @param direction - +1 for next, -1 for previous.
|
||||
* @returns The neighbouring id (wrapped), `currentId` unchanged if it isn't in
|
||||
* the list, or null for an empty list.
|
||||
*/
|
||||
export function nextFocalId(orderedIds: string[], currentId: string, direction: 1 | -1): string | null {
|
||||
if (orderedIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const i = orderedIds.indexOf(currentId);
|
||||
if (i === -1) {
|
||||
return currentId;
|
||||
}
|
||||
const len = orderedIds.length;
|
||||
const next = (i + direction + len) % len;
|
||||
return orderedIds[next];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type {
|
||||
Pairing,
|
||||
Role,
|
||||
} from './pairing';
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* A slot within a Pairing that a font fills.
|
||||
*/
|
||||
export type Role = 'header' | 'body';
|
||||
|
||||
/**
|
||||
* The atomic unit of comparison: a header font + a body font.
|
||||
* Carries a surrogate `id` (stable for the card's life, never tracks content)
|
||||
* and the two font ids it pairs. Text and typography are global to the Board,
|
||||
* not stored here.
|
||||
*/
|
||||
export interface Pairing {
|
||||
/**
|
||||
* Surrogate key generated at creation, stable for the card's life.
|
||||
* Distinguishes duplicates with identical fonts. Focal/cycling key on this.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Font entity id filling the header role.
|
||||
*/
|
||||
headerFontId: string;
|
||||
/**
|
||||
* Font entity id filling the body role.
|
||||
*/
|
||||
bodyFontId: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
comboKey,
|
||||
createPairing,
|
||||
nextFocalId,
|
||||
} from './domain';
|
||||
export type {
|
||||
Pairing,
|
||||
Role,
|
||||
} from './model/types';
|
||||
@@ -0,0 +1,4 @@
|
||||
export type {
|
||||
Pairing,
|
||||
Role,
|
||||
} from './pairing';
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Re-export of the Pairing identity types. The source of truth lives in
|
||||
* `domain/types` so the pure domain segment can reference them without importing
|
||||
* `model` (FSD+ domain isolation: ui -> model -> domain, never back).
|
||||
*/
|
||||
export type {
|
||||
Pairing,
|
||||
Role,
|
||||
} from '../../domain/types';
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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';
|
||||
|
||||
+42
-17
@@ -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;
|
||||
|
||||
+5
-2
@@ -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;
|
||||
};
|
||||
+67
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+3
-5
@@ -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,26 +5,26 @@
|
||||
Desktop: inline bar with combo controls.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
Button,
|
||||
ComboControl,
|
||||
ControlGroup,
|
||||
Popover,
|
||||
Slider,
|
||||
} from '$shared/ui';
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { Popover } from 'bits-ui';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { typographySettingsStore } from '../../model';
|
||||
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.
|
||||
@@ -73,33 +74,21 @@ $effect(() => {
|
||||
{#if !hidden}
|
||||
{#if responsive.isMobileOrTablet}
|
||||
<div class={className}>
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="primary" {...props}>
|
||||
{#snippet icon()}
|
||||
<Settings2Icon class="size-4" />
|
||||
{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover bind:open side="top" align="end" sideOffset={8}>
|
||||
{#snippet trigger(props)}
|
||||
<Button variant="primary" {...props}>
|
||||
{#snippet icon()}
|
||||
<Settings2Icon class="size-4" />
|
||||
{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
{#snippet children({ close })}
|
||||
<div
|
||||
class={cn(
|
||||
'z-50 w-72 p-4 rounded-none',
|
||||
'w-72 p-4 rounded-none',
|
||||
'surface-popover',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=top]:slide-in-from-bottom-2',
|
||||
'data-[side=bottom]:slide-in-from-top-2',
|
||||
)}
|
||||
interactOutsideBehavior="close"
|
||||
escapeKeydownBehavior="close"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
||||
@@ -111,17 +100,13 @@ $effect(() => {
|
||||
CONTROLS
|
||||
</span>
|
||||
</div>
|
||||
<Popover.Close>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
aria-label="Close controls"
|
||||
>
|
||||
<XIcon class="size-3.5 text-neutral-500" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Popover.Close>
|
||||
<button
|
||||
onclick={close}
|
||||
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
aria-label="Close controls"
|
||||
>
|
||||
<XIcon class="size-3.5 text-neutral-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
@@ -135,9 +120,9 @@ $effect(() => {
|
||||
/>
|
||||
</ControlGroup>
|
||||
{/each}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
|
||||
@@ -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,
|
||||
@@ -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';
|
||||
+20
-3
@@ -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;
|
||||
+2
-3
@@ -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);
|
||||
+2
-1
@@ -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');
|
||||
|
||||
+9
-3
@@ -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);
|
||||
+3
-1
@@ -6,9 +6,11 @@
|
||||
import { type Snippet } from 'svelte';
|
||||
import {
|
||||
type NavigationAction,
|
||||
scrollBreadcrumbsStore,
|
||||
getScrollBreadcrumbsStore,
|
||||
} from '../../model';
|
||||
|
||||
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Navigation index
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
export { getThemeManager } from './model';
|
||||
export { ThemeSwitch } from './ui';
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export { fitColumns } from './lib';
|
||||
export {
|
||||
__resetBoard,
|
||||
type BoardStore,
|
||||
FRAME_ROLE_GAP,
|
||||
getBoard,
|
||||
MAX_COLUMNS,
|
||||
type RoleTypography,
|
||||
} from './model';
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
combineFrameHeight,
|
||||
type CombineFrameHeightInput,
|
||||
fitColumns,
|
||||
type FitColumnsInput,
|
||||
measureRoleHeight,
|
||||
type RoleHeightInput,
|
||||
} from './measure';
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { combineFrameHeight } from './combineFrameHeight';
|
||||
|
||||
describe('combineFrameHeight', () => {
|
||||
it('sums header + gap + body block heights', () => {
|
||||
expect(combineFrameHeight({ headerHeight: 60, bodyHeight: 200, gap: 24 })).toBe(284);
|
||||
});
|
||||
it('omits the gap when one block is empty (zero height)', () => {
|
||||
expect(combineFrameHeight({ headerHeight: 0, bodyHeight: 200, gap: 24 })).toBe(200);
|
||||
expect(combineFrameHeight({ headerHeight: 60, bodyHeight: 0, gap: 24 })).toBe(60);
|
||||
});
|
||||
it('is zero when both blocks are empty', () => {
|
||||
expect(combineFrameHeight({ headerHeight: 0, bodyHeight: 0, gap: 24 })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Inputs for combining a frame's two role blocks into one height.
|
||||
*/
|
||||
export interface CombineFrameHeightInput {
|
||||
/**
|
||||
* Measured header block height in px.
|
||||
*/
|
||||
headerHeight: number;
|
||||
/**
|
||||
* Measured body block height in px.
|
||||
*/
|
||||
bodyHeight: number;
|
||||
/**
|
||||
* Gap in px between the header and body blocks.
|
||||
*/
|
||||
gap: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total focal-frame height: header block + gap + body block. The gap only
|
||||
* applies when both blocks have height — an empty role (no specimen text)
|
||||
* contributes neither height nor a dangling gap.
|
||||
*
|
||||
* @param input - The two block heights and the inter-block gap.
|
||||
* @returns The combined frame height in px.
|
||||
*/
|
||||
export function combineFrameHeight({ headerHeight, bodyHeight, gap }: CombineFrameHeightInput): number {
|
||||
const gapApplies = headerHeight > 0 && bodyHeight > 0;
|
||||
return headerHeight + bodyHeight + (gapApplies ? gap : 0);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { fitColumns } from './fitColumns';
|
||||
|
||||
describe('fitColumns', () => {
|
||||
it('packs as many honest columns as fit, gap-aware', () => {
|
||||
// each needs 600, gap 40, available 1280 -> 1 col=600, 2 cols=1240, 3=1880
|
||||
expect(fitColumns({ naturalWidth: 600, available: 1280, gap: 40, maxColumns: 3 })).toBe(2);
|
||||
});
|
||||
it('never exceeds maxColumns even with room', () => {
|
||||
expect(fitColumns({ naturalWidth: 100, available: 5000, gap: 20, maxColumns: 3 })).toBe(3);
|
||||
});
|
||||
it('never returns less than 1', () => {
|
||||
expect(fitColumns({ naturalWidth: 9000, available: 300, gap: 20, maxColumns: 3 })).toBe(1);
|
||||
});
|
||||
it('fits a column at the exact boundary (inclusive)', () => {
|
||||
// 2 cols: 2*600 + 1*40 = 1240 == available -> fits
|
||||
expect(fitColumns({ naturalWidth: 600, available: 1240, gap: 40, maxColumns: 3 })).toBe(2);
|
||||
// one px short -> only 1
|
||||
expect(fitColumns({ naturalWidth: 600, available: 1239, gap: 40, maxColumns: 3 })).toBe(1);
|
||||
});
|
||||
it('respects a maxColumns of 1 even with unlimited room', () => {
|
||||
expect(fitColumns({ naturalWidth: 100, available: 5000, gap: 20, maxColumns: 1 })).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Inputs for column gating.
|
||||
*/
|
||||
export interface FitColumnsInput {
|
||||
/**
|
||||
* The widest pairing's Pretext natural (shrink-wrap) width in px.
|
||||
*/
|
||||
naturalWidth: number;
|
||||
/**
|
||||
* Total available width in px for the columns row.
|
||||
*/
|
||||
available: number;
|
||||
/**
|
||||
* Gap in px between columns.
|
||||
*/
|
||||
gap: number;
|
||||
/**
|
||||
* Hard cap on columns that still preserve an honest measure (2–3).
|
||||
*/
|
||||
maxColumns: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many equal honest columns fit. Uses the real per-pairing required width
|
||||
* (Pretext shrink-wrap) — the 45–75ch rule is only a fallback bound elsewhere.
|
||||
* `n` columns occupy `n*naturalWidth + (n-1)*gap`. Clamped to [1, maxColumns].
|
||||
*
|
||||
* @param input - Natural width, available width, gap, and column cap.
|
||||
* @returns The number of columns that fit, in [1, maxColumns].
|
||||
*/
|
||||
export function fitColumns({ naturalWidth, available, gap, maxColumns }: FitColumnsInput): number {
|
||||
let fit = 1;
|
||||
for (let n = 2; n <= maxColumns; n++) {
|
||||
if (n * naturalWidth + (n - 1) * gap <= available) {
|
||||
fit = n;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return fit;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
combineFrameHeight,
|
||||
type CombineFrameHeightInput,
|
||||
} from './combineFrameHeight';
|
||||
export {
|
||||
fitColumns,
|
||||
type FitColumnsInput,
|
||||
} from './fitColumns';
|
||||
export {
|
||||
measureRoleHeight,
|
||||
type RoleHeightInput,
|
||||
} from './measureFrameHeight';
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { measureRoleHeight } from './measureFrameHeight';
|
||||
|
||||
describe('measureRoleHeight', () => {
|
||||
it('multiplies pretext line count by sizePx*lineHeight', () => {
|
||||
const layout = vi.fn().mockReturnValue({ lineCount: 3, height: 0 });
|
||||
const prepared = {} as never;
|
||||
// 3 lines * 20px * 1.5 = 90
|
||||
expect(measureRoleHeight({ prepared, maxWidth: 600, sizePx: 20, lineHeight: 1.5 }, layout)).toBe(90);
|
||||
});
|
||||
it('passes width and pixel line-height into pretext layout', () => {
|
||||
const layout = vi.fn().mockReturnValue({ lineCount: 1, height: 0 });
|
||||
measureRoleHeight({ prepared: {} as never, maxWidth: 600, sizePx: 16, lineHeight: 1.25 }, layout);
|
||||
expect(layout).toHaveBeenCalledWith(expect.anything(), 600, 16 * 1.25);
|
||||
});
|
||||
it('returns 0 when the text lays out to zero lines (empty specimen)', () => {
|
||||
const layout = vi.fn().mockReturnValue({ lineCount: 0, height: 0 });
|
||||
expect(measureRoleHeight({ prepared: {} as never, maxWidth: 600, sizePx: 16, lineHeight: 1.5 }, layout))
|
||||
.toBe(0);
|
||||
});
|
||||
it('handles fractional sizes and line-heights without rounding', () => {
|
||||
const layout = vi.fn().mockReturnValue({ lineCount: 2, height: 0 });
|
||||
// 2 * 15.5 * 1.4 = 43.4
|
||||
expect(measureRoleHeight({ prepared: {} as never, maxWidth: 320, sizePx: 15.5, lineHeight: 1.4 }, layout))
|
||||
.toBeCloseTo(43.4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
type PreparedText,
|
||||
layout as pretextLayout,
|
||||
} from '@chenglou/pretext';
|
||||
|
||||
/**
|
||||
* Inputs for measuring one role block's rendered height.
|
||||
*/
|
||||
export interface RoleHeightInput {
|
||||
/**
|
||||
* Pretext-prepared specimen text for this role+font.
|
||||
*/
|
||||
prepared: PreparedText;
|
||||
/**
|
||||
* Available width in px (the focal frame's content width).
|
||||
*/
|
||||
maxWidth: number;
|
||||
/**
|
||||
* Resolved font-size in px.
|
||||
*/
|
||||
sizePx: number;
|
||||
/**
|
||||
* Unitless line-height multiplier.
|
||||
*/
|
||||
lineHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Height in px of a role's text block at the given width, from Pretext's
|
||||
* pure-arithmetic line count.
|
||||
*
|
||||
* Height is `lineCount * sizePx * lineHeight` rather than Pretext's own
|
||||
* `height` so it tracks the CSS box model exactly (line-height as a multiple of
|
||||
* font-size), keeping measurement and render in lockstep — the zero-shift
|
||||
* invariant.
|
||||
*
|
||||
* @param input - Prepared text plus width and resolved type metrics.
|
||||
* @param layout - Pretext layout fn; injectable for unit tests, defaults to
|
||||
* `@chenglou/pretext`'s `layout`.
|
||||
* @returns The block height in px.
|
||||
*/
|
||||
export function measureRoleHeight(input: RoleHeightInput, layout = pretextLayout): number {
|
||||
const { prepared, maxWidth, sizePx, lineHeight } = input;
|
||||
const { lineCount } = layout(prepared, maxWidth, sizePx * lineHeight);
|
||||
return lineCount * sizePx * lineHeight;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* localStorage key for the persisted board (pairings + focal + specimen).
|
||||
*/
|
||||
export const BOARD_STORAGE_KEY = 'glyphdiff:board';
|
||||
|
||||
/**
|
||||
* Per-role typography storage key — header AdjustTypography instance.
|
||||
*/
|
||||
export const HEADER_TYPO_KEY = 'glyphdiff:typo:header';
|
||||
|
||||
/**
|
||||
* Per-role typography storage key — body AdjustTypography instance.
|
||||
*/
|
||||
export const BODY_TYPO_KEY = 'glyphdiff:typo:body';
|
||||
|
||||
/**
|
||||
* Schema version stamped into persisted board state (gates future
|
||||
* migrations / the URL share-state codec).
|
||||
*/
|
||||
export const BOARD_SCHEMA_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Hard cap on side-by-side columns that still preserve an honest measure.
|
||||
*/
|
||||
export const MAX_COLUMNS = 3;
|
||||
|
||||
/**
|
||||
* Vertical gap in px between the header block and the body block within a frame.
|
||||
* Used by frame-height measurement so the reserved height matches the rendered
|
||||
* layout exactly (zero-shift).
|
||||
*/
|
||||
export const FRAME_ROLE_GAP = 24;
|
||||
|
||||
/**
|
||||
* Default shared specimen — one header line + one body paragraph (single
|
||||
* language). Used to seed the board and as the share-state fallback.
|
||||
*/
|
||||
export const DEFAULT_SPECIMEN = {
|
||||
header: 'The Art of Harmonious Type',
|
||||
body:
|
||||
'Good typography is invisible. It guides the eye without calling attention to itself, balancing rhythm, contrast, and proportion so the reader forgets there is a typeface at all and simply reads.',
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
FRAME_ROLE_GAP,
|
||||
MAX_COLUMNS,
|
||||
} from './const/const';
|
||||
export {
|
||||
__resetBoard,
|
||||
type BoardStore,
|
||||
getBoard,
|
||||
type RoleTypography,
|
||||
} from './store/boardStore/boardStore.svelte';
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
/**
|
||||
* Font orchestration is exercised in e2e (needs a real browser/queryClient).
|
||||
* Here we stub the entity's font stores so the board's pure logic stays testable
|
||||
* off the network — only `candidateFontIds` derivation is asserted at this level.
|
||||
*/
|
||||
const mockLifecycle = vi.hoisted(() => ({
|
||||
touch: vi.fn(),
|
||||
pin: vi.fn(),
|
||||
unpin: vi.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Catalog stub with four fonts so the seeding effect has material to pair.
|
||||
* Seeding only fires when storage is empty AND nothing has been added yet, so
|
||||
* the empty/add tests (which never flush before asserting) are unaffected.
|
||||
*/
|
||||
const mockCatalog = vi.hoisted(() => ({
|
||||
fonts: [
|
||||
{ id: 'c0', name: 'C0' },
|
||||
{ id: 'c1', name: 'C1' },
|
||||
{ id: 'c2', name: 'C2' },
|
||||
{ id: 'c3', name: 'C3' },
|
||||
],
|
||||
}));
|
||||
|
||||
/** Mutable resolved-font list the stubbed FontsByIdsStore returns; reset per test. */
|
||||
const mockFonts = vi.hoisted(() => [] as { id: string; name: string }[]);
|
||||
|
||||
vi.mock('$entities/Font', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('$entities/Font')>();
|
||||
class MockFontsByIdsStore {
|
||||
setIds() {}
|
||||
get fonts() {
|
||||
return mockFonts;
|
||||
}
|
||||
get isLoading() {
|
||||
return false;
|
||||
}
|
||||
destroy() {}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
FontsByIdsStore: MockFontsByIdsStore,
|
||||
getFontLifecycleManager: () => mockLifecycle,
|
||||
getFontCatalog: () => mockCatalog,
|
||||
getFontUrl: () => 'https://example.com/font.woff2',
|
||||
};
|
||||
});
|
||||
|
||||
// ensureCanvasFonts needs a real browser canvas; stub it to resolve immediately.
|
||||
// Spread actual so createPersistentStore/getPretextFontString stay real.
|
||||
vi.mock('$shared/lib', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('$shared/lib')>();
|
||||
return { ...actual, ensureCanvasFonts: vi.fn(() => Promise.resolve()) };
|
||||
});
|
||||
|
||||
// Pretext measures via canvas (degenerate in jsdom); stub for deterministic lines.
|
||||
vi.mock('@chenglou/pretext', () => ({
|
||||
prepareWithSegments: vi.fn(() => ({})),
|
||||
layout: vi.fn(() => ({ lineCount: 2, height: 0 })),
|
||||
}));
|
||||
|
||||
import { flushSync } from 'svelte';
|
||||
import {
|
||||
__resetBoard,
|
||||
getBoard,
|
||||
} from './boardStore.svelte';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockFonts.length = 0;
|
||||
__resetBoard();
|
||||
});
|
||||
afterEach(() => __resetBoard());
|
||||
|
||||
describe('boardStore', () => {
|
||||
it('starts empty with no focal', () => {
|
||||
const board = getBoard();
|
||||
expect(board.pairings).toEqual([]);
|
||||
expect(board.focalId).toBeNull();
|
||||
});
|
||||
|
||||
it('adds a pairing and makes the first one focal', () => {
|
||||
const board = getBoard();
|
||||
const p = board.addPairing('Inter', 'Lora');
|
||||
expect(board.pairings).toHaveLength(1);
|
||||
expect(board.focalId).toBe(p.id);
|
||||
expect(board.focal).toEqual(p);
|
||||
});
|
||||
|
||||
it('cycles focal forward with wrap', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
const b = board.addPairing('Roboto', 'Merriweather');
|
||||
board.setFocal(a.id);
|
||||
board.cycle(1);
|
||||
expect(board.focalId).toBe(b.id);
|
||||
board.cycle(1);
|
||||
expect(board.focalId).toBe(a.id);
|
||||
});
|
||||
|
||||
it('cycles focal backward with wrap', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
const b = board.addPairing('Roboto', 'Merriweather');
|
||||
board.setFocal(a.id);
|
||||
board.cycle(-1);
|
||||
expect(board.focalId).toBe(b.id);
|
||||
});
|
||||
|
||||
it('empties the board and clears focal when the last pairing is removed', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
board.removePairing(a.id);
|
||||
expect(board.pairings).toEqual([]);
|
||||
expect(board.focalId).toBeNull();
|
||||
});
|
||||
|
||||
it('duplicates a pairing as a distinct card next to the source', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
const dup = board.duplicate(a.id);
|
||||
expect(dup.id).not.toBe(a.id);
|
||||
expect(dup.headerFontId).toBe('Inter');
|
||||
expect(board.pairings[1].id).toBe(dup.id);
|
||||
});
|
||||
|
||||
it('swaps one role on the focal pairing', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
board.swapFont(a.id, 'body', 'Merriweather');
|
||||
expect(board.focal?.bodyFontId).toBe('Merriweather');
|
||||
});
|
||||
|
||||
it('rewrites the shared specimen (global, not per-pairing)', () => {
|
||||
const board = getBoard();
|
||||
board.addPairing('Inter', 'Lora');
|
||||
board.setSpecimen('header', 'New Header');
|
||||
expect(board.specimen.header).toBe('New Header');
|
||||
});
|
||||
|
||||
it('keeps a focal when the focal pairing is removed', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
const b = board.addPairing('Roboto', 'Merriweather');
|
||||
board.setFocal(a.id);
|
||||
board.removePairing(a.id);
|
||||
expect(board.pairings).toHaveLength(1);
|
||||
expect(board.focalId).toBe(b.id);
|
||||
});
|
||||
|
||||
it('seeds curated pairings from the catalog when storage is empty', () => {
|
||||
const board = getBoard();
|
||||
flushSync(); // let the seed effect run
|
||||
expect(board.pairings.length).toBeGreaterThan(0);
|
||||
expect(board.focalId).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not seed when storage already has pairings', () => {
|
||||
// pre-seed storage so a fresh board rehydrates instead of seeding
|
||||
const first = getBoard();
|
||||
const p = first.addPairing('Inter', 'Lora');
|
||||
__resetBoard();
|
||||
const restored = getBoard();
|
||||
flushSync();
|
||||
expect(restored.pairings).toHaveLength(1);
|
||||
expect(restored.pairings[0].id).toBe(p.id);
|
||||
});
|
||||
|
||||
it('returns fallback 0 before warm, positive height once fonts resolve and warm', async () => {
|
||||
mockFonts.push({ id: 'Inter', name: 'Inter' }, { id: 'Lora', name: 'Lora' });
|
||||
const board = getBoard();
|
||||
const p = board.addPairing('Inter', 'Lora');
|
||||
// Cold: canvas not yet warm -> reserved fallback, never a cold measure.
|
||||
expect(board.frameHeight(p.id, 600)).toBe(0);
|
||||
await vi.waitFor(() => expect(board.measureReady).toBe(true));
|
||||
expect(board.frameHeight(p.id, 600)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('collects every distinct candidate font id for preloading', () => {
|
||||
const board = getBoard();
|
||||
board.addPairing('Inter', 'Lora');
|
||||
board.addPairing('Inter', 'Merriweather'); // Inter deduped
|
||||
expect(new Set(board.candidateFontIds)).toEqual(new Set(['Inter', 'Lora', 'Merriweather']));
|
||||
});
|
||||
|
||||
it('exposes default per-role typography', () => {
|
||||
const board = getBoard();
|
||||
expect(board.typo.header.size).toBeGreaterThan(0);
|
||||
expect(board.typo.header.weight).toBeGreaterThan(0);
|
||||
expect(board.typo.body.leading).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('sets one role typography independently via setTypo', () => {
|
||||
const board = getBoard();
|
||||
board.setTypo('header', { size: 64, weight: 700, leading: 1.1, tracking: -0.02 });
|
||||
expect(board.typo.header.size).toBe(64);
|
||||
expect(board.typo.header.weight).toBe(700);
|
||||
// body untouched
|
||||
expect(board.typo.body.size).not.toBe(64);
|
||||
});
|
||||
|
||||
it('persists and rehydrates pairings, focal, and specimen', () => {
|
||||
const board = getBoard();
|
||||
const a = board.addPairing('Inter', 'Lora');
|
||||
board.setSpecimen('body', 'Persisted body');
|
||||
__resetBoard();
|
||||
const restored = getBoard();
|
||||
expect(restored.pairings).toHaveLength(1);
|
||||
expect(restored.pairings[0].id).toBe(a.id);
|
||||
expect(restored.focalId).toBe(a.id);
|
||||
expect(restored.specimen.body).toBe('Persisted body');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* CompareBoard store — the board singleton.
|
||||
*
|
||||
* Owns the comparison board's business state: the ordered list of Pairings, the
|
||||
* single focal pairing, and the board-global specimen text (header + body).
|
||||
* Persists to localStorage as a compact, URL-encoding-friendly blob.
|
||||
*
|
||||
* Typography is NOT owned here as an AdjustTypography store (features can't
|
||||
* import sibling features). Instead the board holds plain per-role typography
|
||||
* values, fed in via `setTypo` by `widgets/Board` (dependency inversion).
|
||||
*
|
||||
* Font metadata is resolved + preloaded via the Font entity (candidate
|
||||
* preloading, focal pinning). Frame heights are Pretext-measured behind a
|
||||
* canvas warm-gate (the zero-shift core) and memoized for flicker-free cycling.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
type FontCatalogStore,
|
||||
type FontLifecycleManager,
|
||||
type FontLoadRequestConfig,
|
||||
FontsByIdsStore,
|
||||
type UnifiedFont,
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
getFontUrl,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
type Pairing,
|
||||
type Role,
|
||||
comboKey,
|
||||
createPairing,
|
||||
nextFocalId,
|
||||
} from '$entities/Pairing';
|
||||
import {
|
||||
combineFrameHeight,
|
||||
measureRoleHeight,
|
||||
} from '$features/CompareBoard/lib/measure';
|
||||
import {
|
||||
BOARD_SCHEMA_VERSION,
|
||||
BOARD_STORAGE_KEY,
|
||||
DEFAULT_SPECIMEN,
|
||||
FRAME_ROLE_GAP,
|
||||
} from '$features/CompareBoard/model/const/const';
|
||||
import {
|
||||
createPersistentStore,
|
||||
createSingleton,
|
||||
ensureCanvasFonts,
|
||||
getPretextFontString,
|
||||
} from '$shared/lib';
|
||||
import { prepareWithSegments } from '@chenglou/pretext';
|
||||
import {
|
||||
flushSync,
|
||||
untrack,
|
||||
} from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
/**
|
||||
* Compact persisted board shape. Font ids are abbreviated (`h`/`b`) to keep the
|
||||
* blob small and URL-encoding-friendly; specimen text is in localStorage but is
|
||||
* intentionally excluded from any future URL share.
|
||||
*/
|
||||
interface PersistedBoard {
|
||||
/**
|
||||
* Schema version (gates migrations / the future URL codec).
|
||||
*/
|
||||
v: number;
|
||||
/**
|
||||
* Pairings in board order: surrogate id + the two font ids.
|
||||
*/
|
||||
pairings: { id: string; h: string; b: string }[];
|
||||
/**
|
||||
* The focal pairing's id, or null when the board is empty.
|
||||
*/
|
||||
focalId: string | null;
|
||||
/**
|
||||
* Board-global specimen text.
|
||||
*/
|
||||
specimen: { header: string; body: string };
|
||||
}
|
||||
|
||||
const emptyBoard = (): PersistedBoard => ({
|
||||
v: BOARD_SCHEMA_VERSION,
|
||||
pairings: [],
|
||||
focalId: null,
|
||||
specimen: { ...DEFAULT_SPECIMEN },
|
||||
});
|
||||
|
||||
/**
|
||||
* Plain per-role typography values the board renders and measures with. Mirrors
|
||||
* the four axes an `AdjustTypography` store exposes, but as a framework-free
|
||||
* value shape the board owns — the inversion seam (`widgets/Board` pushes the
|
||||
* concrete store's values in via `setTypo`). Not persisted here: the
|
||||
* AdjustTypography stores own typography persistence.
|
||||
*/
|
||||
export interface RoleTypography {
|
||||
/**
|
||||
* Font size in px (honest, absolute — no responsive multiplier).
|
||||
*/
|
||||
size: number;
|
||||
/**
|
||||
* Numeric font weight (100–900).
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Unitless line-height multiplier.
|
||||
*/
|
||||
leading: number;
|
||||
/**
|
||||
* Letter spacing in px.
|
||||
*/
|
||||
tracking: number;
|
||||
}
|
||||
|
||||
const defaultRoleTypography = (): RoleTypography => ({
|
||||
size: DEFAULT_FONT_SIZE,
|
||||
weight: DEFAULT_FONT_WEIGHT,
|
||||
leading: DEFAULT_LINE_HEIGHT,
|
||||
tracking: DEFAULT_LETTER_SPACING,
|
||||
});
|
||||
|
||||
/**
|
||||
* Singleton board store. Pairings live as a reassigned `$state` array (ordered
|
||||
* cycling needs index order); mutations reassign so Svelte tracks them and
|
||||
* persist synchronously through the persistent store.
|
||||
*/
|
||||
export class BoardStore {
|
||||
/**
|
||||
* Ordered pairings on the board.
|
||||
*/
|
||||
#pairings = $state<Pairing[]>([]);
|
||||
/**
|
||||
* The focal pairing's id, or null when the board is empty.
|
||||
*/
|
||||
#focalId = $state<string | null>(null);
|
||||
/**
|
||||
* Board-global specimen text shared by every pairing.
|
||||
*/
|
||||
#specimen = $state<{ header: string; body: string }>({ ...DEFAULT_SPECIMEN });
|
||||
/**
|
||||
* Per-role typography, fed in by the widget from the AdjustTypography stores
|
||||
* (dependency-inversion seam). Read by font-loading and frame measurement.
|
||||
*/
|
||||
#typo = $state<{ header: RoleTypography; body: RoleTypography }>({
|
||||
header: defaultRoleTypography(),
|
||||
body: defaultRoleTypography(),
|
||||
});
|
||||
/**
|
||||
* localStorage-backed mirror of the board blob.
|
||||
*/
|
||||
#storage = createPersistentStore<PersistedBoard>(BOARD_STORAGE_KEY, emptyBoard());
|
||||
/**
|
||||
* Batch font-metadata resolver, kept in sync with `candidateFontIds`.
|
||||
*/
|
||||
#fontsByIds: FontsByIdsStore;
|
||||
/**
|
||||
* Font load/cache/eviction manager; pinned to keep on-screen fonts resident.
|
||||
*/
|
||||
#lifecycle: FontLifecycleManager;
|
||||
/**
|
||||
* Paginated font catalog — source of fonts for default seeding.
|
||||
*/
|
||||
#fontCatalog: FontCatalogStore;
|
||||
/**
|
||||
* One-shot guard: only seed a default board when storage was empty at
|
||||
* construction (never re-seed after the user empties the board).
|
||||
*/
|
||||
#shouldSeed: boolean;
|
||||
/**
|
||||
* Font strings whose canvas metrics are confirmed real (warm). Reactive
|
||||
* (SvelteSet) so a completed warm re-runs height readers. Gates `prepare()`
|
||||
* to avoid poisoning Pretext's cache with fallback widths.
|
||||
*/
|
||||
#warmed = new SvelteSet<string>();
|
||||
/**
|
||||
* Font strings with an in-flight `ensureCanvasFonts` — dedupes warm requests.
|
||||
*/
|
||||
#warming = new Set<string>();
|
||||
/**
|
||||
* Memoized frame heights keyed by (combo, width, specimen, typography), so
|
||||
* cycling back to a measured pairing is O(1) and never reflows.
|
||||
*/
|
||||
#heightCache = new Map<string, number>();
|
||||
/**
|
||||
* Last computed height per pairing — the reserved fallback returned while a
|
||||
* pairing's fonts load/warm, so the frame never collapses to 0 mid-cycle.
|
||||
*/
|
||||
#lastHeight = new Map<string, number>();
|
||||
/**
|
||||
* Disposes the constructor's $effect.root. Must run on teardown.
|
||||
*/
|
||||
#disposeEffects: () => void;
|
||||
|
||||
constructor() {
|
||||
const stored = this.#storage.value;
|
||||
this.#pairings = stored.pairings.map(p => createPairing(p.h, p.b, p.id));
|
||||
this.#focalId = stored.focalId;
|
||||
this.#specimen = { ...stored.specimen };
|
||||
this.#shouldSeed = stored.pairings.length === 0;
|
||||
|
||||
this.#lifecycle = getFontLifecycleManager();
|
||||
this.#fontCatalog = getFontCatalog();
|
||||
this.#fontsByIds = new FontsByIdsStore(this.candidateFontIds);
|
||||
|
||||
this.#disposeEffects = $effect.root(() => {
|
||||
// Seed a curated default board the first time the catalog is ready and
|
||||
// storage was empty — so the screen is never blank on first visit.
|
||||
$effect(() => {
|
||||
if (!this.#shouldSeed || this.#pairings.length > 0) {
|
||||
return;
|
||||
}
|
||||
const fonts = this.#fontCatalog.fonts;
|
||||
if (fonts.length < 2) {
|
||||
return;
|
||||
}
|
||||
untrack(() => {
|
||||
this.#shouldSeed = false;
|
||||
const count = Math.min(4, Math.floor(fonts.length / 2));
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.addPairing(fonts[i * 2].id, fonts[i * 2 + 1].id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Keep the batch query's id set in sync with the board's candidates.
|
||||
$effect(() => {
|
||||
this.#fontsByIds.setIds(this.candidateFontIds);
|
||||
});
|
||||
|
||||
// Preload every candidate font at its role weight (brief §Performance).
|
||||
$effect(() => {
|
||||
const configs = this.#candidateConfigs();
|
||||
if (configs.length > 0) {
|
||||
this.#lifecycle.touch(configs);
|
||||
}
|
||||
});
|
||||
|
||||
// Pin the focal pairing's fonts so eviction never drops on-screen
|
||||
// glyphs; unpin on focal/weight change via the cleanup return.
|
||||
$effect(() => {
|
||||
const focal = this.focal;
|
||||
if (!focal) {
|
||||
return;
|
||||
}
|
||||
const headerWeight = this.#typo.header.weight;
|
||||
const bodyWeight = this.#typo.body.weight;
|
||||
const header = this.fontById(focal.headerFontId);
|
||||
const body = this.fontById(focal.bodyFontId);
|
||||
if (header) {
|
||||
this.#lifecycle.pin(header.id, headerWeight, header.features?.isVariable);
|
||||
}
|
||||
if (body) {
|
||||
this.#lifecycle.pin(body.id, bodyWeight, body.features?.isVariable);
|
||||
}
|
||||
return () => {
|
||||
if (header) {
|
||||
this.#lifecycle.unpin(header.id, headerWeight, header.features?.isVariable);
|
||||
}
|
||||
if (body) {
|
||||
this.#lifecycle.unpin(body.id, bodyWeight, body.features?.isVariable);
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds dedup'd font-load configs for every resolvable candidate font at its
|
||||
* role weight (header fonts at header weight, body fonts at body weight).
|
||||
* Unresolved fonts (metadata not yet fetched) are skipped.
|
||||
*/
|
||||
#candidateConfigs(): FontLoadRequestConfig[] {
|
||||
const configs: FontLoadRequestConfig[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (fontId: string, weight: number) => {
|
||||
const font = this.fontById(fontId);
|
||||
if (!font) {
|
||||
return;
|
||||
}
|
||||
const url = getFontUrl(font, weight);
|
||||
if (!url || seen.has(url)) {
|
||||
return;
|
||||
}
|
||||
seen.add(url);
|
||||
configs.push({ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable });
|
||||
};
|
||||
for (const pairing of this.#pairings) {
|
||||
add(pairing.headerFontId, this.#typo.header.weight);
|
||||
add(pairing.bodyFontId, this.#typo.body.weight);
|
||||
}
|
||||
return configs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes current state back to the persistent store. The persistent store's
|
||||
* own effect flushes to localStorage; `destroy()` forces that flush so
|
||||
* synchronous rehydration (and test teardown) never loses a write.
|
||||
*/
|
||||
#persist() {
|
||||
this.#storage.value = {
|
||||
v: BOARD_SCHEMA_VERSION,
|
||||
pairings: this.#pairings.map(p => ({ id: p.id, h: p.headerFontId, b: p.bodyFontId })),
|
||||
focalId: this.#focalId,
|
||||
specimen: { ...this.#specimen },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All pairings in board order (reactive).
|
||||
*/
|
||||
get pairings(): readonly Pairing[] {
|
||||
return this.#pairings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The focal pairing's id, or null when empty (reactive).
|
||||
*/
|
||||
get focalId(): string | null {
|
||||
return this.#focalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The focal pairing, or undefined when empty (reactive).
|
||||
*/
|
||||
get focal(): Pairing | undefined {
|
||||
return this.#pairings.find(p => p.id === this.#focalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Board-global specimen text (reactive).
|
||||
*/
|
||||
get specimen(): { header: string; body: string } {
|
||||
return this.#specimen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-role typography values (reactive). Fed by the widget via `setTypo`.
|
||||
*/
|
||||
get typo(): { header: RoleTypography; body: RoleTypography } {
|
||||
return this.#typo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one role's typography values. Called by `widgets/Board` whenever
|
||||
* the corresponding AdjustTypography store changes (the inversion seam).
|
||||
*
|
||||
* @param role - Which role's typography to set.
|
||||
* @param values - The new typography values for that role.
|
||||
*/
|
||||
setTypo(role: Role, values: RoleTypography) {
|
||||
this.#typo = { ...this.#typo, [role]: { ...values } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every distinct font id referenced by any pairing (header or body). The
|
||||
* preload set — kept in sync with the batch font resolver.
|
||||
*/
|
||||
get candidateFontIds(): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const pairing of this.#pairings) {
|
||||
ids.add(pairing.headerFontId);
|
||||
ids.add(pairing.bodyFontId);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a font id to its loaded metadata, or undefined if not yet fetched.
|
||||
*
|
||||
* @param id - Font entity id.
|
||||
* @returns The font metadata, or undefined while loading.
|
||||
*/
|
||||
fontById(id: string): UnifiedFont | undefined {
|
||||
return this.#fontsByIds.fonts.find(f => f.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves both fonts of a pairing for the UI.
|
||||
*
|
||||
* @param pairing - The pairing to resolve.
|
||||
* @returns Header and body font metadata (each undefined while loading).
|
||||
*/
|
||||
resolvePairingFonts(pairing: Pairing): { header?: UnifiedFont; body?: UnifiedFont } {
|
||||
return {
|
||||
header: this.fontById(pairing.headerFontId),
|
||||
body: this.fontById(pairing.bodyFontId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The focal frame's measured height at the given content width.
|
||||
*
|
||||
* @param contentWidth - The frame's content width in px.
|
||||
* @returns Height in px (0 when the board is empty).
|
||||
*/
|
||||
focalFrameHeight(contentWidth: number): number {
|
||||
return this.#focalId ? this.frameHeight(this.#focalId, contentWidth) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-measures a (typically next-up) pairing so cycling to it never reflows.
|
||||
*
|
||||
* @param pairingId - The pairing to measure ahead of time.
|
||||
* @param contentWidth - The frame's content width in px.
|
||||
* @returns Height in px (fallback while fonts load/warm).
|
||||
*/
|
||||
peekFrameHeight(pairingId: string, contentWidth: number): number {
|
||||
return this.frameHeight(pairingId, contentWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measured height of a pairing's frame (header block + gap + body block) at a
|
||||
* content width, via Pretext's pure line-count arithmetic. Returns the
|
||||
* last-known height (or 0) until both fonts are resolved AND the canvas is
|
||||
* warm — never measures cold, which would poison Pretext's width cache
|
||||
* forever. Results are memoized per (combo, width, specimen, typography).
|
||||
*
|
||||
* @param pairingId - The pairing to measure.
|
||||
* @param contentWidth - The frame's content width in px.
|
||||
* @returns Height in px.
|
||||
*/
|
||||
frameHeight(pairingId: string, contentWidth: number): number {
|
||||
const pairing = this.#pairings.find(p => p.id === pairingId);
|
||||
if (!pairing) {
|
||||
return 0;
|
||||
}
|
||||
const { header, body } = this.resolvePairingFonts(pairing);
|
||||
const fallback = this.#lastHeight.get(pairingId) ?? 0;
|
||||
if (!header || !body) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const headerFont = getPretextFontString(this.#typo.header.weight, this.#typo.header.size, header.name);
|
||||
const bodyFont = getPretextFontString(this.#typo.body.weight, this.#typo.body.size, body.name);
|
||||
|
||||
this.#ensureWarm([headerFont, bodyFont]);
|
||||
// SvelteSet read is reactive: a completed warm re-runs height readers.
|
||||
if (!this.#warmed.has(headerFont) || !this.#warmed.has(bodyFont)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const key = `${comboKey(pairing)}|${contentWidth}|${this.#specimen.header}|${this.#specimen.body}|`
|
||||
+ this.#typoSignature();
|
||||
const cached = this.#heightCache.get(key);
|
||||
if (cached !== undefined) {
|
||||
this.#lastHeight.set(pairingId, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const headerHeight = measureRoleHeight({
|
||||
prepared: prepareWithSegments(this.#specimen.header, headerFont, {
|
||||
letterSpacing: this.#typo.header.tracking,
|
||||
}),
|
||||
maxWidth: contentWidth,
|
||||
sizePx: this.#typo.header.size,
|
||||
lineHeight: this.#typo.header.leading,
|
||||
});
|
||||
const bodyHeight = measureRoleHeight({
|
||||
prepared: prepareWithSegments(this.#specimen.body, bodyFont, {
|
||||
letterSpacing: this.#typo.body.tracking,
|
||||
}),
|
||||
maxWidth: contentWidth,
|
||||
sizePx: this.#typo.body.size,
|
||||
lineHeight: this.#typo.body.leading,
|
||||
});
|
||||
const height = combineFrameHeight({ headerHeight, bodyHeight, gap: FRAME_ROLE_GAP });
|
||||
this.#heightCache.set(key, height);
|
||||
this.#lastHeight.set(pairingId, height);
|
||||
return height;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the focal pairing's fonts are resolved and canvas-warm — the UI
|
||||
* gates the first paint of the focal frame on this to avoid a cold-measure
|
||||
* flash.
|
||||
*/
|
||||
get measureReady(): boolean {
|
||||
const focal = this.focal;
|
||||
if (!focal) {
|
||||
return false;
|
||||
}
|
||||
const { header, body } = this.resolvePairingFonts(focal);
|
||||
if (!header || !body) {
|
||||
return false;
|
||||
}
|
||||
const headerFont = getPretextFontString(this.#typo.header.weight, this.#typo.header.size, header.name);
|
||||
const bodyFont = getPretextFontString(this.#typo.body.weight, this.#typo.body.size, body.name);
|
||||
return this.#warmed.has(headerFont) && this.#warmed.has(bodyFont);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kicks off canvas warming for any cold font strings (dedup'd). Fire-and-
|
||||
* forget: on resolution the strings join `#warmed`, re-running height readers.
|
||||
*/
|
||||
#ensureWarm(fontStrings: string[]) {
|
||||
const cold = fontStrings.filter(s => !this.#warmed.has(s) && !this.#warming.has(s));
|
||||
if (cold.length === 0) {
|
||||
return;
|
||||
}
|
||||
cold.forEach(s => this.#warming.add(s));
|
||||
void ensureCanvasFonts(cold)
|
||||
.then(() => {
|
||||
cold.forEach(s => {
|
||||
this.#warming.delete(s);
|
||||
this.#warmed.add(s);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
cold.forEach(s => this.#warming.delete(s));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature of both roles' typography, for the height memo key.
|
||||
*/
|
||||
#typoSignature(): string {
|
||||
const h = this.#typo.header;
|
||||
const b = this.#typo.body;
|
||||
return `${h.size},${h.weight},${h.leading},${h.tracking};${b.size},${b.weight},${b.leading},${b.tracking}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a pairing to the end of the board. The first pairing added becomes
|
||||
* focal.
|
||||
*
|
||||
* @param headerFontId - Font id for the header role.
|
||||
* @param bodyFontId - Font id for the body role.
|
||||
* @returns The created pairing.
|
||||
*/
|
||||
addPairing(headerFontId: string, bodyFontId: string): Pairing {
|
||||
const pairing = createPairing(headerFontId, bodyFontId);
|
||||
this.#pairings = [...this.#pairings, pairing];
|
||||
if (this.#focalId === null) {
|
||||
this.#focalId = pairing.id;
|
||||
}
|
||||
this.#persist();
|
||||
return pairing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones a pairing as a distinct card inserted directly after the source, and
|
||||
* makes the clone focal so the user can immediately swap one side.
|
||||
*
|
||||
* @param id - Source pairing id.
|
||||
* @returns The new pairing.
|
||||
*/
|
||||
duplicate(id: string): Pairing {
|
||||
const index = this.#pairings.findIndex(p => p.id === id);
|
||||
const source = this.#pairings[index];
|
||||
const dup = createPairing(source.headerFontId, source.bodyFontId);
|
||||
this.#pairings = [
|
||||
...this.#pairings.slice(0, index + 1),
|
||||
dup,
|
||||
...this.#pairings.slice(index + 1),
|
||||
];
|
||||
this.#focalId = dup.id;
|
||||
this.#persist();
|
||||
return dup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a pairing. If the removed pairing was focal, focal moves to a
|
||||
* neighbour so exactly one focal always exists on a non-empty board.
|
||||
*
|
||||
* @param id - Pairing id to remove.
|
||||
*/
|
||||
removePairing(id: string) {
|
||||
let nextFocal = this.#focalId;
|
||||
if (this.#focalId === id) {
|
||||
// Pick a neighbour from the still-full ordered list; if the only
|
||||
// candidate is the one being removed, the board becomes empty.
|
||||
const candidate = nextFocalId(this.#pairings.map(p => p.id), id, 1);
|
||||
nextFocal = candidate === id ? null : candidate;
|
||||
}
|
||||
this.#pairings = this.#pairings.filter(p => p.id !== id);
|
||||
this.#focalId = nextFocal;
|
||||
this.#persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the focal pairing.
|
||||
*
|
||||
* @param id - Pairing id to focus.
|
||||
*/
|
||||
setFocal(id: string) {
|
||||
this.#focalId = id;
|
||||
this.#persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps focal one pairing in board order, wrapping at both ends.
|
||||
*
|
||||
* @param direction - +1 for next, -1 for previous.
|
||||
*/
|
||||
cycle(direction: 1 | -1) {
|
||||
if (this.#focalId === null) {
|
||||
return;
|
||||
}
|
||||
const next = nextFocalId(this.#pairings.map(p => p.id), this.#focalId, direction);
|
||||
if (next !== null) {
|
||||
this.#focalId = next;
|
||||
this.#persist();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the font filling one role of a pairing.
|
||||
*
|
||||
* @param id - Pairing id.
|
||||
* @param role - Which role to swap.
|
||||
* @param fontId - New font id for that role.
|
||||
*/
|
||||
swapFont(id: string, role: Role, fontId: string) {
|
||||
this.#pairings = this.#pairings.map(p => {
|
||||
if (p.id !== id) {
|
||||
return p;
|
||||
}
|
||||
return role === 'header' ? { ...p, headerFontId: fontId } : { ...p, bodyFontId: fontId };
|
||||
});
|
||||
this.#persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the board-global specimen for a role.
|
||||
*
|
||||
* @param role - Which role's text to set.
|
||||
* @param text - New specimen text.
|
||||
*/
|
||||
setSpecimen(role: Role, text: string) {
|
||||
this.#specimen = { ...this.#specimen, [role]: text };
|
||||
this.#persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes the pending persist write, then disposes the persistent store.
|
||||
* Call on teardown.
|
||||
*/
|
||||
destroy() {
|
||||
flushSync();
|
||||
this.#disposeEffects();
|
||||
this.#fontsByIds.destroy();
|
||||
this.#storage.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const board = createSingleton(
|
||||
() => new BoardStore(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getBoard = board.get;
|
||||
|
||||
// test-only reset, so specs don't share live state or persisted blobs
|
||||
export const __resetBoard = board.reset;
|
||||
@@ -1 +0,0 @@
|
||||
export { FontSampler } from './ui';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user