Compare commits
107 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 | |||
| 7ddf232e3a | |||
| b3bc40b76c | |||
| 839460726e | |||
| 6877807aaf | |||
| 3dca11fea8 | |||
| 0b675635b3 | |||
| 9780ff9358 | |||
| 1ad015aed6 | |||
| 10603d18bf | |||
| 39d1ce4c37 | |||
| fcd61be4fa | |||
| 28a8e49915 | |||
| 43e8507144 | |||
| 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,3 +1,4 @@
|
|||||||
|
import { windowSizeForLine } from '../src/entities/Font/domain/windowSizeForLine/windowSizeForLine';
|
||||||
import {
|
import {
|
||||||
expect,
|
expect,
|
||||||
test,
|
test,
|
||||||
@@ -5,12 +6,22 @@ import {
|
|||||||
|
|
||||||
test.describe('preview text', () => {
|
test.describe('preview text', () => {
|
||||||
test('drives the slider character rendering', async ({ comparison }) => {
|
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.pickPair('Inter', 'Roboto');
|
||||||
await comparison.setPreviewText('Sphinx');
|
await comparison.setPreviewText(text);
|
||||||
|
|
||||||
// Each grapheme renders as a `.char-wrap` cell in the slider once
|
// Window chars render as `.char-wrap` cells for crossfade. The window
|
||||||
// both fonts are loaded. Six glyphs → six cells.
|
// size is a pure function of the line's grapheme count — assert against
|
||||||
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(6);
|
// 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 }) => {
|
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",
|
"version": "0.0.1",
|
||||||
"packageManager": "yarn@4.11.0",
|
"packageManager": "yarn@4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"sideEffects": [
|
||||||
|
"*.css",
|
||||||
|
"**/router.ts"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -44,7 +48,6 @@
|
|||||||
"@types/jsdom": "28.0.1",
|
"@types/jsdom": "28.0.1",
|
||||||
"@vitest/browser-playwright": "4.1.5",
|
"@vitest/browser-playwright": "4.1.5",
|
||||||
"@vitest/coverage-v8": "4.1.5",
|
"@vitest/coverage-v8": "4.1.5",
|
||||||
"bits-ui": "2.18.1",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dprint": "0.54.0",
|
"dprint": "0.54.0",
|
||||||
"jsdom": "29.1.1",
|
"jsdom": "29.1.1",
|
||||||
@@ -66,6 +69,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chenglou/pretext": "0.0.6",
|
"@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
|
* 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.
|
* layout shell. This is the root component mounted by the application.
|
||||||
*
|
*
|
||||||
* Structure:
|
* Structure:
|
||||||
* - QueryProvider provides TanStack Query client for data fetching
|
* - QueryProvider provides TanStack Query client for data fetching
|
||||||
* - Layout provides sidebar, header/footer, and page container
|
* - 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 '$routes/router';
|
||||||
import { QueryProvider } from './providers';
|
import { Router } from 'sv-router';
|
||||||
|
import {
|
||||||
|
AppBindingsProvider,
|
||||||
|
QueryProvider,
|
||||||
|
} from './providers';
|
||||||
import Layout from './ui/Layout.svelte';
|
import Layout from './ui/Layout.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<Layout>
|
<AppBindingsProvider>
|
||||||
<Page />
|
<Layout>
|
||||||
</Layout>
|
<Router />
|
||||||
|
</Layout>
|
||||||
|
</AppBindingsProvider>
|
||||||
</QueryProvider>
|
</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.
|
descendants of this provider.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
@@ -18,6 +18,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
// First call to the lazy singleton — constructs the shared client for the app.
|
||||||
|
const queryClient = getQueryClient();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export { default as AppBindingsProvider } from './AppBindings.svelte';
|
||||||
export { default as QueryProvider } from './QueryProvider.svelte';
|
export { default as QueryProvider } from './QueryProvider.svelte';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Application shell with providers and page wrapper
|
Application shell with providers and page wrapper
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { themeManager } from '$features/ChangeAppTheme';
|
import { getThemeManager } from '$features/ChangeAppTheme';
|
||||||
import G from '$shared/assets/G.svg';
|
import G from '$shared/assets/G.svg';
|
||||||
import { ResponsiveProvider } from '$shared/lib';
|
import { ResponsiveProvider } from '$shared/lib';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
@@ -32,6 +32,8 @@ interface Props {
|
|||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
let fontsReady = $state(true);
|
let fontsReady = $state(true);
|
||||||
|
|
||||||
|
const themeManager = getThemeManager();
|
||||||
const theme = $derived(themeManager.value);
|
const theme = $derived(themeManager.value);
|
||||||
|
|
||||||
onMount(() => themeManager.init());
|
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 { 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 { fontKeys } from '$shared/api/queryKeys';
|
||||||
import { FontResponseError } from '../../lib/errors/errors';
|
import { FontResponseError } from '../../lib/errors/errors';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '$shared/api/api';
|
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 { fontKeys } from '$shared/api/queryKeys';
|
||||||
import { buildQueryString } from '$shared/lib/utils';
|
import { buildQueryString } from '$shared/lib/utils';
|
||||||
import type { QueryParams } 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 {
|
export function seedFontCache(fonts: UnifiedFont[]): void {
|
||||||
fonts.forEach(font => {
|
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 {
|
||||||
export * from './lib';
|
computeLineRenderModel,
|
||||||
export * from './model';
|
DualFontLayout,
|
||||||
export * from './ui';
|
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.
|
* Thrown when the network request to the proxy API fails.
|
||||||
|
|||||||
@@ -1,49 +1,5 @@
|
|||||||
export { getFontUrl } from './getFontUrl/getFontUrl';
|
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 {
|
export {
|
||||||
FontNetworkError,
|
FontNetworkError,
|
||||||
FontResponseError,
|
FontResponseError,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ vi.mock('@chenglou/pretext', async () => {
|
|||||||
layout: vi.fn(actual.layout),
|
layout: vi.fn(actual.layout),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
import { mockUnifiedFont } from '$entities/Font/testing';
|
||||||
import {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
describe,
|
describe,
|
||||||
@@ -22,7 +23,6 @@ import {
|
|||||||
vi,
|
vi,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import type { FontLoadStatus } from '../../model/types';
|
import type { FontLoadStatus } from '../../model/types';
|
||||||
import { mockUnifiedFont } from '../mocks';
|
|
||||||
import { createFontRowSizeResolver } from './createFontRowSizeResolver';
|
import { createFontRowSizeResolver } from './createFontRowSizeResolver';
|
||||||
|
|
||||||
// Fixed-width canvas mock: every character is 10px wide regardless of font.
|
// 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
|
* Font size constants
|
||||||
*/
|
*/
|
||||||
@@ -33,60 +30,6 @@ export const MIN_LETTER_SPACING = -0.1;
|
|||||||
export const MAX_LETTER_SPACING = 0.5;
|
export const MAX_LETTER_SPACING = 0.5;
|
||||||
export const LETTER_SPACING_STEP = 0.01;
|
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.
|
* Index value for items not yet loaded in a virtualized list.
|
||||||
* Treated as being at the very bottom of the infinite scroll.
|
* Treated as being at the very bottom of the infinite scroll.
|
||||||
|
|||||||
@@ -1,3 +1,51 @@
|
|||||||
export * from './const/const';
|
export {
|
||||||
export * from './store';
|
DEFAULT_FONT_SIZE,
|
||||||
export * from './types';
|
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 { flushSync } from 'svelte';
|
||||||
import {
|
import {
|
||||||
afterEach,
|
afterEach,
|
||||||
@@ -12,27 +15,33 @@ import {
|
|||||||
FontNetworkError,
|
FontNetworkError,
|
||||||
FontResponseError,
|
FontResponseError,
|
||||||
} from '../../../lib/errors/errors';
|
} from '../../../lib/errors/errors';
|
||||||
import {
|
|
||||||
generateMixedCategoryFonts,
|
|
||||||
generateMockFonts,
|
|
||||||
} from '../../../lib/mocks/fonts.mock';
|
|
||||||
import type { UnifiedFont } from '../../types';
|
import type { UnifiedFont } from '../../types';
|
||||||
import { FontCatalogStore } from './fontCatalogStore.svelte';
|
import { FontCatalogStore } from './fontCatalogStore.svelte';
|
||||||
|
|
||||||
vi.mock('$shared/api/queryClient', async importOriginal => {
|
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 actual = await importOriginal<typeof import('$shared/api/queryClient')>();
|
||||||
|
const mockClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
queryClient: new QueryClient({
|
getQueryClient: () => mockClient,
|
||||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||||
|
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { getQueryClient } from '$shared/api/queryClient';
|
||||||
import { fetchProxyFonts } from '../../../api';
|
import { fetchProxyFonts } from '../../../api';
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_QUERY_GC_TIME_MS,
|
DEFAULT_QUERY_GC_TIME_MS,
|
||||||
DEFAULT_QUERY_STALE_TIME_MS,
|
DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
queryClient,
|
getQueryClient,
|
||||||
} from '$shared/api/queryClient';
|
} from '$shared/api/queryClient';
|
||||||
|
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||||
import {
|
import {
|
||||||
type InfiniteData,
|
type InfiniteData,
|
||||||
InfiniteQueryObserver,
|
InfiniteQueryObserver,
|
||||||
@@ -46,7 +47,7 @@ export class FontCatalogStore {
|
|||||||
readonly unknown[],
|
readonly unknown[],
|
||||||
PageParam
|
PageParam
|
||||||
>;
|
>;
|
||||||
#qc = queryClient;
|
#qc = getQueryClient();
|
||||||
#unsubscribe: () => void;
|
#unsubscribe: () => void;
|
||||||
|
|
||||||
constructor(params: FontStoreParams = {}) {
|
constructor(params: FontStoreParams = {}) {
|
||||||
@@ -483,8 +484,12 @@ export class FontCatalogStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
|
const catalog = createSingleton(
|
||||||
return new FontCatalogStore(params);
|
() => 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 { SvelteMap } from 'svelte/reactivity';
|
||||||
import {
|
import {
|
||||||
type FontLoadRequestConfig,
|
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 () => {
|
it('throws FontParseError when font.load() rejects', async () => {
|
||||||
const loadError = new Error('parse failed');
|
const loadError = new Error('parse failed');
|
||||||
const MockFontFace = vi.fn(
|
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);
|
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 {
|
import {
|
||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
seedFontCache,
|
seedFontCache,
|
||||||
} from '$entities/Font/api/proxy/proxyFonts';
|
} from '../../../api/proxy/proxyFonts';
|
||||||
import {
|
import {
|
||||||
FontNetworkError,
|
FontNetworkError,
|
||||||
FontResponseError,
|
FontResponseError,
|
||||||
} from '$entities/Font/lib/errors/errors';
|
} from '../../../lib/errors/errors';
|
||||||
import type { UnifiedFont } from '$entities/Font/model/types';
|
import type { UnifiedFont } from '../../types';
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
|
||||||
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal fetcher that seeds the cache and handles error wrapping.
|
* 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 { getQueryClient } from '$shared/api/queryClient';
|
||||||
import {
|
|
||||||
FontNetworkError,
|
const queryClient = getQueryClient();
|
||||||
FontResponseError,
|
|
||||||
} from '$entities/Font/lib/errors/errors';
|
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
@@ -12,6 +9,11 @@ import {
|
|||||||
it,
|
it,
|
||||||
vi,
|
vi,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
import * as api from '../../../api/proxy/proxyFonts';
|
||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '../../../lib/errors/errors';
|
||||||
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
|
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
|
||||||
|
|
||||||
describe('FontsByIdsStore', () => {
|
describe('FontsByIdsStore', () => {
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
// Font lifecycle manager (browser-side load + cache + eviction)
|
// Font lifecycle manager (browser-side load + cache + eviction)
|
||||||
export * from './fontLifecycleManager/fontLifecycleManager.svelte';
|
export {
|
||||||
|
__resetFontLifecycleManager,
|
||||||
|
FontLifecycleManager,
|
||||||
|
getFontLifecycleManager,
|
||||||
|
} from './fontLifecycleManager/fontLifecycleManager.svelte';
|
||||||
|
|
||||||
// Paginated catalog
|
// Paginated catalog
|
||||||
export {
|
export { getFontCatalog } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||||
createFontCatalogStore,
|
export type { FontCatalogStore } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||||
FontCatalogStore,
|
|
||||||
fontCatalogStore,
|
// Batch fetch by IDs (detail-cache seeding)
|
||||||
} from './fontCatalogStore/fontCatalogStore.svelte';
|
export { FontsByIdsStore } from './fontsByIdsStore/fontsByIdsStore.svelte';
|
||||||
|
|||||||
@@ -23,5 +23,7 @@ export type {
|
|||||||
FontCollectionState,
|
FontCollectionState,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
|
||||||
export * from './store/fontLifecycle';
|
export type {
|
||||||
export * from './typography';
|
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 fixtures.
|
||||||
* MOCK FONT DATA
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Factory functions and preset mock data for fonts.
|
|
||||||
* Used in Storybook stories, tests, and development.
|
* Used in Storybook stories, tests, and development.
|
||||||
*
|
*
|
||||||
* ## Usage
|
* ## Usage
|
||||||
@@ -16,7 +12,7 @@
|
|||||||
* GOOGLE_FONTS,
|
* GOOGLE_FONTS,
|
||||||
* FONTHARE_FONTS,
|
* FONTHARE_FONTS,
|
||||||
* UNIFIED_FONTS,
|
* UNIFIED_FONTS,
|
||||||
* } from '$entities/Font/lib/mocks';
|
* } from '$entities/Font/testing';
|
||||||
*
|
*
|
||||||
* // Create a mock Google Font
|
* // Create a mock Google Font
|
||||||
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
|
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
|
||||||
@@ -28,7 +24,7 @@
|
|||||||
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
|
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
|
||||||
*
|
*
|
||||||
* // Use preset fonts
|
* // 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.
|
* Comprehensive mock data for Storybook stories, tests, and development.
|
||||||
*
|
*
|
||||||
* ## Quick Start
|
* ## Quick Start
|
||||||
@@ -13,7 +10,7 @@
|
|||||||
* UNIFIED_FONTS,
|
* UNIFIED_FONTS,
|
||||||
* MOCK_FILTERS,
|
* MOCK_FILTERS,
|
||||||
* createMockFontStoreState,
|
* createMockFontStoreState,
|
||||||
* } from '$entities/Font/lib/mocks';
|
* } from '$entities/Font/testing';
|
||||||
*
|
*
|
||||||
* // Use in stories
|
* // Use in stories
|
||||||
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
|
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
|
||||||
+2
-6
@@ -8,7 +8,7 @@
|
|||||||
* import {
|
* import {
|
||||||
* createMockQueryState,
|
* createMockQueryState,
|
||||||
* MOCK_STORES,
|
* MOCK_STORES,
|
||||||
* } from '$entities/Font/lib/mocks';
|
* } from '$entities/Font/testing';
|
||||||
*
|
*
|
||||||
* // Create a mock query state
|
* // Create a mock query state
|
||||||
* const loadingState = createMockQueryState({ status: 'pending' });
|
* const loadingState = createMockQueryState({ status: 'pending' });
|
||||||
@@ -21,11 +21,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { UnifiedFont } from '$entities/Font/model/types';
|
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||||
import type {
|
import type { QueryStatus } from '@tanstack/svelte-query';
|
||||||
QueryKey,
|
|
||||||
QueryObserverResult,
|
|
||||||
QueryStatus,
|
|
||||||
} from '@tanstack/svelte-query';
|
|
||||||
import {
|
import {
|
||||||
UNIFIED_FONTS,
|
UNIFIED_FONTS,
|
||||||
generateMockFonts,
|
generateMockFonts,
|
||||||
@@ -10,20 +10,20 @@ const { Story } = defineMeta({
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
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 },
|
story: { inline: false },
|
||||||
},
|
},
|
||||||
layout: 'centered',
|
layout: 'centered',
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
weight: { control: 'number' },
|
status: { control: 'select', options: ['loading', 'loaded', 'error'] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { mockUnifiedFont } from '$entities/Font/lib/mocks';
|
import { mockUnifiedFont } from '$entities/Font/testing';
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
const fontUnknown = mockUnifiedFont({ id: 'nonexistent-font-xk92z', name: 'Nonexistent Font Xk92z' });
|
const fontUnknown = mockUnifiedFont({ id: 'nonexistent-font-xk92z', name: 'Nonexistent Font Xk92z' });
|
||||||
@@ -39,11 +39,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
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>)}
|
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||||
<FontApplicator {...args}>
|
<FontApplicator {...args}>
|
||||||
@@ -58,11 +58,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
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>)}
|
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||||
<FontApplicator {...args}>
|
<FontApplicator {...args}>
|
||||||
@@ -72,16 +72,16 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
name="Custom Weight"
|
name="Error State"
|
||||||
parameters={{
|
parameters={{
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
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>)}
|
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||||
<FontApplicator {...args}>
|
<FontApplicator {...args}>
|
||||||
|
|||||||
@@ -6,11 +6,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import {
|
import type {
|
||||||
DEFAULT_FONT_WEIGHT,
|
FontLoadStatus,
|
||||||
type UnifiedFont,
|
UnifiedFont,
|
||||||
fontLifecycleManager,
|
} from '../../model/types';
|
||||||
} from '../../model';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -18,10 +17,13 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
font: UnifiedFont;
|
font: UnifiedFont;
|
||||||
/**
|
/**
|
||||||
* Font weight
|
* Current load status for this font, supplied by the composing layer.
|
||||||
* @default 400
|
* 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
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
@@ -39,20 +41,12 @@ interface Props {
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
font,
|
font,
|
||||||
weight = DEFAULT_FONT_WEIGHT,
|
status,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
skeleton,
|
skeleton,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const status = $derived(
|
|
||||||
fontLifecycleManager.getFontStatus(
|
|
||||||
font.id,
|
|
||||||
weight,
|
|
||||||
font.features?.isVariable,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+19
-2
@@ -4,7 +4,7 @@ import { defineMeta } from '@storybook/addon-svelte-csf';
|
|||||||
import FontSampler from './FontSampler.svelte';
|
import FontSampler from './FontSampler.svelte';
|
||||||
|
|
||||||
const { Story } = defineMeta({
|
const { Story } = defineMeta({
|
||||||
title: 'Features/FontSampler',
|
title: 'Entities/Font/FontSampler',
|
||||||
component: FontSampler,
|
component: FontSampler,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -21,6 +21,11 @@ const { Story } = defineMeta({
|
|||||||
control: 'object',
|
control: 'object',
|
||||||
description: 'Font information 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: {
|
text: {
|
||||||
control: 'text',
|
control: 'text',
|
||||||
description: 'Editable sample text (two-way bindable)',
|
description: 'Editable sample text (two-way bindable)',
|
||||||
@@ -34,8 +39,8 @@ const { Story } = defineMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { UnifiedFont } from '$entities/Font';
|
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
|
import type { UnifiedFont } from '../../model/types';
|
||||||
|
|
||||||
// Mock fonts for testing
|
// Mock fonts for testing
|
||||||
const mockArial: UnifiedFont = {
|
const mockArial: UnifiedFont = {
|
||||||
@@ -79,14 +84,24 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
isVariable: false,
|
isVariable: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Stand-in for the AdjustTypography store the composing widget injects.
|
||||||
|
const mockTypography = {
|
||||||
|
renderedSize: 48,
|
||||||
|
weight: 400,
|
||||||
|
height: 1.5,
|
||||||
|
spacing: 0,
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
name="Default"
|
name="Default"
|
||||||
args={{
|
args={{
|
||||||
font: mockArial,
|
font: mockArial,
|
||||||
|
status: 'loaded',
|
||||||
text: 'The quick brown fox jumps over the lazy dog',
|
text: 'The quick brown fox jumps over the lazy dog',
|
||||||
index: 0,
|
index: 0,
|
||||||
|
typography: mockTypography,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||||
@@ -101,9 +116,11 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
name="Long Text"
|
name="Long Text"
|
||||||
args={{
|
args={{
|
||||||
font: mockGeorgia,
|
font: mockGeorgia,
|
||||||
|
status: 'loaded',
|
||||||
text:
|
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.',
|
'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,
|
index: 1,
|
||||||
|
typography: mockTypography,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||||
+54
-24
@@ -4,11 +4,6 @@
|
|||||||
Visual design matches FontCard: sharp corners, red hover accent, header stats.
|
Visual design matches FontCard: sharp corners, red hover accent, header stats.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
|
||||||
FontApplicator,
|
|
||||||
type UnifiedFont,
|
|
||||||
} from '$entities/Font';
|
|
||||||
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
@@ -17,12 +12,47 @@ import {
|
|||||||
Stat,
|
Stat,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import { fly } from 'svelte/transition';
|
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 {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Font info
|
* Font info
|
||||||
*/
|
*/
|
||||||
font: UnifiedFont;
|
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
|
* Sample text
|
||||||
*/
|
*/
|
||||||
@@ -32,12 +62,15 @@ interface Props {
|
|||||||
* @default 0
|
* @default 0
|
||||||
*/
|
*/
|
||||||
index?: number;
|
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();
|
let { font, status, text = $bindable(), index = 0, typography }: Props = $props();
|
||||||
|
|
||||||
// Adjust the property name to match your UnifiedFont type
|
|
||||||
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
|
|
||||||
|
|
||||||
// Extract provider badge with fallback
|
// Extract provider badge with fallback
|
||||||
const providerBadge = $derived(
|
const providerBadge = $derived(
|
||||||
@@ -46,10 +79,10 @@ const providerBadge = $derived(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const stats = $derived([
|
const stats = $derived([
|
||||||
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
|
{ label: 'SZ', value: `${typography.renderedSize}PX` },
|
||||||
{ label: 'WGT', value: `${typographySettingsStore.weight}` },
|
{ label: 'WGT', value: `${typography.weight}` },
|
||||||
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
|
{ label: 'LH', value: typography.height.toFixed(2) },
|
||||||
{ label: 'LTR', value: `${typographySettingsStore.spacing}` },
|
{ label: 'LTR', value: `${typography.spacing}` },
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -67,9 +100,8 @@ const stats = $derived([
|
|||||||
min-h-60
|
min-h-60
|
||||||
rounded-none
|
rounded-none
|
||||||
"
|
"
|
||||||
style:font-weight={typographySettingsStore.weight}
|
style:font-weight={typography.weight}
|
||||||
>
|
>
|
||||||
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
flex items-center justify-between
|
flex items-center justify-between
|
||||||
@@ -91,9 +123,9 @@ const stats = $derived([
|
|||||||
{font.name}
|
{font.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{#if fontType}
|
{#if font?.category}
|
||||||
<Badge size="xs" variant="default" nowrap>
|
<Badge size="xs" variant="default" nowrap>
|
||||||
{fontType}
|
{font?.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -130,19 +162,18 @@ const stats = $derived([
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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
|
<ContentEditable
|
||||||
bind:text
|
bind:text
|
||||||
fontSize={typographySettingsStore.renderedSize}
|
fontSize={typography.renderedSize}
|
||||||
lineHeight={typographySettingsStore.height}
|
lineHeight={typography.height}
|
||||||
letterSpacing={typographySettingsStore.spacing}
|
letterSpacing={typography.spacing}
|
||||||
/>
|
/>
|
||||||
</FontApplicator>
|
</FontApplicator>
|
||||||
</div>
|
</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">
|
<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}
|
{#each stats as stat, i}
|
||||||
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
||||||
@@ -154,7 +185,6 @@ const stats = $derived([
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Red hover line ─────────────────────────────────────────────── -->
|
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
absolute bottom-0 left-0 right-0
|
absolute bottom-0 left-0 right-0
|
||||||
@@ -5,21 +5,18 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { debounce } from '$shared/lib/utils';
|
import { debounce } from '$shared/lib/utils';
|
||||||
import {
|
import { VirtualList } from '$shared/ui';
|
||||||
Skeleton,
|
|
||||||
VirtualList,
|
|
||||||
} from '$shared/ui';
|
|
||||||
import type {
|
import type {
|
||||||
ComponentProps,
|
ComponentProps,
|
||||||
Snippet,
|
Snippet,
|
||||||
} from 'svelte';
|
} from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { getFontUrl } from '../../lib';
|
import { createFontLoadRequestContfig } from '../../lib/createFontLoadRequestContfig/createFontLoadRequestContfig';
|
||||||
import {
|
import {
|
||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
fontCatalogStore,
|
getFontCatalog,
|
||||||
fontLifecycleManager,
|
getFontLifecycleManager,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props extends
|
interface Props extends
|
||||||
@@ -55,17 +52,28 @@ let {
|
|||||||
...rest
|
...rest
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const isLoading = $derived(
|
const fontCatalog = getFontCatalog();
|
||||||
fontCatalogStore.isFetching || fontCatalogStore.isLoading,
|
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 visibleFonts = $state<UnifiedFont[]>([]);
|
||||||
let isCatchingUp = $state(false);
|
let isCatchingUp = $state<boolean>(false);
|
||||||
|
|
||||||
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0);
|
const showInitialSkeleton = $derived.by(() => (
|
||||||
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
|
!!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.
|
// 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[]) {
|
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||||
visibleFonts = items;
|
visibleFonts = items;
|
||||||
@@ -79,12 +87,12 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
|
|||||||
* font files for thousands of intermediate fonts.
|
* font files for thousands of intermediate fonts.
|
||||||
*/
|
*/
|
||||||
async function handleJump(targetIndex: number) {
|
async function handleJump(targetIndex: number) {
|
||||||
if (isCatchingUp || !fontCatalogStore.pagination.hasMore) {
|
if (isCatchingUp || !hasMore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isCatchingUp = true;
|
isCatchingUp = true;
|
||||||
try {
|
try {
|
||||||
await fontCatalogStore.fetchAllPagesTo(targetIndex);
|
await fontCatalog.fetchAllPagesTo(targetIndex);
|
||||||
} finally {
|
} finally {
|
||||||
isCatchingUp = false;
|
isCatchingUp = false;
|
||||||
}
|
}
|
||||||
@@ -105,13 +113,7 @@ $effect(() => {
|
|||||||
if (isCatchingUp) {
|
if (isCatchingUp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
|
const configs = visibleFonts.flatMap(item => createFontLoadRequestContfig(item, weight));
|
||||||
const url = getFontUrl(item, weight);
|
|
||||||
if (!url) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
|
|
||||||
});
|
|
||||||
if (configs.length > 0) {
|
if (configs.length > 0) {
|
||||||
debouncedTouch(configs);
|
debouncedTouch(configs);
|
||||||
}
|
}
|
||||||
@@ -137,13 +139,11 @@ $effect(() => {
|
|||||||
* Load more fonts by moving to the next page
|
* Load more fonts by moving to the next page
|
||||||
*/
|
*/
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
if (
|
if (!hasMore || isFetching) {
|
||||||
!fontCatalogStore.pagination.hasMore
|
|
||||||
|| fontCatalogStore.isFetching
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fontCatalogStore.nextPage();
|
|
||||||
|
fontCatalog.nextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,12 +153,10 @@ function loadMore() {
|
|||||||
* of the loaded items. Only fetches if there are more pages available.
|
* of the loaded items. Only fetches if there are more pages available.
|
||||||
*/
|
*/
|
||||||
function handleNearBottom(_lastVisibleIndex: number) {
|
function handleNearBottom(_lastVisibleIndex: number) {
|
||||||
const { hasMore } = fontCatalogStore.pagination;
|
|
||||||
|
|
||||||
// VirtualList already checks if we're near the bottom of loaded items.
|
// VirtualList already checks if we're near the bottom of loaded items.
|
||||||
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
|
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
|
||||||
// during batch catch-up, which would otherwise let nextPage() race with it.
|
// during batch catch-up, which would otherwise let nextPage() race with it.
|
||||||
if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) {
|
if (hasMore && !isFetching && !isCatchingUp) {
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,9 +175,9 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||||
<VirtualList
|
<VirtualList
|
||||||
items={fontCatalogStore.fonts}
|
items={fonts}
|
||||||
total={fontCatalogStore.pagination.total}
|
{total}
|
||||||
isLoading={isLoading || isCatchingUp}
|
isLoading={isLoading || isFetching || isCatchingUp}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
onNearBottom={handleNearBottom}
|
onNearBottom={handleNearBottom}
|
||||||
onJump={handleJump}
|
onJump={handleJump}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||||
|
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||||
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
FontApplicator,
|
FontApplicator,
|
||||||
|
FontSampler,
|
||||||
FontVirtualList,
|
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 {
|
export {
|
||||||
createTypographySettingsStore,
|
createTypographySettingsStore,
|
||||||
|
getTypographySettingsStore,
|
||||||
|
MULTIPLIER_L,
|
||||||
|
MULTIPLIER_M,
|
||||||
|
MULTIPLIER_S,
|
||||||
type TypographySettingsStore,
|
type TypographySettingsStore,
|
||||||
typographySettingsStore,
|
|
||||||
} from './model';
|
} from './model';
|
||||||
export { TypographyMenu } from './ui';
|
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 {
|
export {
|
||||||
createTypographySettingsStore,
|
createTypographySettingsStore,
|
||||||
|
getTypographySettingsStore,
|
||||||
type TypographySettingsStore,
|
type TypographySettingsStore,
|
||||||
typographySettingsStore,
|
|
||||||
} from './store/typographySettingsStore/typographySettingsStore.svelte';
|
} from './store/typographySettingsStore/typographySettingsStore.svelte';
|
||||||
|
|||||||
+42
-17
@@ -11,22 +11,27 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ControlId,
|
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
DEFAULT_LETTER_SPACING,
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
// Deep path (not the root barrel) on purpose: pulls only these pure
|
||||||
} from '$entities/Font';
|
// 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 {
|
import {
|
||||||
type ControlDataModel,
|
|
||||||
type ControlModel,
|
|
||||||
type PersistentStore,
|
type PersistentStore,
|
||||||
type TypographyControl,
|
|
||||||
createPersistentStore,
|
createPersistentStore,
|
||||||
createTypographyControl,
|
createSingleton,
|
||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
|
import type { NumericControl } from '$shared/ui';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
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
|
* Epsilon for detecting "significant" base-size changes when reconciling
|
||||||
@@ -36,7 +41,7 @@ import { SvelteMap } from 'svelte/reactivity';
|
|||||||
*/
|
*/
|
||||||
const BASE_SIZE_EPSILON = 0.01;
|
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
|
* A control with its associated instance
|
||||||
@@ -45,7 +50,7 @@ export interface Control extends ControlOnlyFields<ControlId> {
|
|||||||
/**
|
/**
|
||||||
* The reactive typography control instance
|
* 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
|
* The underlying font size before responsive scaling is applied
|
||||||
*/
|
*/
|
||||||
#baseSize = $state(DEFAULT_FONT_SIZE);
|
#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>) {
|
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
||||||
this.#storage = storage;
|
this.#storage = storage;
|
||||||
@@ -116,7 +127,7 @@ export class TypographySettingsStore {
|
|||||||
|
|
||||||
// The Sync Effect (UI -> Storage)
|
// The Sync Effect (UI -> Storage)
|
||||||
// We access .value explicitly to ensure Svelte 5 tracks the dependency
|
// We access .value explicitly to ensure Svelte 5 tracks the dependency
|
||||||
$effect.root(() => {
|
this.#disposeEffects = $effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
|
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
|
||||||
const fontSize = this.#baseSize;
|
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
|
* Gets initial value for a control from storage or defaults
|
||||||
*/
|
*/
|
||||||
@@ -288,9 +307,6 @@ export class TypographySettingsStore {
|
|||||||
if (c.id === 'font_size') {
|
if (c.id === 'font_size') {
|
||||||
c.instance.value = defaults.fontSize * this.#multiplier;
|
c.instance.value = defaults.fontSize * this.#multiplier;
|
||||||
} else {
|
} 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') {
|
if (c.id === 'font_weight') {
|
||||||
c.instance.value = defaults.fontWeight;
|
c.instance.value = defaults.fontWeight;
|
||||||
}
|
}
|
||||||
@@ -335,10 +351,19 @@ export function createTypographySettingsStore(
|
|||||||
return new TypographySettingsStore(configs, storage);
|
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(
|
const typographySettingsStore = createSingleton(
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
() => createTypographySettingsStore(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, COMPARISON_STORAGE_KEY),
|
||||||
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_FONT_WEIGHT,
|
||||||
DEFAULT_LETTER_SPACING,
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
} from '$entities/Font/model/const/const';
|
||||||
} from '$entities/Font';
|
|
||||||
import {
|
import {
|
||||||
beforeEach,
|
beforeEach,
|
||||||
describe,
|
describe,
|
||||||
@@ -15,6 +14,7 @@ import {
|
|||||||
it,
|
it,
|
||||||
vi,
|
vi,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../../const/const';
|
||||||
import {
|
import {
|
||||||
type TypographySettings,
|
type TypographySettings,
|
||||||
TypographySettingsStore,
|
TypographySettingsStore,
|
||||||
@@ -51,6 +51,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
|||||||
let mockPersistentStore: {
|
let mockPersistentStore: {
|
||||||
value: TypographySettings;
|
value: TypographySettings;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
|
destroy: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMockPersistentStore = (initialValue: TypographySettings) => {
|
const createMockPersistentStore = (initialValue: TypographySettings) => {
|
||||||
@@ -70,6 +71,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
|||||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
destroy() {},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -535,6 +537,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
|||||||
mockStorage = v;
|
mockStorage = v;
|
||||||
},
|
},
|
||||||
clear: clearSpy,
|
clear: clearSpy,
|
||||||
|
destroy() {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const manager = new TypographySettingsStore(
|
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 {
|
import type { NumericControl } from '$shared/ui';
|
||||||
type TypographyControl,
|
|
||||||
createTypographyControl,
|
|
||||||
} from '$shared/lib';
|
|
||||||
import {
|
import {
|
||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
it,
|
it,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
import { createTypographyControl } from './createTypographyControl.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Strategy for createTypographyControl Helper
|
* Test Strategy for createTypographyControl Helper
|
||||||
@@ -34,7 +32,7 @@ describe('createTypographyControl - Unit Tests', () => {
|
|||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
}): TypographyControl {
|
}): NumericControl {
|
||||||
return createTypographyControl({
|
return createTypographyControl({
|
||||||
value: initialValue,
|
value: initialValue,
|
||||||
min: options?.min ?? 0,
|
min: options?.min ?? 0,
|
||||||
@@ -5,26 +5,26 @@
|
|||||||
Desktop: inline bar with combo controls.
|
Desktop: inline bar with combo controls.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
|
||||||
MULTIPLIER_L,
|
|
||||||
MULTIPLIER_M,
|
|
||||||
MULTIPLIER_S,
|
|
||||||
} from '$entities/Font';
|
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ComboControl,
|
ComboControl,
|
||||||
ControlGroup,
|
ControlGroup,
|
||||||
|
Popover,
|
||||||
Slider,
|
Slider,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
import { Popover } from 'bits-ui';
|
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { typographySettingsStore } from '../../model';
|
import {
|
||||||
|
MULTIPLIER_L,
|
||||||
|
MULTIPLIER_M,
|
||||||
|
MULTIPLIER_S,
|
||||||
|
getTypographySettingsStore,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +46,7 @@ interface Props {
|
|||||||
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
|
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
const typographySettingsStore = getTypographySettingsStore();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the common font size multiplier based on the current responsive state.
|
* Sets the common font size multiplier based on the current responsive state.
|
||||||
@@ -73,33 +74,21 @@ $effect(() => {
|
|||||||
{#if !hidden}
|
{#if !hidden}
|
||||||
{#if responsive.isMobileOrTablet}
|
{#if responsive.isMobileOrTablet}
|
||||||
<div class={className}>
|
<div class={className}>
|
||||||
<Popover.Root bind:open>
|
<Popover bind:open side="top" align="end" sideOffset={8}>
|
||||||
<Popover.Trigger>
|
{#snippet trigger(props)}
|
||||||
{#snippet child({ props })}
|
<Button variant="primary" {...props}>
|
||||||
<Button variant="primary" {...props}>
|
{#snippet icon()}
|
||||||
{#snippet icon()}
|
<Settings2Icon class="size-4" />
|
||||||
<Settings2Icon class="size-4" />
|
{/snippet}
|
||||||
{/snippet}
|
</Button>
|
||||||
</Button>
|
{/snippet}
|
||||||
{/snippet}
|
|
||||||
</Popover.Trigger>
|
|
||||||
|
|
||||||
<Popover.Portal>
|
{#snippet children({ close })}
|
||||||
<Popover.Content
|
<div
|
||||||
side="top"
|
|
||||||
align="end"
|
|
||||||
sideOffset={8}
|
|
||||||
class={cn(
|
class={cn(
|
||||||
'z-50 w-72 p-4 rounded-none',
|
'w-72 p-4 rounded-none',
|
||||||
'surface-popover',
|
'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 -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
||||||
@@ -111,17 +100,13 @@ $effect(() => {
|
|||||||
CONTROLS
|
CONTROLS
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Popover.Close>
|
<button
|
||||||
{#snippet child({ props })}
|
onclick={close}
|
||||||
<button
|
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||||
{...props}
|
aria-label="Close controls"
|
||||||
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>
|
||||||
<XIcon class="size-3.5 text-neutral-500" />
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</Popover.Close>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls -->
|
<!-- Controls -->
|
||||||
@@ -135,9 +120,9 @@ $effect(() => {
|
|||||||
/>
|
/>
|
||||||
</ControlGroup>
|
</ControlGroup>
|
||||||
{/each}
|
{/each}
|
||||||
</Popover.Content>
|
</div>
|
||||||
</Popover.Portal>
|
{/snippet}
|
||||||
</Popover.Root>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* @example
|
* @example
|
||||||
* ```svelte
|
* ```svelte
|
||||||
* <script lang="ts">
|
* <script lang="ts">
|
||||||
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
|
||||||
* import { onMount } from 'svelte';
|
* import { onMount } from 'svelte';
|
||||||
*
|
*
|
||||||
* onMount(() => {
|
* onMount(() => {
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
getScrollBreadcrumbsStore,
|
||||||
type NavigationAction,
|
type NavigationAction,
|
||||||
scrollBreadcrumbsStore,
|
|
||||||
} from './model';
|
} from './model';
|
||||||
export {
|
export {
|
||||||
BreadcrumbHeader,
|
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
|
* Scroll-based breadcrumb tracking store
|
||||||
*
|
*
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
* @example
|
* @example
|
||||||
* ```svelte
|
* ```svelte
|
||||||
* <script lang="ts">
|
* <script lang="ts">
|
||||||
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
|
||||||
*
|
*
|
||||||
* onMount(() => {
|
* onMount(() => {
|
||||||
* scrollBreadcrumbsStore.add({
|
* scrollBreadcrumbsStore.add({
|
||||||
@@ -167,6 +169,13 @@ class ScrollBreadcrumbsStore {
|
|||||||
this.#detachScrollListener();
|
this.#detachScrollListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tears down the observer and scroll listener. Call on store disposal.
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.#disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All tracked items sorted by index
|
* 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', () => {
|
describe('ScrollBreadcrumbsStore', () => {
|
||||||
let scrollListeners: Array<() => void> = [];
|
let scrollListeners: Array<() => void> = [];
|
||||||
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||||
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
let scrollToSpy: ReturnType<typeof vi.spyOn>;
|
let scrollToSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
// Helper to create mock elements
|
// Helper to create mock elements
|
||||||
@@ -111,7 +110,7 @@ describe('ScrollBreadcrumbsStore', () => {
|
|||||||
|
|
||||||
// Track scroll event listeners
|
// Track scroll event listeners
|
||||||
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
|
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
|
||||||
(event: string, listener: EventListenerOrEventListenerObject, options?: any) => {
|
(event: string, listener: EventListenerOrEventListenerObject, _options?: any) => {
|
||||||
if (event === 'scroll') {
|
if (event === 'scroll') {
|
||||||
scrollListeners.push(listener as () => void);
|
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) => {
|
(event: string, listener: EventListenerOrEventListenerObject) => {
|
||||||
if (event === 'scroll') {
|
if (event === 'scroll') {
|
||||||
const index = scrollListeners.indexOf(listener as () => void);
|
const index = scrollListeners.indexOf(listener as () => void);
|
||||||
+2
-1
@@ -14,9 +14,10 @@ import { cubicOut } from 'svelte/easing';
|
|||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import {
|
import {
|
||||||
type BreadcrumbItem,
|
type BreadcrumbItem,
|
||||||
scrollBreadcrumbsStore,
|
getScrollBreadcrumbsStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
|
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
|
||||||
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
|
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
+9
-3
@@ -1,18 +1,24 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { scrollBreadcrumbsStore } from '../../model';
|
import { getScrollBreadcrumbsStore } from '../../model';
|
||||||
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
|
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
|
||||||
|
|
||||||
|
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
{ index: 100, title: 'Introduction' },
|
{ index: 100, title: 'Introduction' },
|
||||||
{ index: 101, title: 'Typography' },
|
{ index: 101, title: 'Typography' },
|
||||||
{ index: 102, title: 'Spacing' },
|
{ index: 102, title: 'Spacing' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @type {HTMLDivElement} */
|
/** @type {HTMLDivElement | undefined} */
|
||||||
let container;
|
let container = $state();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
|
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
|
||||||
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
|
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
|
||||||
+3
-1
@@ -6,9 +6,11 @@
|
|||||||
import { type Snippet } from 'svelte';
|
import { type Snippet } from 'svelte';
|
||||||
import {
|
import {
|
||||||
type NavigationAction,
|
type NavigationAction,
|
||||||
scrollBreadcrumbsStore,
|
getScrollBreadcrumbsStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
|
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Navigation index
|
* Navigation index
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './model';
|
export { getThemeManager } from './model';
|
||||||
export * from './ui';
|
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';
|
export const STORAGE_KEY = 'glyphdiff:theme';
|
||||||
|
|
||||||
@@ -125,6 +128,7 @@ class ThemeManager {
|
|||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
||||||
this.#mediaQuery = null;
|
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
|
* ThemeManager class exported for testing purposes
|
||||||
* Use the singleton `themeManager` in application code.
|
* Use the `getThemeManager()` accessor in application code.
|
||||||
*/
|
*/
|
||||||
export { ThemeManager };
|
export { ThemeManager };
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ const { Story } = defineMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { themeManager } from '$features/ChangeAppTheme';
|
import { getThemeManager } from '$features/ChangeAppTheme';
|
||||||
|
|
||||||
|
const themeManager = getThemeManager();
|
||||||
// Current theme state for display
|
// Current theme state for display
|
||||||
const currentTheme = $derived(themeManager.value);
|
const currentTheme = $derived(themeManager.value);
|
||||||
const themeSource = $derived(themeManager.source);
|
const themeSource = $derived(themeManager.source);
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import { IconButton } from '$shared/ui';
|
|||||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||||
import SunIcon from '@lucide/svelte/icons/sun';
|
import SunIcon from '@lucide/svelte/icons/sun';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { themeManager } from '../../model';
|
import { getThemeManager } from '../../model';
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
|
const themeManager = getThemeManager();
|
||||||
const theme = $derived(themeManager.value);
|
const theme = $derived(themeManager.value);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,25 @@ import {
|
|||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
} from '@testing-library/svelte';
|
} 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';
|
import ThemeSwitch from './ThemeSwitch.svelte';
|
||||||
|
|
||||||
const context = new Map([['responsive', { isMobile: false }]]);
|
const context = new Map([['responsive', { isMobile: false }]]);
|
||||||
|
|
||||||
describe('ThemeSwitch', () => {
|
describe('ThemeSwitch', () => {
|
||||||
|
let themeManager: ReturnType<typeof getThemeManager>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
themeManager = getThemeManager();
|
||||||
themeManager.setTheme('light');
|
themeManager.setTheme('light');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__resetThemeManager();
|
||||||
|
});
|
||||||
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('renders an icon button', () => {
|
it('renders an icon button', () => {
|
||||||
render(ThemeSwitch, { context });
|
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';
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
|
||||||
|
|
||||||
export { FontSampler };
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user