Compare commits
106 Commits
eb5a8d1e5b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c78b8e032e | |||
| 11d5ba0e63 | |||
| 99e9a1fb2c | |||
| 5084df3914 | |||
| a2ec025a65 | |||
| 8dbea97a33 | |||
| 744cdc9d19 | |||
| 600b905e01 | |||
| 4ad0fe4cfa | |||
| eafe89b313 | |||
| 724b00d3d5 | |||
| c09ca93f4e | |||
| 99ab7e9e08 | |||
| ec488cf1ce | |||
| fe07c60dd4 | |||
| 0aae710e35 | |||
| ded9606c30 | |||
| f0736f4d35 | |||
| 5eb458eabb | |||
| a428eac309 | |||
| 09869aed00 | |||
| 028853aff5 | |||
| 1c6427c586 | |||
| 60e115309c | |||
| b390efdabe | |||
| 771bda745c | |||
| c6c8497906 | |||
| f3a10e38df | |||
| 9788f07dec | |||
| deefb51b57 | |||
| 431fb41a7f | |||
| db6384110e | |||
| cbd95350bb | |||
| a8a985ee6a | |||
| be073286dc | |||
| 7798c4bbdf | |||
| 3ae22ad515 | |||
| ffa897ee54 | |||
| 93c52dd132 | |||
| 9e0c8f740b | |||
| b1b5177e02 | |||
| ef9cd33e48 | |||
| f3c76df2c5 | |||
| ae2d0e3c2f | |||
| 3f5151efa0 | |||
| 19d9b07c55 | |||
| 1209358d40 | |||
| d7decd7a00 | |||
| 9d6220d2ec | |||
| 4756682863 | |||
| 7ddf232e3a | |||
| b3bc40b76c | |||
| 839460726e | |||
| 6877807aaf | |||
| 3dca11fea8 | |||
| 0b675635b3 | |||
| 9780ff9358 | |||
| 1ad015aed6 | |||
| 10603d18bf | |||
| 39d1ce4c37 | |||
| fcd61be4fa | |||
| 28a8e49915 | |||
| 43e8507144 | |||
| 67af3d946a | |||
| c6d0270072 | |||
| a677dc6b0b | |||
| f7cd6b5081 | |||
| dda8ef6368 | |||
| d77b51736a | |||
| 1e16330097 | |||
| c41016ac5d | |||
| aa4189f6a8 | |||
| 17c022470e | |||
| a9f3b990ab | |||
| 36673597f7 | |||
| 42bcc915c7 | |||
| c72b51b1c7 | |||
| 6888f67f14 | |||
| a651d3d16f | |||
| 4d8dcf52e0 | |||
| 907145c655 | |||
| e49148008b | |||
| c613d4cf88 | |||
| 7834c7cbf2 | |||
| 4640d6e521 | |||
| 8adf5cd7b3 | |||
| b8edeff86f | |||
| 7d66b0bc92 | |||
| ecdb2f1b7f | |||
| 6a07b89773 | |||
| 02aa27dc48 | |||
| 4652857512 | |||
| d5f0814efc | |||
| 6153769317 | |||
| 3e568685b3 | |||
| 581ffb5887 | |||
| 2ece4c5559 | |||
| 1fa099bef5 | |||
| 50238e12c3 | |||
| f13dfe1caf | |||
| f4edb67acb | |||
| ccf51c645e | |||
| efbc464b14 | |||
| c5092a488b | |||
| ddadac8686 | |||
| f6911fbcca |
+195
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["import"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn",
|
||||
"perf": "warn",
|
||||
// style/restriction off: opt-in, contradictory grab-bags. Wanted rules enabled individually below.
|
||||
"style": "off",
|
||||
"restriction": "off"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
".svelte-kit",
|
||||
".vercel",
|
||||
"*.config.js",
|
||||
"*.config.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "warn",
|
||||
"no-debugger": "error",
|
||||
"no-alert": "warn",
|
||||
|
||||
// no-cycle resolves $-aliases via tsconfig auto-discovery (no resolver config in oxlint)
|
||||
"import/no-cycle": "error",
|
||||
"import/no-duplicates": "warn",
|
||||
"import/no-unassigned-import": "off", // CSS/side-effect imports are intentional
|
||||
|
||||
"no-sequences": "error",
|
||||
"no-underscore-dangle": "off",
|
||||
"no-shadow": "warn",
|
||||
"no-implicit-coercion": "warn",
|
||||
"no-await-in-loop": "warn",
|
||||
"no-return-assign": "warn",
|
||||
"no-new": "warn",
|
||||
"no-unneeded-ternary": "warn"
|
||||
},
|
||||
// FSD boundaries. oxlint has no zone rule, so layer/segment direction is enforced
|
||||
// with no-restricted-imports patterns scoped per glob. Layer order (high->low):
|
||||
// app(exempt top shell) > routes > widgets > features > entities > shared.
|
||||
// A layer bans imports from itself (cross-slice via alias) and every layer above.
|
||||
// Overrides are LAST-WINS, not merged: a file matching two overrides keeps only the
|
||||
// last rule config. So the domain override (below) is a self-contained superset, and
|
||||
// the test/story override (last) fully disables boundary checks for those files.
|
||||
"overrides": [
|
||||
// shared = lowest layer: imports nothing above it
|
||||
{
|
||||
"files": ["src/shared/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"$app",
|
||||
"$app/*",
|
||||
"$routes",
|
||||
"$routes/*",
|
||||
"$widgets",
|
||||
"$widgets/*",
|
||||
"$features",
|
||||
"$features/*",
|
||||
"$entities",
|
||||
"$entities/*"
|
||||
],
|
||||
"message": "FSD layer violation: `shared` is the lowest layer and may not import from any layer above it."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// entities: import shared only; no other entity via alias; interior ui<-only-ui
|
||||
{
|
||||
"files": ["src/entities/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*", "$features", "$features/*"],
|
||||
"message": "FSD layer violation: `entities` may only import from `shared`."
|
||||
},
|
||||
{
|
||||
"group": ["$entities", "$entities/*"],
|
||||
"message": "FSD cross-slice violation: do not import another entity via its alias. Use relative imports inside your own slice; invert the dependency through a higher layer for cross-slice needs."
|
||||
},
|
||||
{
|
||||
"group": ["../ui", "../ui/*", "../../ui/*"],
|
||||
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// features: import entities/shared only; no other feature via alias
|
||||
{
|
||||
"files": ["src/features/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*"],
|
||||
"message": "FSD layer violation: `features` may only import from `entities` and `shared`."
|
||||
},
|
||||
{
|
||||
"group": ["$features", "$features/*"],
|
||||
"message": "FSD cross-slice violation: do not import another feature via its alias. Invert the dependency through a higher layer (widget/route)."
|
||||
},
|
||||
{
|
||||
"group": ["../ui", "../ui/*", "../../ui/*"],
|
||||
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// widgets: import features/entities/shared only; no other widget via alias
|
||||
{
|
||||
"files": ["src/widgets/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["$app", "$app/*", "$routes", "$routes/*"],
|
||||
"message": "FSD layer violation: `widgets` may only import from `features`, `entities`, and `shared`."
|
||||
},
|
||||
{
|
||||
"group": ["$widgets", "$widgets/*"],
|
||||
"message": "FSD cross-slice violation: do not import another widget via its alias. Invert the dependency through the route layer."
|
||||
},
|
||||
{
|
||||
"group": ["../ui", "../ui/*", "../../ui/*"],
|
||||
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// routes: top of the FSD list, imports any layer below; only app is above it
|
||||
{
|
||||
"files": ["src/routes/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{ "group": ["$app", "$app/*"], "message": "FSD layer violation: `routes` may not import from `app`." }
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// domain (FSD+): pure logic. Imports NO layer (not even shared) and no sibling
|
||||
// model/ui segment. Superset: wins over the layer override above for these files.
|
||||
{
|
||||
"files": ["src/**/domain/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"$app",
|
||||
"$app/*",
|
||||
"$routes",
|
||||
"$routes/*",
|
||||
"$widgets",
|
||||
"$widgets/*",
|
||||
"$features",
|
||||
"$features/*",
|
||||
"$entities",
|
||||
"$entities/*",
|
||||
"$shared",
|
||||
"$shared/*"
|
||||
],
|
||||
"message": "FSD+ domain isolation: `domain` is pure business logic and may not import any layer (including `shared`). Allowed: relative imports within `domain` and framework-agnostic npm packages."
|
||||
},
|
||||
{
|
||||
"group": ["../model", "../model/*", "../../model/*", "../ui", "../ui/*", "../../ui/*"],
|
||||
"message": "FSD+ domain isolation: `domain` may not import sibling `model` or `ui` segments. Dependency flows ui -> model -> domain, never back."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// tests/stories/fixtures legitimately cross-import (e.g. $entities/Font/testing).
|
||||
// Must be LAST so last-wins disables boundary checks for them.
|
||||
{
|
||||
"files": ["**/*.test.ts", "**/*.spec.ts", "**/*.stories.svelte", "src/**/testing/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,28 @@
|
||||
:3000 {
|
||||
root * /usr/share/caddy
|
||||
file_server
|
||||
try_files {path} /index.html
|
||||
|
||||
# Compress text responses only. woff2/png and other binaries are already
|
||||
# compressed, so they're excluded — re-compressing them burns CPU for ~0%.
|
||||
encode {
|
||||
zstd
|
||||
gzip
|
||||
match {
|
||||
header Content-Type text/*
|
||||
header Content-Type application/javascript*
|
||||
header Content-Type application/json*
|
||||
header Content-Type image/svg+xml*
|
||||
}
|
||||
}
|
||||
|
||||
# Vite emits all build output under /assets/ with content-hashed filenames,
|
||||
# so those bytes never change for a given URL — cache them indefinitely.
|
||||
@assets path /assets/*
|
||||
header @assets Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
# The HTML shell is the un-hashed entry point; it must revalidate so a new
|
||||
# deploy is served immediately rather than from a stale cache.
|
||||
header /index.html Cache-Control "no-cache"
|
||||
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { windowSizeForLine } from '../src/entities/Font/domain/windowSizeForLine/windowSizeForLine';
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
@@ -5,12 +6,22 @@ import {
|
||||
|
||||
test.describe('preview text', () => {
|
||||
test('drives the slider character rendering', async ({ comparison }) => {
|
||||
/**
|
||||
* Must stay a single unwrapped line of ASCII: the assertion feeds
|
||||
* `text.length` (UTF-16 code units) to `windowSizeForLine`, but the
|
||||
* renderer feeds it the line's grapheme count. They match only for
|
||||
* plain ASCII — emoji/combining marks (length > graphemes) or wrapping
|
||||
* (one input string splitting into several lines) silently desync them.
|
||||
*/
|
||||
const text = 'Sphinx';
|
||||
await comparison.pickPair('Inter', 'Roboto');
|
||||
await comparison.setPreviewText('Sphinx');
|
||||
await comparison.setPreviewText(text);
|
||||
|
||||
// Each grapheme renders as a `.char-wrap` cell in the slider once
|
||||
// both fonts are loaded. Six glyphs → six cells.
|
||||
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(6);
|
||||
// Window chars render as `.char-wrap` cells for crossfade. The window
|
||||
// size is a pure function of the line's grapheme count — assert against
|
||||
// the rule, not a hardcoded constant, so tuning the policy can't silently
|
||||
// break this. "Sphinx" is one unwrapped line of 6 graphemes.
|
||||
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(windowSizeForLine(text.length));
|
||||
});
|
||||
|
||||
test('preserves the typed value in the input', async ({ comparison }) => {
|
||||
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn",
|
||||
"perf": "warn",
|
||||
"style": "warn",
|
||||
"restriction": "error"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
".svelte-kit",
|
||||
".vercel",
|
||||
"*.config.js",
|
||||
"*.config.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"no-debugger": "error",
|
||||
"no-alert": "warn"
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -4,6 +4,10 @@
|
||||
"version": "0.0.1",
|
||||
"packageManager": "yarn@4.11.0",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"*.css",
|
||||
"**/router.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@@ -44,7 +48,6 @@
|
||||
"@types/jsdom": "28.0.1",
|
||||
"@vitest/browser-playwright": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"bits-ui": "2.18.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dprint": "0.54.0",
|
||||
"jsdom": "29.1.1",
|
||||
@@ -66,6 +69,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "0.0.6",
|
||||
"@tanstack/svelte-query": "6.1.28"
|
||||
"@tanstack/svelte-query": "6.1.28",
|
||||
"sv-router": "^0.16.3"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -6,21 +6,27 @@
|
||||
/**
|
||||
* App Component
|
||||
*
|
||||
* Application entry point component. Wraps the main page route within the shared
|
||||
* Application entry point component. Wraps the active route within the shared
|
||||
* layout shell. This is the root component mounted by the application.
|
||||
*
|
||||
* Structure:
|
||||
* - QueryProvider provides TanStack Query client for data fetching
|
||||
* - Layout provides sidebar, header/footer, and page container
|
||||
* - Page renders the current route content
|
||||
* - Router renders the matched route component
|
||||
*/
|
||||
import Page from '$routes/Page.svelte';
|
||||
import { QueryProvider } from './providers';
|
||||
import '$routes/router';
|
||||
import { Router } from 'sv-router';
|
||||
import {
|
||||
AppBindingsProvider,
|
||||
QueryProvider,
|
||||
} from './providers';
|
||||
import Layout from './ui/Layout.svelte';
|
||||
</script>
|
||||
|
||||
<QueryProvider>
|
||||
<AppBindingsProvider>
|
||||
<Layout>
|
||||
<Page />
|
||||
<Router />
|
||||
</Layout>
|
||||
</AppBindingsProvider>
|
||||
</QueryProvider>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
Component: AppBindings
|
||||
Provider that starts app-wide store bindings (filters → sort → font catalog)
|
||||
for its subtree. Mount-scoped so the bindings' lifetime tracks the app tree.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { startFilterBindings } from '$features/FilterAndSortFonts';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Content snippet
|
||||
*/
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// startFilterBindings returns its $effect.root cleanup; onMount runs it on unmount.
|
||||
onMount(() => startFilterBindings());
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@@ -6,7 +6,7 @@
|
||||
descendants of this provider.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
@@ -18,6 +18,9 @@ interface Props {
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// First call to the lazy singleton — constructs the shared client for the app.
|
||||
const queryClient = getQueryClient();
|
||||
</script>
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as AppBindingsProvider } from './AppBindings.svelte';
|
||||
export { default as QueryProvider } from './QueryProvider.svelte';
|
||||
|
||||
+16
-23
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./fonts.css";
|
||||
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@@ -216,9 +217,7 @@
|
||||
/* Monospace label tracking — used in Loader and Footnote */
|
||||
--tracking-wider-mono: 0.2em;
|
||||
|
||||
/* ============================================
|
||||
SHADOW TOKENS
|
||||
============================================ */
|
||||
/* Shadow tokens */
|
||||
|
||||
/* Default resting shadow — equivalent to Tailwind's shadow-sm. Used on
|
||||
buttons, sliders, popover triggers in non-floating state. */
|
||||
@@ -245,9 +244,7 @@
|
||||
/* Drawer / overlay shadow — full-strength shadow-2xl. */
|
||||
--shadow-overlay: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
|
||||
/* ============================================
|
||||
MOTION TOKENS
|
||||
============================================ */
|
||||
/* Motion tokens */
|
||||
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 200ms;
|
||||
@@ -274,7 +271,7 @@
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||
font-family: var(--font-secondary);
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
|
||||
@@ -325,9 +322,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DESIGN-SYSTEM UTILITIES
|
||||
============================================
|
||||
/* Design-system utilities.
|
||||
Defined via `@utility` (Tailwind v4) so they integrate with the variant
|
||||
system (`hover:`, `dark:`, breakpoints) and don't rely on `@apply`
|
||||
chains. Colors reference the mode-switching semantic vars defined in
|
||||
@@ -362,7 +357,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Surface utilities ────────────────────────────────────────── */
|
||||
/* Surface utilities */
|
||||
|
||||
@utility surface-canvas {
|
||||
background-color: var(--color-surface);
|
||||
@@ -391,7 +386,7 @@
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
/* ── Shape / layout ───────────────────────────────────────────── */
|
||||
/* Shape / layout */
|
||||
|
||||
@utility flex-center {
|
||||
display: flex;
|
||||
@@ -422,7 +417,7 @@
|
||||
background-size: 10px 10px;
|
||||
}
|
||||
|
||||
/* ── Typography ───────────────────────────────────────────────── */
|
||||
/* Typography */
|
||||
|
||||
@utility text-label-mono {
|
||||
font-family: var(--font-primary);
|
||||
@@ -431,7 +426,7 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Global utility - useful across your app */
|
||||
/* Honor prefers-reduced-motion: collapse animation and transition timing. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
@@ -440,12 +435,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Performance optimization for collapsible elements */
|
||||
/* Hint the upcoming height animation on open collapsibles. */
|
||||
[data-state="open"] {
|
||||
will-change: height;
|
||||
}
|
||||
|
||||
/* Smooth focus transitions - good globally */
|
||||
/* Transition siblings of a focus-visible peer. */
|
||||
.peer:focus-visible ~ * {
|
||||
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
@@ -472,11 +467,9 @@
|
||||
animation: nudge 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SCROLLBAR STYLES
|
||||
============================================ */
|
||||
/* Scrollbar styling */
|
||||
|
||||
/* ---- Modern API: color + width (Chrome 121+, FF 64+) ---- */
|
||||
/* Standard API: color + width (Chrome 121+, Firefox 64+). */
|
||||
@supports (scrollbar-width: auto) {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
@@ -488,8 +481,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Webkit layer: runs ON TOP in Chrome, standalone in old Safari ---- */
|
||||
/* Handles things scrollbar-width can't: hiding buttons, exact sizing */
|
||||
/* WebKit fallback: applies on top of the standard API in Chrome, standalone in
|
||||
older Safari. Covers what scrollbar-width can't — hiding buttons, exact sizing. */
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -497,7 +490,7 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
display: none; /* kills arrows */
|
||||
display: none; /* hide scrollbar buttons */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Self-hosted interface fonts (latin subset only).
|
||||
Vendored from @fontsource — see docs/interface-font-selfhost-benchmark.md.
|
||||
Variable faces (Inter, Space Grotesk) keep their wght axis; Inter also keeps opsz.
|
||||
url()s are resolved + content-hashed by Vite at build → immutable long-cache.
|
||||
*/
|
||||
|
||||
/* Inter — variable wght + opsz, the body/secondary UI font (--font-secondary) */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
src: url('../assets/fonts/inter-latin-opsz-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
src: url('../assets/fonts/inter-latin-opsz-italic.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* Space Grotesk — variable wght, the primary/display UI font (--font-primary) */
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 300 700;
|
||||
src: url('../assets/fonts/space-grotesk-latin-wght-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* Space Mono — static 400/700 × roman/italic (--font-mono) */
|
||||
@font-face {
|
||||
font-family: 'Space Mono';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('../assets/fonts/space-mono-latin-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Space Mono';
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('../assets/fonts/space-mono-latin-400-italic.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Space Mono';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src: url('../assets/fonts/space-mono-latin-700-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Space Mono';
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src: url('../assets/fonts/space-mono-latin-700-italic.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* Syne — static 800, the logo font (--font-logo) */
|
||||
@font-face {
|
||||
font-family: 'Syne';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 800;
|
||||
src: url('../assets/fonts/syne-latin-800-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
Vendored
+5
@@ -38,6 +38,11 @@ declare module '*.jpg' {
|
||||
|
||||
declare module '*.css';
|
||||
|
||||
declare module '*.woff2?url' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
|
||||
+20
-25
@@ -3,12 +3,20 @@
|
||||
Application shell with providers and page wrapper
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { themeManager } from '$features/ChangeAppTheme';
|
||||
import { getThemeManager } from '$features/ChangeAppTheme';
|
||||
import G from '$shared/assets/G.svg';
|
||||
import { ResponsiveProvider } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import { Footer } from '$widgets/Footer';
|
||||
|
||||
/*
|
||||
Preload the two render-critical interface faces (primary + secondary).
|
||||
`?url` resolves to the content-hashed path Vite emits, so the binary is
|
||||
fetched immediately rather than waiting for CSS @font-face discovery.
|
||||
*/
|
||||
import interWoff2 from '../assets/fonts/inter-latin-opsz-normal.woff2?url';
|
||||
import spaceGroteskWoff2 from '../assets/fonts/space-grotesk-latin-wght-normal.woff2?url';
|
||||
|
||||
import {
|
||||
type Snippet,
|
||||
onDestroy,
|
||||
@@ -24,6 +32,8 @@ interface Props {
|
||||
|
||||
let { children }: Props = $props();
|
||||
let fontsReady = $state(true);
|
||||
|
||||
const themeManager = getThemeManager();
|
||||
const theme = $derived(themeManager.value);
|
||||
|
||||
onMount(() => themeManager.init());
|
||||
@@ -33,36 +43,21 @@ onDestroy(() => themeManager.destroy());
|
||||
<svelte:head>
|
||||
<link rel="icon" href={G} type="image/svg+xml" />
|
||||
|
||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||
<!-- Self-hosted interface fonts (see src/app/styles/fonts/fonts.css). Preload the two critical faces. -->
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://cdn.fontshare.com"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
rel="preload"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
href={interWoff2}
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
as="style"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
href={spaceGroteskWoff2}
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
|
||||
media="print"
|
||||
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
|
||||
/>
|
||||
<noscript>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
|
||||
/>
|
||||
</noscript>
|
||||
<title>GlyphDiff | Typography & Typefaces</title>
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './store/scrollBreadcrumbsStore.svelte';
|
||||
export * from './types/types.ts';
|
||||
@@ -19,7 +19,9 @@ vi.mock('$shared/api/api', () => ({
|
||||
}));
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { FontResponseError } from '../../lib/errors/errors';
|
||||
import {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { buildQueryString } from '$shared/lib/utils';
|
||||
import type { QueryParams } from '$shared/lib/utils';
|
||||
@@ -26,7 +26,7 @@ import type { UnifiedFont } from '../../model/types';
|
||||
*/
|
||||
export function seedFontCache(fonts: UnifiedFont[]): void {
|
||||
fonts.forEach(font => {
|
||||
queryClient.setQueryData(fontKeys.detail(font.id), font);
|
||||
getQueryClient().setQueryData(fontKeys.detail(font.id), font);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
||||
import { clearCache } from '@chenglou/pretext';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { DualFontLayout } from './DualFontLayout';
|
||||
|
||||
// FontA: 10px per character. FontB: 15px per character.
|
||||
// The mock dispatches on whether the font string contains 'FontA' or 'FontB'.
|
||||
const FONT_A_WIDTH = 10;
|
||||
const FONT_B_WIDTH = 15;
|
||||
|
||||
function fontWidthFactory(font: string, text: string): number {
|
||||
const perChar = font.includes('FontA') ? FONT_A_WIDTH : FONT_B_WIDTH;
|
||||
return text.length * perChar;
|
||||
}
|
||||
|
||||
describe('DualFontLayout', () => {
|
||||
let layout: DualFontLayout;
|
||||
|
||||
beforeEach(() => {
|
||||
installCanvasMock(fontWidthFactory);
|
||||
clearCache();
|
||||
layout = new DualFontLayout();
|
||||
});
|
||||
|
||||
it('returns empty result for empty string', () => {
|
||||
const result = layout.layout('', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
expect(result.lines).toHaveLength(0);
|
||||
expect(result.totalHeight).toBe(0);
|
||||
});
|
||||
|
||||
it('uses worst-case width across both fonts to determine line breaks', () => {
|
||||
// 'AB CD' — two 2-char words separated by a space.
|
||||
// FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total.
|
||||
// FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '.
|
||||
// Unified must use FontB widths — so it must wrap at the same place FontB wraps.
|
||||
const result = layout.layout('AB CD', '400 16px "FontA"', '400 16px "FontB"', 35, 20);
|
||||
expect(result.lines.length).toBeGreaterThan(1);
|
||||
// First line text must not include both words.
|
||||
expect(result.lines[0].text).not.toContain('CD');
|
||||
});
|
||||
|
||||
it('provides xA and xB offsets for both fonts on a single line', () => {
|
||||
// 'ABC' fits in 500px for both fonts.
|
||||
// FontA: A@0(w=10), B@10(w=10), C@20(w=10)
|
||||
// FontB: A@0(w=15), B@15(w=15), C@30(w=15)
|
||||
const result = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const chars = result.lines[0].chars;
|
||||
|
||||
expect(chars).toHaveLength(3);
|
||||
|
||||
expect(chars[0].xA).toBe(0);
|
||||
expect(chars[0].widthA).toBe(FONT_A_WIDTH);
|
||||
expect(chars[0].xB).toBe(0);
|
||||
expect(chars[0].widthB).toBe(FONT_B_WIDTH);
|
||||
|
||||
expect(chars[1].xA).toBe(FONT_A_WIDTH); // 10
|
||||
expect(chars[1].widthA).toBe(FONT_A_WIDTH);
|
||||
expect(chars[1].xB).toBe(FONT_B_WIDTH); // 15
|
||||
expect(chars[1].widthB).toBe(FONT_B_WIDTH);
|
||||
|
||||
expect(chars[2].xA).toBe(FONT_A_WIDTH * 2); // 20
|
||||
expect(chars[2].xB).toBe(FONT_B_WIDTH * 2); // 30
|
||||
});
|
||||
|
||||
it('returns cached result when called again with same arguments', () => {
|
||||
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const r2 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
expect(r2).toBe(r1); // strict reference equality — same object
|
||||
});
|
||||
|
||||
it('re-computes when text changes', () => {
|
||||
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const r2 = layout.layout('DEF', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
expect(r2).not.toBe(r1);
|
||||
expect(r2.lines[0].text).not.toBe(r1.lines[0].text);
|
||||
});
|
||||
|
||||
it('re-computes when width changes', () => {
|
||||
const r1 = layout.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const r2 = layout.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 60, 20);
|
||||
expect(r2).not.toBe(r1);
|
||||
});
|
||||
|
||||
it('re-computes when fontA changes', () => {
|
||||
const r1 = layout.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const r2 = layout.layout('ABC', '400 24px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
expect(r2).not.toBe(r1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
type PreparedTextWithSegments,
|
||||
layoutWithLines,
|
||||
prepareWithSegments,
|
||||
} from '@chenglou/pretext';
|
||||
|
||||
/**
|
||||
* Default render size in px when callers omit the `size` arg on `layout()`.
|
||||
*/
|
||||
const DEFAULT_RENDER_SIZE_PX = 16;
|
||||
|
||||
/**
|
||||
* Per-grapheme data computed during dual-font layout. Internal to the engine;
|
||||
* consumed by computeLineRenderModel to derive the per-frame render model.
|
||||
*/
|
||||
export interface ComparisonChar {
|
||||
/**
|
||||
* Grapheme cluster (may be >1 code unit for emoji, combining marks).
|
||||
*/
|
||||
char: string;
|
||||
/**
|
||||
* X offset from line start in fontA, pixels.
|
||||
*/
|
||||
xA: number;
|
||||
/**
|
||||
* Advance width of this grapheme in fontA, pixels.
|
||||
*/
|
||||
widthA: number;
|
||||
/**
|
||||
* X offset from line start in fontB, pixels.
|
||||
*/
|
||||
xB: number;
|
||||
/**
|
||||
* Advance width of this grapheme in fontB, pixels.
|
||||
*/
|
||||
widthB: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single laid-out line. `chars` carries the per-grapheme data needed by
|
||||
* computeLineRenderModel. Consumers should not iterate it directly.
|
||||
*/
|
||||
export interface ComparisonLine {
|
||||
/**
|
||||
* Full text of this line as returned by pretext.
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Rendered width in pixels — maximum across fontA and fontB.
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* Per-grapheme metadata for both fonts.
|
||||
*/
|
||||
chars: ComparisonChar[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated output of a dual-font layout pass.
|
||||
*/
|
||||
export interface ComparisonResult {
|
||||
/**
|
||||
* Per-line grapheme data. Empty when input text is empty.
|
||||
*/
|
||||
lines: ComparisonLine[];
|
||||
/**
|
||||
* Total height in pixels.
|
||||
*/
|
||||
totalHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dual-font text layout engine backed by `@chenglou/pretext`.
|
||||
*
|
||||
* Computes identical line breaks for two fonts simultaneously by constructing a
|
||||
* "unified" prepared-text object whose per-glyph widths are the worst-case maximum
|
||||
* of font A and font B. This guarantees that both fonts wrap at exactly the same
|
||||
* positions, making side-by-side or slider comparison visually coherent.
|
||||
*
|
||||
* Relies on pretext's published structural fields on `PreparedTextWithSegments`
|
||||
* (`widths`, `breakableFitAdvances`, `lineEndFitAdvances`, `lineEndPaintAdvances`)
|
||||
* which are exposed via the `PreparedCore` intersection in `@chenglou/pretext@0.0.6`.
|
||||
*
|
||||
* **Two-level caching strategy**
|
||||
* 1. Font-change cache (`#preparedA`, `#preparedB`, `#unifiedPrepared`): rebuilt only
|
||||
* when `text`, `fontA`, or `fontB` changes. `prepareWithSegments` is expensive
|
||||
* (canvas measurement), so this avoids re-measuring during slider interaction.
|
||||
* 2. Layout cache (`#lastResult`): rebuilt when `width` or `lineHeight` changes but
|
||||
* the fonts have not changed. Line-breaking is cheap relative to measurement, but
|
||||
* still worth skipping on every render tick.
|
||||
*
|
||||
* Per-frame slider state derivation lives in `computeLineRenderModel`, not on the
|
||||
* class. This class is pure layout + caching; it holds no reactive state.
|
||||
*/
|
||||
export class DualFontLayout {
|
||||
#segmenter: Intl.Segmenter;
|
||||
|
||||
// Cached prepared data
|
||||
#preparedA: PreparedTextWithSegments | null = null;
|
||||
#preparedB: PreparedTextWithSegments | null = null;
|
||||
#unifiedPrepared: PreparedTextWithSegments | null = null;
|
||||
|
||||
#lastText = '';
|
||||
#lastFontA = '';
|
||||
#lastFontB = '';
|
||||
#lastSpacing = 0;
|
||||
#lastSize = 0;
|
||||
|
||||
// Cached layout results
|
||||
#lastWidth = -1;
|
||||
#lastLineHeight = -1;
|
||||
#lastResult: ComparisonResult | null = null;
|
||||
|
||||
constructor(locale?: string) {
|
||||
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out `text` using both fonts within `width` pixels.
|
||||
*
|
||||
* Line breaks are determined by the worst-case (maximum) glyph widths across
|
||||
* both fonts, so both fonts always wrap at identical positions.
|
||||
*
|
||||
* @param text Raw text to lay out.
|
||||
* @param fontA CSS font string for the first font: `"weight sizepx \"family\""`.
|
||||
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
|
||||
* @param width Available line width in pixels.
|
||||
* @param lineHeight Line height in pixels (passed directly to pretext).
|
||||
* @param spacing Letter spacing in em (from typography settings).
|
||||
* @param size Current font size in pixels (used to convert spacing em to px).
|
||||
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
|
||||
*/
|
||||
layout(
|
||||
text: string,
|
||||
fontA: string,
|
||||
fontB: string,
|
||||
width: number,
|
||||
lineHeight: number,
|
||||
spacing: number = 0,
|
||||
size: number = DEFAULT_RENDER_SIZE_PX,
|
||||
): ComparisonResult {
|
||||
if (!text) {
|
||||
return { lines: [], totalHeight: 0 };
|
||||
}
|
||||
|
||||
const spacingPx = spacing * size;
|
||||
|
||||
const isFontChange = text !== this.#lastText
|
||||
|| fontA !== this.#lastFontA
|
||||
|| fontB !== this.#lastFontB
|
||||
|| spacing !== this.#lastSpacing
|
||||
|| size !== this.#lastSize;
|
||||
|
||||
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
|
||||
|
||||
if (!isFontChange && !isLayoutChange && this.#lastResult) {
|
||||
return this.#lastResult;
|
||||
}
|
||||
|
||||
// 1. Prepare (or use cache)
|
||||
if (isFontChange) {
|
||||
this.#preparedA = prepareWithSegments(text, fontA);
|
||||
this.#preparedB = prepareWithSegments(text, fontB);
|
||||
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB, spacingPx);
|
||||
|
||||
this.#lastText = text;
|
||||
this.#lastFontA = fontA;
|
||||
this.#lastFontB = fontB;
|
||||
this.#lastSpacing = spacing;
|
||||
this.#lastSize = size;
|
||||
}
|
||||
|
||||
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
|
||||
return { lines: [], totalHeight: 0 };
|
||||
}
|
||||
|
||||
const { lines, height } = layoutWithLines(this.#unifiedPrepared, width, lineHeight);
|
||||
|
||||
// 3. Map results back to both fonts
|
||||
const preparedA = this.#preparedA;
|
||||
const preparedB = this.#preparedB;
|
||||
const resultLines: ComparisonLine[] = lines.map(line => {
|
||||
const chars: ComparisonChar[] = [];
|
||||
let currentXA = 0;
|
||||
let currentXB = 0;
|
||||
|
||||
const start = line.start;
|
||||
const end = line.end;
|
||||
|
||||
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
|
||||
const segmentText = preparedA.segments[sIdx];
|
||||
if (segmentText === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
|
||||
|
||||
const advA = preparedA.breakableFitAdvances[sIdx];
|
||||
const advB = preparedB.breakableFitAdvances[sIdx];
|
||||
|
||||
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
|
||||
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
|
||||
|
||||
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
|
||||
const char = graphemes[gIdx];
|
||||
let wA = advA != null ? advA[gIdx]! : preparedA.widths[sIdx]!;
|
||||
let wB = advB != null ? advB[gIdx]! : preparedB.widths[sIdx]!;
|
||||
|
||||
// Apply letter spacing (tracking) to the width of each character
|
||||
wA += spacingPx;
|
||||
wB += spacingPx;
|
||||
|
||||
chars.push({
|
||||
char,
|
||||
xA: currentXA,
|
||||
widthA: wA,
|
||||
xB: currentXB,
|
||||
widthB: wB,
|
||||
});
|
||||
currentXA += wA;
|
||||
currentXB += wB;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: line.text,
|
||||
width: line.width,
|
||||
chars,
|
||||
};
|
||||
});
|
||||
|
||||
this.#lastWidth = width;
|
||||
this.#lastLineHeight = lineHeight;
|
||||
this.#lastResult = {
|
||||
lines: resultLines,
|
||||
totalHeight: height,
|
||||
};
|
||||
|
||||
return this.#lastResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two prepared texts into a worst-case unified version so both fonts
|
||||
* wrap at identical positions. Per-segment widths are the elementwise max
|
||||
* across both fonts, with `spacingPx` added to model letter-spacing.
|
||||
*/
|
||||
#createUnifiedPrepared(
|
||||
a: PreparedTextWithSegments,
|
||||
b: PreparedTextWithSegments,
|
||||
spacingPx: number = 0,
|
||||
): PreparedTextWithSegments {
|
||||
const unified: PreparedTextWithSegments = { ...a };
|
||||
|
||||
unified.widths = a.widths.map((w, i) => Math.max(w, b.widths[i]) + spacingPx);
|
||||
unified.lineEndFitAdvances = a.lineEndFitAdvances.map((w, i) =>
|
||||
Math.max(w, b.lineEndFitAdvances[i]) + spacingPx
|
||||
);
|
||||
unified.lineEndPaintAdvances = a.lineEndPaintAdvances.map((w, i) =>
|
||||
Math.max(w, b.lineEndPaintAdvances[i]) + spacingPx
|
||||
);
|
||||
|
||||
unified.breakableFitAdvances = a.breakableFitAdvances.map((advA, i) => {
|
||||
const advB = b.breakableFitAdvances[i];
|
||||
if (!advA && !advB) {
|
||||
return null;
|
||||
}
|
||||
if (!advA) {
|
||||
return advB!.map(w => w + spacingPx);
|
||||
}
|
||||
if (!advB) {
|
||||
return advA.map(w => w + spacingPx);
|
||||
}
|
||||
return advA.map((w, j) => Math.max(w, advB[j]) + spacingPx);
|
||||
});
|
||||
|
||||
return unified;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
|
||||
import {
|
||||
type LineRenderModel,
|
||||
computeLineRenderModel,
|
||||
findSplitIndex,
|
||||
} from './computeLineRenderModel';
|
||||
|
||||
/**
|
||||
* Build a ComparisonLine fixture with given per-char widths. xA/xB are
|
||||
* cumulative prefix sums of widthA/widthB respectively.
|
||||
*/
|
||||
function makeLine(
|
||||
chars: { char: string; widthA: number; widthB: number }[],
|
||||
): ComparisonLine {
|
||||
let xA = 0;
|
||||
let xB = 0;
|
||||
const out: ComparisonLine = {
|
||||
text: chars.map(c => c.char).join(''),
|
||||
width: chars.reduce((s, c) => s + Math.max(c.widthA, c.widthB), 0),
|
||||
chars: chars.map(c => {
|
||||
const entry = {
|
||||
char: c.char,
|
||||
xA,
|
||||
xB,
|
||||
widthA: c.widthA,
|
||||
widthB: c.widthB,
|
||||
};
|
||||
xA += c.widthA;
|
||||
xB += c.widthB;
|
||||
return entry;
|
||||
}),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test helper: compute split + render model in one step, matching the
|
||||
* SliderArea call site shape.
|
||||
*/
|
||||
function compute(
|
||||
line: ComparisonLine,
|
||||
sliderPos: number,
|
||||
containerWidth: number,
|
||||
windowSize: number,
|
||||
): LineRenderModel {
|
||||
const split = findSplitIndex(line, sliderPos, containerWidth);
|
||||
return computeLineRenderModel(line, split, windowSize);
|
||||
}
|
||||
|
||||
describe('computeLineRenderModel', () => {
|
||||
it('returns empty model for an empty line', () => {
|
||||
const line = makeLine([]);
|
||||
const model = compute(line, 50, 500, 5);
|
||||
expect(model.leftText).toBe('');
|
||||
expect(model.windowChars).toEqual([]);
|
||||
expect(model.rightText).toBe('');
|
||||
});
|
||||
|
||||
it('places entire line in rightText when slider is at 0', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = compute(line, 0, 500, 0);
|
||||
expect(model.leftText).toBe('');
|
||||
expect(model.windowChars).toEqual([]);
|
||||
expect(model.rightText).toBe('ABC');
|
||||
});
|
||||
|
||||
it('places entire line in leftText when slider is at 100', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = compute(line, 100, 500, 0);
|
||||
expect(model.leftText).toBe('ABC');
|
||||
expect(model.windowChars).toEqual([]);
|
||||
expect(model.rightText).toBe('');
|
||||
});
|
||||
|
||||
it('splits line correctly with slider mid-line (window=0)', () => {
|
||||
// Equal widths → line is centered. Container=300, total=30 → xOffset=135.
|
||||
// Char thresholds (per the threshold formula in the design):
|
||||
// threshold[i] = xOffset + prefA[i] + widthA[i]/2
|
||||
// i=0: 135 + 0 + 5 = 140 → 140/300 = 46.67%
|
||||
// i=1: 135 + 10 + 5 = 150 → 150/300 = 50.00%
|
||||
// i=2: 135 + 20 + 5 = 160 → 160/300 = 53.33%
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
// Slider just past B's threshold (50%) but not C's (53.33%).
|
||||
const model = compute(line, 51, 300, 0);
|
||||
expect(model.leftText).toBe('AB');
|
||||
expect(model.rightText).toBe('C');
|
||||
});
|
||||
|
||||
it('centers window of size 3 on the split index', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
{ char: 'D', widthA: 10, widthB: 10 },
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
// Slider past A and B (~thresholds 43.33%, 46.67%); not past C (50%).
|
||||
// split = 2 → halfWindow = 1 → windowStart = 1, windowEnd = 4
|
||||
const model = compute(line, 48, 300, 3);
|
||||
expect(model.leftText).toBe('A');
|
||||
expect(model.windowChars.map(w => w.char)).toEqual(['B', 'C', 'D']);
|
||||
expect(model.rightText).toBe('E');
|
||||
});
|
||||
|
||||
it('clamps window at line start when slider is near 0', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
{ char: 'D', widthA: 10, widthB: 10 },
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = compute(line, 0, 300, 3);
|
||||
expect(model.leftText).toBe('');
|
||||
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B', 'C']);
|
||||
expect(model.rightText).toBe('DE');
|
||||
});
|
||||
|
||||
it('clamps window at line end when slider is near 100', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
{ char: 'D', widthA: 10, widthB: 10 },
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = compute(line, 100, 300, 3);
|
||||
expect(model.leftText).toBe('AB');
|
||||
expect(model.windowChars.map(w => w.char)).toEqual(['C', 'D', 'E']);
|
||||
expect(model.rightText).toBe('');
|
||||
});
|
||||
|
||||
it('treats whole line as window when line is shorter than windowSize', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const model = compute(line, 50, 300, 5);
|
||||
expect(model.leftText).toBe('');
|
||||
expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B']);
|
||||
expect(model.rightText).toBe('');
|
||||
});
|
||||
|
||||
it('produces stable keys across slider movement within the same line', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
{ char: 'D', widthA: 10, widthB: 10 },
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
const a = compute(line, 40, 300, 3);
|
||||
const b = compute(line, 60, 300, 3);
|
||||
// Chars that appear in both windows must carry identical keys.
|
||||
for (const charA of a.windowChars) {
|
||||
const charB = b.windowChars.find(w => w.char === charA.char);
|
||||
if (charB !== undefined) {
|
||||
expect(charB.key).toBe(charA.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('marks isPast=true for chars before the split and false for chars after', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
{ char: 'D', widthA: 10, widthB: 10 },
|
||||
{ char: 'E', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
// split = 2 → A,B past; C,D,E not
|
||||
const model = compute(line, 48, 300, 5);
|
||||
const expected = new Map([['A', true], ['B', true], ['C', false], ['D', false], ['E', false]]);
|
||||
for (const wc of model.windowChars) {
|
||||
expect(wc.isPast).toBe(expected.get(wc.char));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSplitIndex', () => {
|
||||
it('returns 0 for empty line', () => {
|
||||
const line = makeLine([]);
|
||||
expect(findSplitIndex(line, 50, 500)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when slider is before all char thresholds', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
expect(findSplitIndex(line, 0, 300)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns chars.length when slider is past all char thresholds', () => {
|
||||
const line = makeLine([
|
||||
{ char: 'A', widthA: 10, widthB: 10 },
|
||||
{ char: 'B', widthA: 10, widthB: 10 },
|
||||
{ char: 'C', widthA: 10, widthB: 10 },
|
||||
]);
|
||||
expect(findSplitIndex(line, 100, 300)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ComparisonLine } from '../DualFontLayout/DualFontLayout';
|
||||
|
||||
/**
|
||||
* Per-line render slice consumed by Line.svelte. The window is centered on the
|
||||
* slider's split index and clamps at line boundaries.
|
||||
*/
|
||||
export interface LineRenderModel {
|
||||
/**
|
||||
* Chars before the window joined into a single string, rendered as one fontA text run.
|
||||
*/
|
||||
leftText: string;
|
||||
/**
|
||||
* Window chars — each rendered as its own Character element with crossfade slots.
|
||||
*/
|
||||
windowChars: Array<{
|
||||
/**
|
||||
* Stable key for Svelte keyed each — survives slider movement within the same line.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Grapheme cluster to render.
|
||||
*/
|
||||
char: string;
|
||||
/**
|
||||
* True once the slider has crossed this char's threshold.
|
||||
*/
|
||||
isPast: boolean;
|
||||
}>;
|
||||
/**
|
||||
* Chars after the window joined into a single string, rendered as one fontB text run.
|
||||
*/
|
||||
rightText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of chars whose flip threshold the slider has crossed.
|
||||
*
|
||||
* Exposed as a separate step so consumers can pass the resulting primitive
|
||||
* `split` across component boundaries: when split is unchanged tick-to-tick,
|
||||
* downstream `$derived` reads of `computeLineRenderModel(line, split, ...)`
|
||||
* short-circuit on value equality and skip re-rendering.
|
||||
*
|
||||
* For each candidate split `i`, the line's hypothetical width at that moment is
|
||||
* `prefA[i] + widthA[i] + sufB[i+1]` (past chars in fontA, char `i` flipping, future
|
||||
* chars in fontB). The threshold is the x of char `i`'s center in the centered line.
|
||||
* Thresholds are monotonically non-decreasing in `i`, so the scan short-circuits on
|
||||
* the first miss.
|
||||
*/
|
||||
export function findSplitIndex(
|
||||
line: ComparisonLine,
|
||||
sliderPos: number,
|
||||
containerWidth: number,
|
||||
): number {
|
||||
const chars = line.chars;
|
||||
const n = chars.length;
|
||||
if (n === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sliderX = (sliderPos / 100) * containerWidth;
|
||||
|
||||
const prefA = new Float64Array(n + 1);
|
||||
const sufB = new Float64Array(n + 1);
|
||||
for (let i = 0, j = n - 1; i < n; i++, j--) {
|
||||
prefA[i + 1] = prefA[i] + chars[i].widthA;
|
||||
sufB[j] = sufB[j + 1] + chars[j].widthB;
|
||||
}
|
||||
|
||||
let split = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1];
|
||||
const xOffset = (containerWidth - totalWidth) / 2;
|
||||
const threshold = xOffset + prefA[i] + chars[i].widthA / 2;
|
||||
if (sliderX > threshold) {
|
||||
split = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slices a laid-out line into three regions around a precomputed split index:
|
||||
* a fontA bulk run, an N-char crossfade window, and a fontB bulk run.
|
||||
*
|
||||
* Pure and allocation-bounded: two strings plus a `windowSize`-length array per call.
|
||||
* Takes `split` as a primitive so callers can feed it into a `$derived` and
|
||||
* skip re-evaluation on ticks where the split index is unchanged.
|
||||
*
|
||||
* @param line Line from `DualFontLayout.layout()`. Empty `chars` yields an empty model.
|
||||
* @param split Count of chars the slider has passed, in `[0, line.chars.length]`.
|
||||
* @param windowSize Number of chars in the crossfade window. Clamped to `[0, line.chars.length]`.
|
||||
* At line edges the window is shifted (not shrunk) to keep its size.
|
||||
*/
|
||||
export function computeLineRenderModel(
|
||||
line: ComparisonLine,
|
||||
split: number,
|
||||
windowSize: number,
|
||||
): LineRenderModel {
|
||||
const chars = line.chars;
|
||||
const n = chars.length;
|
||||
if (n === 0) {
|
||||
return { leftText: '', windowChars: [], rightText: '' };
|
||||
}
|
||||
|
||||
const halfWindow = Math.floor(Math.max(0, windowSize) / 2);
|
||||
let windowStart = clamp(split - halfWindow, 0, n);
|
||||
let windowEnd = clamp(windowStart + Math.max(0, windowSize), 0, n);
|
||||
windowStart = Math.max(0, windowEnd - Math.max(0, windowSize));
|
||||
|
||||
const leftText = chars.slice(0, windowStart).map(c => c.char).join('');
|
||||
const rightText = chars.slice(windowEnd).map(c => c.char).join('');
|
||||
const windowChars = chars.slice(windowStart, windowEnd).map((c, idx) => ({
|
||||
key: `${windowStart + idx}-${c.char}`,
|
||||
char: c.char,
|
||||
isPast: (windowStart + idx) < split,
|
||||
}));
|
||||
|
||||
return { leftText, windowChars, rightText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps `value` into the inclusive range `[lo, hi]`. Assumes `lo <= hi`.
|
||||
*/
|
||||
function clamp(value: number, lo: number, hi: number): number {
|
||||
if (value < lo) {
|
||||
return lo;
|
||||
}
|
||||
if (value > hi) {
|
||||
return hi;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
type ComparisonLine,
|
||||
type ComparisonResult,
|
||||
DualFontLayout,
|
||||
} from './DualFontLayout/DualFontLayout';
|
||||
export {
|
||||
computeLineRenderModel,
|
||||
findSplitIndex,
|
||||
type LineRenderModel,
|
||||
} from './computeLineRenderModel/computeLineRenderModel';
|
||||
export { windowSizeForLine } from './windowSizeForLine/windowSizeForLine';
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { windowSizeForLine } from './windowSizeForLine';
|
||||
|
||||
describe('windowSizeForLine', () => {
|
||||
it('returns 0 for an empty or non-positive line', () => {
|
||||
expect(windowSizeForLine(0)).toBe(0);
|
||||
expect(windowSizeForLine(-3)).toBe(0);
|
||||
});
|
||||
|
||||
it('floors non-empty short lines at the minimum window of 1', () => {
|
||||
expect(windowSizeForLine(1)).toBe(1);
|
||||
expect(windowSizeForLine(2)).toBe(1);
|
||||
expect(windowSizeForLine(3)).toBe(1);
|
||||
});
|
||||
|
||||
it('scales with round(n / 3) in the mid range', () => {
|
||||
expect(windowSizeForLine(6)).toBe(2);
|
||||
expect(windowSizeForLine(12)).toBe(4);
|
||||
});
|
||||
|
||||
it('caps at the maximum window of 5', () => {
|
||||
expect(windowSizeForLine(15)).toBe(5);
|
||||
expect(windowSizeForLine(16)).toBe(5);
|
||||
expect(windowSizeForLine(100)).toBe(5);
|
||||
});
|
||||
|
||||
it('rounds to nearest at fractional boundaries', () => {
|
||||
// round(4/3)=1, round(5/3)=2, round(13/3)=4, round(14/3)=5
|
||||
expect(windowSizeForLine(4)).toBe(1);
|
||||
expect(windowSizeForLine(5)).toBe(2);
|
||||
expect(windowSizeForLine(13)).toBe(4);
|
||||
expect(windowSizeForLine(14)).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Crossfade-window sizing policy for the dual-font slider.
|
||||
*
|
||||
* The slider renders a band of per-char `Character` cells that opacity-crossfade
|
||||
* between the two fonts; everything outside the band is committed native bulk
|
||||
* text. A fixed band looked wrong on short lines — a 6-grapheme line left almost
|
||||
* no bulk, so nearly the whole line shimmered as per-char DOM. The band size
|
||||
* therefore scales with the line's grapheme count and caps so long lines don't
|
||||
* pay for an oversized per-char DOM band.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fraction of a line's graphemes that sit in the crossfade band.
|
||||
*/
|
||||
const WINDOW_RATIO = 1 / 3;
|
||||
/**
|
||||
* Smallest band for a non-empty line — guarantees at least one crossfading char.
|
||||
*
|
||||
* Accepted tradeoff: short lines now get a band of 1–2, so a fast slider drag
|
||||
* can unmount a char before its ~100ms opacity crossfade finishes, a slight pop.
|
||||
* Worth it for the "bulk committed, small band shimmering" look on short lines;
|
||||
* raising this trades that pop back for less committed bulk.
|
||||
*/
|
||||
const WINDOW_MIN = 1;
|
||||
/**
|
||||
* Largest band regardless of line length — bounds per-char DOM cost.
|
||||
*/
|
||||
const WINDOW_MAX = 5;
|
||||
|
||||
/**
|
||||
* Crossfade window size, in graphemes, for a line of `n` graphemes.
|
||||
* `clamp(round(n / 3), 1, 5)`; an empty/non-positive line gets no window.
|
||||
*/
|
||||
export function windowSizeForLine(n: number): number {
|
||||
if (n <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(WINDOW_MAX, Math.max(WINDOW_MIN, Math.round(n * WINDOW_RATIO)));
|
||||
}
|
||||
@@ -1,4 +1,93 @@
|
||||
export * from './api';
|
||||
export * from './lib';
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
export {
|
||||
computeLineRenderModel,
|
||||
DualFontLayout,
|
||||
findSplitIndex,
|
||||
windowSizeForLine,
|
||||
} from './domain';
|
||||
export type {
|
||||
ComparisonLine,
|
||||
ComparisonResult,
|
||||
LineRenderModel,
|
||||
} from './domain';
|
||||
|
||||
export {
|
||||
createFontRowSizeResolver,
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
getFontUrl,
|
||||
} from './lib';
|
||||
export type { FontRowSizeResolverOptions } from './lib';
|
||||
|
||||
export {
|
||||
FontApplicator,
|
||||
FontSampler,
|
||||
FontVirtualList,
|
||||
} from './ui';
|
||||
|
||||
// Pure model surface (types + constants).
|
||||
export {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LETTER_SPACING_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LETTER_SPACING,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LETTER_SPACING,
|
||||
MIN_LINE_HEIGHT,
|
||||
VIRTUAL_INDEX_NOT_LOADED,
|
||||
} from './model/const/const';
|
||||
export type {
|
||||
FilterGroup,
|
||||
FilterType,
|
||||
FontCategory,
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
FontCollectionState,
|
||||
FontFeatures,
|
||||
FontFilters,
|
||||
FontLoadRequestConfig,
|
||||
FontLoadStatus,
|
||||
FontMetadata,
|
||||
FontProvider,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './model/types';
|
||||
|
||||
/*
|
||||
* Stores are exposed as lazy accessors / classes (not eager singletons): the
|
||||
* entity's public API is complete, so consumers go through this barrel instead
|
||||
* of deep-importing `./model` (FSD public-API boundary). Construction happens on
|
||||
* first call, so this is inert at import. The slice root already transitively
|
||||
* loads `@tanstack/query-core` via `./ui` (FontVirtualList), so surfacing the
|
||||
* stores here adds no new eager cost.
|
||||
*/
|
||||
export {
|
||||
FontLifecycleManager,
|
||||
FontsByIdsStore,
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
} from './model';
|
||||
export type { FontCatalogStore } from './model';
|
||||
|
||||
/*
|
||||
* `./api` (proxy clients: `fetchProxyFonts`, `seedFontCache`, …) is intentionally
|
||||
* NOT re-exported here — those are not part of the entity's consumed surface and
|
||||
* importing them eagerly constructs the TanStack `queryClient`. Import via the
|
||||
* segment: `import { fetchProxyFonts } from '$entities/Font/api'`.
|
||||
*/
|
||||
|
||||
// `./testing` is intentionally not re-exported: fixtures must not leak into the
|
||||
// production public API. Import them via `$entities/Font/testing`.
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
import { createFontLoadRequestContfig } from './createFontLoadRequestContfig';
|
||||
|
||||
/**
|
||||
* Minimal UnifiedFont mock — override only the fields a case exercises.
|
||||
*/
|
||||
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
|
||||
const baseFont: UnifiedFont = {
|
||||
id: 'test-font',
|
||||
name: 'Test Font',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin'],
|
||||
variants: [],
|
||||
styles: {},
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
features: {
|
||||
isVariable: false,
|
||||
tags: [],
|
||||
},
|
||||
};
|
||||
|
||||
return { ...baseFont, ...overrides };
|
||||
}
|
||||
|
||||
describe('createFontLoadRequestContfig', () => {
|
||||
it('builds a single-element config when a URL resolves', () => {
|
||||
const font = createMockFont({
|
||||
id: 'roboto',
|
||||
name: 'Roboto',
|
||||
styles: { variants: { '400': 'https://example.com/roboto-400.woff2' } },
|
||||
});
|
||||
|
||||
const result = createFontLoadRequestContfig(font, 400);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'roboto',
|
||||
name: 'Roboto',
|
||||
weight: 400,
|
||||
url: 'https://example.com/roboto-400.woff2',
|
||||
isVariable: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array when no URL resolves (flatMap drops the font)', () => {
|
||||
const font = createMockFont({ styles: {} });
|
||||
|
||||
expect(createFontLoadRequestContfig(font, 400)).toEqual([]);
|
||||
});
|
||||
|
||||
it('forwards isVariable from font features', () => {
|
||||
const font = createMockFont({
|
||||
features: { isVariable: true, tags: [] },
|
||||
styles: { variants: { '700': 'https://example.com/inter-vf.woff2' } },
|
||||
});
|
||||
|
||||
const [config] = createFontLoadRequestContfig(font, 700);
|
||||
|
||||
expect(config.isVariable).toBe(true);
|
||||
});
|
||||
|
||||
it('sets isVariable to undefined when features is absent', () => {
|
||||
// features is non-optional on UnifiedFont, but upstream data can be partial —
|
||||
// the optional chain must not throw, and isVariable stays undefined.
|
||||
const font = createMockFont({
|
||||
styles: { variants: { '400': 'https://example.com/font.woff2' } },
|
||||
});
|
||||
// @ts-expect-error — deliberately drop the guaranteed field to exercise the optional chain
|
||||
font.features = undefined;
|
||||
|
||||
const [config] = createFontLoadRequestContfig(font, 400);
|
||||
|
||||
expect(config.isVariable).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the resolved fallback URL, not just exact matches', () => {
|
||||
// getFontUrl falls back to styles.regular when the exact weight is missing;
|
||||
// the config must carry whatever URL actually resolved.
|
||||
const font = createMockFont({
|
||||
styles: { regular: 'https://example.com/font-regular.woff2' },
|
||||
});
|
||||
|
||||
const [config] = createFontLoadRequestContfig(font, 900);
|
||||
|
||||
expect(config.url).toBe('https://example.com/font-regular.woff2');
|
||||
expect(config.weight).toBe(900);
|
||||
});
|
||||
|
||||
it('carries the requested weight even when the URL is a shared fallback', () => {
|
||||
const font = createMockFont({
|
||||
styles: { variants: { '400': 'https://example.com/shared.woff2' } },
|
||||
});
|
||||
|
||||
expect(createFontLoadRequestContfig(font, 700)[0].weight).toBe(700);
|
||||
});
|
||||
|
||||
it('propagates the invalid-weight error from getFontUrl', () => {
|
||||
const font = createMockFont();
|
||||
|
||||
expect(() => createFontLoadRequestContfig(font, 450)).toThrow('Invalid weight: 450');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type {
|
||||
FontLoadRequestConfig,
|
||||
UnifiedFont,
|
||||
} from '../../model';
|
||||
import { getFontUrl } from '../getFontUrl/getFontUrl';
|
||||
|
||||
/**
|
||||
* Build the font-lifecycle load request for a single font at a given weight.
|
||||
*
|
||||
* Returns a 0-or-1 element array rather than `FontLoadRequestConfig | undefined`
|
||||
* so call sites can `flatMap` over a font list — resolve the URL and drop fonts
|
||||
* that have none in a single pass, with no separate filter step. An empty array
|
||||
* means the font has no loadable asset for this weight (or its fallbacks) and is
|
||||
* silently skipped.
|
||||
*
|
||||
* `isVariable` is forwarded from the font's features so the lifecycle manager can
|
||||
* dedupe variable fonts per ID (they load once regardless of weight) while still
|
||||
* loading static fonts per weight.
|
||||
*
|
||||
* @param font - Unified font to load
|
||||
* @param weight - Numeric weight (100-900)
|
||||
* @returns Single-element config array, or `[]` when no URL resolves
|
||||
* @throws Error when weight is outside the valid 100-900 range (propagated from `getFontUrl`)
|
||||
*/
|
||||
export function createFontLoadRequestContfig(font: UnifiedFont, weight: number): FontLoadRequestConfig[] {
|
||||
const url = getFontUrl(font, weight);
|
||||
|
||||
if (!url) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable }];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NonRetryableError } from '$shared/api/queryClient';
|
||||
import { NonRetryableError } from '$shared/api/nonRetryableError';
|
||||
|
||||
/**
|
||||
* Thrown when the network request to the proxy API fails.
|
||||
|
||||
@@ -1,49 +1,5 @@
|
||||
export { getFontUrl } from './getFontUrl/getFontUrl';
|
||||
|
||||
// Mock data helpers for Storybook and testing
|
||||
export {
|
||||
createCategoriesFilter,
|
||||
createErrorState,
|
||||
createGenericFilter,
|
||||
createLoadingState,
|
||||
createMockComparisonStore,
|
||||
// Filter mocks
|
||||
createMockFilter,
|
||||
createMockFontApiResponse,
|
||||
createMockFontStoreState,
|
||||
// Store mocks
|
||||
createMockQueryState,
|
||||
createMockReactiveState,
|
||||
createMockStore,
|
||||
createProvidersFilter,
|
||||
createSubsetsFilter,
|
||||
createSuccessState,
|
||||
generateMixedCategoryFonts,
|
||||
generateMockFonts,
|
||||
generatePaginatedFonts,
|
||||
generateSequentialFilter,
|
||||
GENERIC_FILTERS,
|
||||
getAllMockFonts,
|
||||
getFontsByCategory,
|
||||
getFontsByProvider,
|
||||
MOCK_FILTERS,
|
||||
MOCK_FILTERS_ALL_SELECTED,
|
||||
MOCK_FILTERS_EMPTY,
|
||||
MOCK_FILTERS_SELECTED,
|
||||
MOCK_FONT_STORE_STATES,
|
||||
MOCK_STORES,
|
||||
type MockFilterOptions,
|
||||
type MockFilters,
|
||||
type MockFontStoreState,
|
||||
// Font mocks
|
||||
// Types
|
||||
type MockQueryObserverResult,
|
||||
type MockQueryState,
|
||||
mockUnifiedFont,
|
||||
type MockUnifiedFontOptions,
|
||||
UNIFIED_FONTS,
|
||||
} from './mocks';
|
||||
|
||||
export {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('@chenglou/pretext', async () => {
|
||||
layout: vi.fn(actual.layout),
|
||||
};
|
||||
});
|
||||
import { mockUnifiedFont } from '$entities/Font/testing';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { FontLoadStatus } from '../../model/types';
|
||||
import { mockUnifiedFont } from '../mocks';
|
||||
import { createFontRowSizeResolver } from './createFontRowSizeResolver';
|
||||
|
||||
// Fixed-width canvas mock: every character is 10px wide regardless of font.
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import type { ControlModel } from '$shared/lib';
|
||||
import type { ControlId } from '../types/typography';
|
||||
|
||||
/**
|
||||
* Font size constants
|
||||
*/
|
||||
@@ -33,60 +30,6 @@ export const MIN_LETTER_SPACING = -0.1;
|
||||
export const MAX_LETTER_SPACING = 0.5;
|
||||
export const LETTER_SPACING_STEP = 0.01;
|
||||
|
||||
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
||||
{
|
||||
id: 'font_size',
|
||||
value: DEFAULT_FONT_SIZE,
|
||||
max: MAX_FONT_SIZE,
|
||||
min: MIN_FONT_SIZE,
|
||||
step: FONT_SIZE_STEP,
|
||||
|
||||
increaseLabel: 'Increase Font Size',
|
||||
decreaseLabel: 'Decrease Font Size',
|
||||
controlLabel: 'Size',
|
||||
},
|
||||
{
|
||||
id: 'font_weight',
|
||||
value: DEFAULT_FONT_WEIGHT,
|
||||
max: MAX_FONT_WEIGHT,
|
||||
min: MIN_FONT_WEIGHT,
|
||||
step: FONT_WEIGHT_STEP,
|
||||
|
||||
increaseLabel: 'Increase Font Weight',
|
||||
decreaseLabel: 'Decrease Font Weight',
|
||||
controlLabel: 'Weight',
|
||||
},
|
||||
{
|
||||
id: 'line_height',
|
||||
value: DEFAULT_LINE_HEIGHT,
|
||||
max: MAX_LINE_HEIGHT,
|
||||
min: MIN_LINE_HEIGHT,
|
||||
step: LINE_HEIGHT_STEP,
|
||||
|
||||
increaseLabel: 'Increase Line Height',
|
||||
decreaseLabel: 'Decrease Line Height',
|
||||
controlLabel: 'Leading',
|
||||
},
|
||||
{
|
||||
id: 'letter_spacing',
|
||||
value: DEFAULT_LETTER_SPACING,
|
||||
max: MAX_LETTER_SPACING,
|
||||
min: MIN_LETTER_SPACING,
|
||||
step: LETTER_SPACING_STEP,
|
||||
|
||||
increaseLabel: 'Increase Letter Spacing',
|
||||
decreaseLabel: 'Decrease Letter Spacing',
|
||||
controlLabel: 'Tracking',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Font size multipliers
|
||||
*/
|
||||
export const MULTIPLIER_S = 0.5;
|
||||
export const MULTIPLIER_M = 0.75;
|
||||
export const MULTIPLIER_L = 1;
|
||||
|
||||
/**
|
||||
* Index value for items not yet loaded in a virtualized list.
|
||||
* Treated as being at the very bottom of the infinite scroll.
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
export * from './const/const';
|
||||
export * from './store';
|
||||
export * from './types';
|
||||
export {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LETTER_SPACING_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LETTER_SPACING,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LETTER_SPACING,
|
||||
MIN_LINE_HEIGHT,
|
||||
VIRTUAL_INDEX_NOT_LOADED,
|
||||
} from './const/const';
|
||||
|
||||
// Stores (lazy accessors + classes)
|
||||
export {
|
||||
__resetFontLifecycleManager,
|
||||
FontLifecycleManager,
|
||||
FontsByIdsStore,
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
} from './store';
|
||||
export type { FontCatalogStore } from './store';
|
||||
|
||||
export type {
|
||||
FilterGroup,
|
||||
FilterType,
|
||||
FontCategory,
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
FontCollectionState,
|
||||
FontFeatures,
|
||||
FontFilters,
|
||||
FontLoadRequestConfig,
|
||||
FontLoadStatus,
|
||||
FontMetadata,
|
||||
FontProvider,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './types';
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { QueryClient } from '@tanstack/query-core';
|
||||
import {
|
||||
generateMixedCategoryFonts,
|
||||
generateMockFonts,
|
||||
} from '$entities/Font/testing';
|
||||
import { flushSync } from 'svelte';
|
||||
import {
|
||||
afterEach,
|
||||
@@ -12,27 +15,33 @@ import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '../../../lib/errors/errors';
|
||||
import {
|
||||
generateMixedCategoryFonts,
|
||||
generateMockFonts,
|
||||
} from '../../../lib/mocks/fonts.mock';
|
||||
import type { UnifiedFont } from '../../types';
|
||||
import { FontCatalogStore } from './fontCatalogStore.svelte';
|
||||
|
||||
vi.mock('$shared/api/queryClient', async importOriginal => {
|
||||
/**
|
||||
* Import QueryClient inside the factory rather than referencing the top-level binding.
|
||||
* A hoisted vi.mock factory that touches a module-level import can hit that import
|
||||
* before it is initialized (ReferenceError) when the import sits in a circular/eager
|
||||
* barrel chain — which it now does via $shared/lib → BaseQueryStore → query-core.
|
||||
*/
|
||||
const { QueryClient } = await import('@tanstack/query-core');
|
||||
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
|
||||
const mockClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||
});
|
||||
return {
|
||||
...actual,
|
||||
queryClient: new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||
}),
|
||||
getQueryClient: () => mockClient,
|
||||
};
|
||||
});
|
||||
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import { fetchProxyFonts } from '../../../api';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
||||
|
||||
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
DEFAULT_QUERY_GC_TIME_MS,
|
||||
DEFAULT_QUERY_STALE_TIME_MS,
|
||||
queryClient,
|
||||
getQueryClient,
|
||||
} from '$shared/api/queryClient';
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
import {
|
||||
type InfiniteData,
|
||||
InfiniteQueryObserver,
|
||||
@@ -46,7 +47,7 @@ export class FontCatalogStore {
|
||||
readonly unknown[],
|
||||
PageParam
|
||||
>;
|
||||
#qc = queryClient;
|
||||
#qc = getQueryClient();
|
||||
#unsubscribe: () => void;
|
||||
|
||||
constructor(params: FontStoreParams = {}) {
|
||||
@@ -483,8 +484,12 @@ export class FontCatalogStore {
|
||||
}
|
||||
}
|
||||
|
||||
export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
|
||||
return new FontCatalogStore(params);
|
||||
}
|
||||
const catalog = createSingleton(
|
||||
() => new FontCatalogStore({ limit: 50 }),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const fontCatalogStore = new FontCatalogStore({ limit: 50 });
|
||||
export const getFontCatalog = catalog.get;
|
||||
|
||||
// test-only reset, so specs don't share a live observer
|
||||
export const __resetFontCatalog = catalog.reset;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import {
|
||||
type FontLoadRequestConfig,
|
||||
@@ -420,6 +421,15 @@ export class FontLifecycleManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance — use throughout the application for unified font loading state.
|
||||
* App-wide font lifecycle manager, created on first access. Lazy so its
|
||||
* AbortController / FontFace bookkeeping isn't set up at module load.
|
||||
*/
|
||||
export const fontLifecycleManager = new FontLifecycleManager();
|
||||
const fontLifecycleManager = createSingleton(
|
||||
() => new FontLifecycleManager(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getFontLifecycleManager = fontLifecycleManager.get;
|
||||
|
||||
// test-only reset, so specs don't share loaded-font/eviction state
|
||||
export const __resetFontLifecycleManager = fontLifecycleManager.reset;
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('loadFont', () => {
|
||||
it('throws FontParseError when font.load() rejects', async () => {
|
||||
const loadError = new Error('parse failed');
|
||||
const MockFontFace = vi.fn(
|
||||
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) {
|
||||
function(this: any, _name: string, _buffer: BufferSource, _options: FontFaceDescriptors) {
|
||||
this.load = vi.fn().mockRejectedValue(loadError);
|
||||
},
|
||||
);
|
||||
|
||||
+5
-5
@@ -1,14 +1,14 @@
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore/BaseQueryStore.svelte';
|
||||
import {
|
||||
fetchFontsByIds,
|
||||
seedFontCache,
|
||||
} from '$entities/Font/api/proxy/proxyFonts';
|
||||
} from '../../../api/proxy/proxyFonts';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '$entities/Font/lib/errors/errors';
|
||||
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
||||
} from '../../../lib/errors/errors';
|
||||
import type { UnifiedFont } from '../../types';
|
||||
|
||||
/**
|
||||
* Internal fetcher that seeds the cache and handles error wrapping.
|
||||
+8
-6
@@ -1,9 +1,6 @@
|
||||
import * as api from '$entities/Font/api/proxy/proxyFonts';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '$entities/Font/lib/errors/errors';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import {
|
||||
beforeEach,
|
||||
@@ -12,6 +9,11 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import * as api from '../../../api/proxy/proxyFonts';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '../../../lib/errors/errors';
|
||||
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
|
||||
|
||||
describe('FontsByIdsStore', () => {
|
||||
@@ -1,9 +1,13 @@
|
||||
// Font lifecycle manager (browser-side load + cache + eviction)
|
||||
export * from './fontLifecycleManager/fontLifecycleManager.svelte';
|
||||
export {
|
||||
__resetFontLifecycleManager,
|
||||
FontLifecycleManager,
|
||||
getFontLifecycleManager,
|
||||
} from './fontLifecycleManager/fontLifecycleManager.svelte';
|
||||
|
||||
// Paginated catalog
|
||||
export {
|
||||
createFontCatalogStore,
|
||||
FontCatalogStore,
|
||||
fontCatalogStore,
|
||||
} from './fontCatalogStore/fontCatalogStore.svelte';
|
||||
export { getFontCatalog } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||
export type { FontCatalogStore } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||
|
||||
// Batch fetch by IDs (detail-cache seeding)
|
||||
export { FontsByIdsStore } from './fontsByIdsStore/fontsByIdsStore.svelte';
|
||||
|
||||
@@ -23,5 +23,7 @@ export type {
|
||||
FontCollectionState,
|
||||
} from './store';
|
||||
|
||||
export * from './store/fontLifecycle';
|
||||
export * from './typography';
|
||||
export type {
|
||||
FontLoadRequestConfig,
|
||||
FontLoadStatus,
|
||||
} from './store/fontLifecycle';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||
@@ -1,9 +1,5 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK FONT DATA
|
||||
* ============================================================================
|
||||
*
|
||||
* Factory functions and preset mock data for fonts.
|
||||
* Mock font data: factory functions and preset fixtures.
|
||||
* Used in Storybook stories, tests, and development.
|
||||
*
|
||||
* ## Usage
|
||||
@@ -16,7 +12,7 @@
|
||||
* GOOGLE_FONTS,
|
||||
* FONTHARE_FONTS,
|
||||
* UNIFIED_FONTS,
|
||||
* } from '$entities/Font/lib/mocks';
|
||||
* } from '$entities/Font/testing';
|
||||
*
|
||||
* // Create a mock Google Font
|
||||
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
|
||||
@@ -28,7 +24,7 @@
|
||||
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
|
||||
*
|
||||
* // Use preset fonts
|
||||
* import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
|
||||
* import { UNIFIED_FONTS } from '$entities/Font/testing';
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK DATA HELPERS - MAIN EXPORT
|
||||
* ============================================================================
|
||||
*
|
||||
* Mock data helpers (main export).
|
||||
* Comprehensive mock data for Storybook stories, tests, and development.
|
||||
*
|
||||
* ## Quick Start
|
||||
@@ -13,7 +10,7 @@
|
||||
* UNIFIED_FONTS,
|
||||
* MOCK_FILTERS,
|
||||
* createMockFontStoreState,
|
||||
* } from '$entities/Font/lib/mocks';
|
||||
* } from '$entities/Font/testing';
|
||||
*
|
||||
* // Use in stories
|
||||
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
|
||||
+2
-6
@@ -8,7 +8,7 @@
|
||||
* import {
|
||||
* createMockQueryState,
|
||||
* MOCK_STORES,
|
||||
* } from '$entities/Font/lib/mocks';
|
||||
* } from '$entities/Font/testing';
|
||||
*
|
||||
* // Create a mock query state
|
||||
* const loadingState = createMockQueryState({ status: 'pending' });
|
||||
@@ -21,11 +21,7 @@
|
||||
*/
|
||||
|
||||
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||
import type {
|
||||
QueryKey,
|
||||
QueryObserverResult,
|
||||
QueryStatus,
|
||||
} from '@tanstack/svelte-query';
|
||||
import type { QueryStatus } from '@tanstack/svelte-query';
|
||||
import {
|
||||
UNIFIED_FONTS,
|
||||
generateMockFonts,
|
||||
@@ -10,20 +10,20 @@ const { Story } = defineMeta({
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Loads a font and applies it to children. Shows blur/scale loading state until font is ready, then reveals with a smooth transition.',
|
||||
'Applies a font to its children based on the supplied load `status`. Renders the skeleton (or system font) until status is `loaded`/`error`, then reveals the font. The status is provided by the composing widget — the component does not read the lifecycle store itself.',
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
layout: 'centered',
|
||||
},
|
||||
argTypes: {
|
||||
weight: { control: 'number' },
|
||||
status: { control: 'select', options: ['loading', 'loaded', 'error'] },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { mockUnifiedFont } from '$entities/Font/lib/mocks';
|
||||
import { mockUnifiedFont } from '$entities/Font/testing';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
const fontUnknown = mockUnifiedFont({ id: 'nonexistent-font-xk92z', name: 'Nonexistent Font Xk92z' });
|
||||
@@ -39,11 +39,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Font that has never been loaded by fontLifecycleManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
|
||||
'Status is `loading`: the font file has not resolved yet, so children render in the skeleton (or system font) fallback rather than the target font.',
|
||||
},
|
||||
},
|
||||
}}
|
||||
args={{ font: fontUnknown, weight: 400 }}
|
||||
args={{ font: fontUnknown, status: 'loading' }}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||
<FontApplicator {...args}>
|
||||
@@ -58,11 +58,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Uses Arial, a system font available in all browsers. Because fontLifecycleManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
|
||||
'Status is `loaded`: the component reveals the font, applying it to its children (Arial here, available in all browsers).',
|
||||
},
|
||||
},
|
||||
}}
|
||||
args={{ font: fontArial, weight: 400 }}
|
||||
args={{ font: fontArial, status: 'loaded' }}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||
<FontApplicator {...args}>
|
||||
@@ -72,16 +72,16 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Custom Weight"
|
||||
name="Error State"
|
||||
parameters={{
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Demonstrates passing a custom weight (700). The weight is forwarded to fontLifecycleManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
|
||||
'Status is `error`: the font failed to load. The component still reveals (it treats `error` like `loaded` for reveal purposes) so children are not stuck behind the skeleton — they fall back to the system font.',
|
||||
},
|
||||
},
|
||||
}}
|
||||
args={{ font: fontArialBold, weight: 700 }}
|
||||
args={{ font: fontArialBold, status: 'error' }}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||
<FontApplicator {...args}>
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
type UnifiedFont,
|
||||
fontLifecycleManager,
|
||||
} from '../../model';
|
||||
import type {
|
||||
FontLoadStatus,
|
||||
UnifiedFont,
|
||||
} from '../../model/types';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -18,10 +17,13 @@ interface Props {
|
||||
*/
|
||||
font: UnifiedFont;
|
||||
/**
|
||||
* Font weight
|
||||
* @default 400
|
||||
* Current load status for this font, supplied by the composing layer.
|
||||
* Kept out of the component so it does not depend on (and import) the
|
||||
* lifecycle store — the owning widget reads the manager and passes the
|
||||
* resolved status down. `undefined` means the font is not tracked yet and
|
||||
* is treated as not-yet-revealed (skeleton / system-font fallback).
|
||||
*/
|
||||
weight?: number;
|
||||
status: FontLoadStatus | undefined;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
@@ -39,20 +41,12 @@ interface Props {
|
||||
|
||||
let {
|
||||
font,
|
||||
weight = DEFAULT_FONT_WEIGHT,
|
||||
status,
|
||||
className,
|
||||
children,
|
||||
skeleton,
|
||||
}: Props = $props();
|
||||
|
||||
const status = $derived(
|
||||
fontLifecycleManager.getFontStatus(
|
||||
font.id,
|
||||
weight,
|
||||
font.features?.isVariable,
|
||||
),
|
||||
);
|
||||
|
||||
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||
</script>
|
||||
|
||||
|
||||
+19
-2
@@ -4,7 +4,7 @@ import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import FontSampler from './FontSampler.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Features/FontSampler',
|
||||
title: 'Entities/Font/FontSampler',
|
||||
component: FontSampler,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
@@ -21,6 +21,11 @@ const { Story } = defineMeta({
|
||||
control: 'object',
|
||||
description: 'Font information object',
|
||||
},
|
||||
status: {
|
||||
control: 'select',
|
||||
options: ['loading', 'loaded', 'error'],
|
||||
description: 'Font-load status, supplied by the composing widget and forwarded to FontApplicator',
|
||||
},
|
||||
text: {
|
||||
control: 'text',
|
||||
description: 'Editable sample text (two-way bindable)',
|
||||
@@ -34,8 +39,8 @@ const { Story } = defineMeta({
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { UnifiedFont } from '$entities/Font';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
|
||||
// Mock fonts for testing
|
||||
const mockArial: UnifiedFont = {
|
||||
@@ -79,14 +84,24 @@ const mockGeorgia: UnifiedFont = {
|
||||
isVariable: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Stand-in for the AdjustTypography store the composing widget injects.
|
||||
const mockTypography = {
|
||||
renderedSize: 48,
|
||||
weight: 400,
|
||||
height: 1.5,
|
||||
spacing: 0,
|
||||
};
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
args={{
|
||||
font: mockArial,
|
||||
status: 'loaded',
|
||||
text: 'The quick brown fox jumps over the lazy dog',
|
||||
index: 0,
|
||||
typography: mockTypography,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||
@@ -101,9 +116,11 @@ const mockGeorgia: UnifiedFont = {
|
||||
name="Long Text"
|
||||
args={{
|
||||
font: mockGeorgia,
|
||||
status: 'loaded',
|
||||
text:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
|
||||
index: 1,
|
||||
typography: mockTypography,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||
+54
-24
@@ -4,11 +4,6 @@
|
||||
Visual design matches FontCard: sharp corners, red hover accent, header stats.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontApplicator,
|
||||
type UnifiedFont,
|
||||
} from '$entities/Font';
|
||||
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
||||
import {
|
||||
Badge,
|
||||
ContentEditable,
|
||||
@@ -17,12 +12,47 @@ import {
|
||||
Stat,
|
||||
} from '$shared/ui';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type {
|
||||
FontLoadStatus,
|
||||
UnifiedFont,
|
||||
} from '../../model/types';
|
||||
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
|
||||
|
||||
/**
|
||||
* Minimal typography contract this view renders with. The AdjustTypography
|
||||
* store satisfies it structurally; defining it here keeps the entity decoupled
|
||||
* from that feature (no entity -> feature import).
|
||||
*/
|
||||
interface FontSampleTypography {
|
||||
/**
|
||||
* Rendered font size in px
|
||||
*/
|
||||
renderedSize: number;
|
||||
/**
|
||||
* Numeric font weight
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Line-height multiplier
|
||||
*/
|
||||
height: number;
|
||||
/**
|
||||
* Letter spacing
|
||||
*/
|
||||
spacing: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Font info
|
||||
*/
|
||||
font: UnifiedFont;
|
||||
/**
|
||||
* Current font-load status, supplied by the composing widget so this
|
||||
* component (and FontApplicator) stay decoupled from the lifecycle store.
|
||||
* `undefined` means not tracked yet (treated as not-yet-revealed).
|
||||
*/
|
||||
status: FontLoadStatus | undefined;
|
||||
/**
|
||||
* Sample text
|
||||
*/
|
||||
@@ -32,12 +62,15 @@ interface Props {
|
||||
* @default 0
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* Typography settings to render the sample with. Injected by the composing
|
||||
* widget (which owns the AdjustTypography store) so this entity view stays
|
||||
* decoupled from that feature — the same inversion as `status`.
|
||||
*/
|
||||
typography: FontSampleTypography;
|
||||
}
|
||||
|
||||
let { font, text = $bindable(), index = 0 }: Props = $props();
|
||||
|
||||
// Adjust the property name to match your UnifiedFont type
|
||||
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
|
||||
let { font, status, text = $bindable(), index = 0, typography }: Props = $props();
|
||||
|
||||
// Extract provider badge with fallback
|
||||
const providerBadge = $derived(
|
||||
@@ -46,10 +79,10 @@ const providerBadge = $derived(
|
||||
);
|
||||
|
||||
const stats = $derived([
|
||||
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
|
||||
{ label: 'WGT', value: `${typographySettingsStore.weight}` },
|
||||
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
|
||||
{ label: 'LTR', value: `${typographySettingsStore.spacing}` },
|
||||
{ label: 'SZ', value: `${typography.renderedSize}PX` },
|
||||
{ label: 'WGT', value: `${typography.weight}` },
|
||||
{ label: 'LH', value: typography.height.toFixed(2) },
|
||||
{ label: 'LTR', value: `${typography.spacing}` },
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -67,9 +100,8 @@ const stats = $derived([
|
||||
min-h-60
|
||||
rounded-none
|
||||
"
|
||||
style:font-weight={typographySettingsStore.weight}
|
||||
style:font-weight={typography.weight}
|
||||
>
|
||||
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
flex items-center justify-between
|
||||
@@ -91,9 +123,9 @@ const stats = $derived([
|
||||
{font.name}
|
||||
</span>
|
||||
|
||||
{#if fontType}
|
||||
{#if font?.category}
|
||||
<Badge size="xs" variant="default" nowrap>
|
||||
{fontType}
|
||||
{font?.category}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
@@ -130,19 +162,18 @@ const stats = $derived([
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Main content area ──────────────────────────────────────────── -->
|
||||
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
|
||||
<FontApplicator {font} weight={typographySettingsStore.weight}>
|
||||
<FontApplicator {font} {status}>
|
||||
<ContentEditable
|
||||
bind:text
|
||||
fontSize={typographySettingsStore.renderedSize}
|
||||
lineHeight={typographySettingsStore.height}
|
||||
letterSpacing={typographySettingsStore.spacing}
|
||||
fontSize={typography.renderedSize}
|
||||
lineHeight={typography.height}
|
||||
letterSpacing={typography.spacing}
|
||||
/>
|
||||
</FontApplicator>
|
||||
</div>
|
||||
|
||||
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
|
||||
<!-- Mobile stats footer; md:hidden because the header stats take over above -->
|
||||
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-subtle flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
|
||||
{#each stats as stat, i}
|
||||
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
||||
@@ -154,7 +185,6 @@ const stats = $derived([
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- ── Red hover line ─────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
absolute bottom-0 left-0 right-0
|
||||
@@ -5,21 +5,18 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { debounce } from '$shared/lib/utils';
|
||||
import {
|
||||
Skeleton,
|
||||
VirtualList,
|
||||
} from '$shared/ui';
|
||||
import { VirtualList } from '$shared/ui';
|
||||
import type {
|
||||
ComponentProps,
|
||||
Snippet,
|
||||
} from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { getFontUrl } from '../../lib';
|
||||
import { createFontLoadRequestContfig } from '../../lib/createFontLoadRequestContfig/createFontLoadRequestContfig';
|
||||
import {
|
||||
type FontLoadRequestConfig,
|
||||
type UnifiedFont,
|
||||
fontCatalogStore,
|
||||
fontLifecycleManager,
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
} from '../../model';
|
||||
|
||||
interface Props extends
|
||||
@@ -55,17 +52,28 @@ let {
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const isLoading = $derived(
|
||||
fontCatalogStore.isFetching || fontCatalogStore.isLoading,
|
||||
);
|
||||
const fontCatalog = getFontCatalog();
|
||||
const fontLifecycleManager = getFontLifecycleManager();
|
||||
|
||||
const isLoading = $derived<boolean>(fontCatalog?.isLoading);
|
||||
const isFetching = $derived<boolean>(fontCatalog.isFetching);
|
||||
const hasMore = $derived<boolean>(fontCatalog?.pagination?.hasMore);
|
||||
const fonts = $derived<UnifiedFont[]>(fontCatalog.fonts);
|
||||
const total = $derived<number>(fontCatalog?.pagination.total);
|
||||
|
||||
let visibleFonts = $state<UnifiedFont[]>([]);
|
||||
let isCatchingUp = $state(false);
|
||||
let isCatchingUp = $state<boolean>(false);
|
||||
|
||||
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0);
|
||||
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
|
||||
const showInitialSkeleton = $derived.by(() => (
|
||||
!!skeleton && (isLoading || isFetching) && fontCatalog.fonts.length === 0
|
||||
));
|
||||
const showCatchupSkeleton = $derived.by(() => (
|
||||
!!skeleton && isCatchingUp
|
||||
));
|
||||
// Settled query with no matches — empty state replaces the (otherwise blank) list.
|
||||
const showEmpty = $derived(!!empty && !isLoading && !isCatchingUp && fontCatalogStore.fonts.length === 0);
|
||||
const showEmpty = $derived.by(() => (
|
||||
!!empty && !(isLoading || isFetching) && !isCatchingUp && fontCatalog.fonts.length === 0
|
||||
));
|
||||
|
||||
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||
visibleFonts = items;
|
||||
@@ -79,12 +87,12 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||
* font files for thousands of intermediate fonts.
|
||||
*/
|
||||
async function handleJump(targetIndex: number) {
|
||||
if (isCatchingUp || !fontCatalogStore.pagination.hasMore) {
|
||||
if (isCatchingUp || !hasMore) {
|
||||
return;
|
||||
}
|
||||
isCatchingUp = true;
|
||||
try {
|
||||
await fontCatalogStore.fetchAllPagesTo(targetIndex);
|
||||
await fontCatalog.fetchAllPagesTo(targetIndex);
|
||||
} finally {
|
||||
isCatchingUp = false;
|
||||
}
|
||||
@@ -105,13 +113,7 @@ $effect(() => {
|
||||
if (isCatchingUp) {
|
||||
return;
|
||||
}
|
||||
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
|
||||
const url = getFontUrl(item, weight);
|
||||
if (!url) {
|
||||
return [];
|
||||
}
|
||||
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
|
||||
});
|
||||
const configs = visibleFonts.flatMap(item => createFontLoadRequestContfig(item, weight));
|
||||
if (configs.length > 0) {
|
||||
debouncedTouch(configs);
|
||||
}
|
||||
@@ -137,13 +139,11 @@ $effect(() => {
|
||||
* Load more fonts by moving to the next page
|
||||
*/
|
||||
function loadMore() {
|
||||
if (
|
||||
!fontCatalogStore.pagination.hasMore
|
||||
|| fontCatalogStore.isFetching
|
||||
) {
|
||||
if (!hasMore || isFetching) {
|
||||
return;
|
||||
}
|
||||
fontCatalogStore.nextPage();
|
||||
|
||||
fontCatalog.nextPage();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,12 +153,10 @@ function loadMore() {
|
||||
* of the loaded items. Only fetches if there are more pages available.
|
||||
*/
|
||||
function handleNearBottom(_lastVisibleIndex: number) {
|
||||
const { hasMore } = fontCatalogStore.pagination;
|
||||
|
||||
// VirtualList already checks if we're near the bottom of loaded items.
|
||||
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
|
||||
// during batch catch-up, which would otherwise let nextPage() race with it.
|
||||
if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) {
|
||||
if (hasMore && !isFetching && !isCatchingUp) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
@@ -177,9 +175,9 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
||||
{:else}
|
||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||
<VirtualList
|
||||
items={fontCatalogStore.fonts}
|
||||
total={fontCatalogStore.pagination.total}
|
||||
isLoading={isLoading || isCatchingUp}
|
||||
items={fonts}
|
||||
{total}
|
||||
isLoading={isLoading || isFetching || isCatchingUp}
|
||||
onVisibleItemsChange={handleInternalVisibleChange}
|
||||
onNearBottom={handleNearBottom}
|
||||
onJump={handleJump}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||
|
||||
export {
|
||||
FontApplicator,
|
||||
FontSampler,
|
||||
FontVirtualList,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export {
|
||||
createTypographySettingsStore,
|
||||
getTypographySettingsStore,
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
type TypographySettingsStore,
|
||||
typographySettingsStore,
|
||||
} from './model';
|
||||
export { TypographyMenu } from './ui';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LETTER_SPACING_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LETTER_SPACING,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LETTER_SPACING,
|
||||
MIN_LINE_HEIGHT,
|
||||
} from '$entities/Font';
|
||||
import type {
|
||||
ControlId,
|
||||
ControlModel,
|
||||
} from '../types/typography';
|
||||
|
||||
/**
|
||||
* Responsive font-size scaling factors applied by typographySettingsStore.
|
||||
*/
|
||||
export const MULTIPLIER_S = 0.5;
|
||||
export const MULTIPLIER_M = 0.75;
|
||||
export const MULTIPLIER_L = 1;
|
||||
|
||||
/**
|
||||
* Default control definitions seeding the typography settings store.
|
||||
* Composed from the font-render ranges/defaults owned by the Font entity.
|
||||
*/
|
||||
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
||||
{
|
||||
id: 'font_size',
|
||||
value: DEFAULT_FONT_SIZE,
|
||||
max: MAX_FONT_SIZE,
|
||||
min: MIN_FONT_SIZE,
|
||||
step: FONT_SIZE_STEP,
|
||||
increaseLabel: 'Increase Font Size',
|
||||
decreaseLabel: 'Decrease Font Size',
|
||||
controlLabel: 'Size',
|
||||
},
|
||||
{
|
||||
id: 'font_weight',
|
||||
value: DEFAULT_FONT_WEIGHT,
|
||||
max: MAX_FONT_WEIGHT,
|
||||
min: MIN_FONT_WEIGHT,
|
||||
step: FONT_WEIGHT_STEP,
|
||||
increaseLabel: 'Increase Font Weight',
|
||||
decreaseLabel: 'Decrease Font Weight',
|
||||
controlLabel: 'Weight',
|
||||
},
|
||||
{
|
||||
id: 'line_height',
|
||||
value: DEFAULT_LINE_HEIGHT,
|
||||
max: MAX_LINE_HEIGHT,
|
||||
min: MIN_LINE_HEIGHT,
|
||||
step: LINE_HEIGHT_STEP,
|
||||
increaseLabel: 'Increase Line Height',
|
||||
decreaseLabel: 'Decrease Line Height',
|
||||
controlLabel: 'Leading',
|
||||
},
|
||||
{
|
||||
id: 'letter_spacing',
|
||||
value: DEFAULT_LETTER_SPACING,
|
||||
max: MAX_LETTER_SPACING,
|
||||
min: MIN_LETTER_SPACING,
|
||||
step: LETTER_SPACING_STEP,
|
||||
increaseLabel: 'Increase Letter Spacing',
|
||||
decreaseLabel: 'Decrease Letter Spacing',
|
||||
controlLabel: 'Tracking',
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,10 @@
|
||||
export {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from './const/const';
|
||||
export {
|
||||
createTypographySettingsStore,
|
||||
getTypographySettingsStore,
|
||||
type TypographySettingsStore,
|
||||
typographySettingsStore,
|
||||
} from './store/typographySettingsStore/typographySettingsStore.svelte';
|
||||
|
||||
+42
-17
@@ -11,22 +11,27 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
type ControlId,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
} from '$entities/Font';
|
||||
// Deep path (not the root barrel) on purpose: pulls only these pure
|
||||
// constants, not the entity's UI/store graph (+ @tanstack) — keeps this
|
||||
// feature store and its spec light at import. See audit D-1.
|
||||
} from '$entities/Font/model/const/const';
|
||||
import {
|
||||
type ControlDataModel,
|
||||
type ControlModel,
|
||||
type PersistentStore,
|
||||
type TypographyControl,
|
||||
createPersistentStore,
|
||||
createTypographyControl,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
import type { NumericControl } from '$shared/ui';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../../const/const';
|
||||
import type {
|
||||
ControlId,
|
||||
ControlModel,
|
||||
} from '../../types/typography';
|
||||
import { createTypographyControl } from '../../typographyControl/createTypographyControl.svelte';
|
||||
|
||||
/**
|
||||
* Epsilon for detecting "significant" base-size changes when reconciling
|
||||
@@ -36,7 +41,7 @@ import { SvelteMap } from 'svelte/reactivity';
|
||||
*/
|
||||
const BASE_SIZE_EPSILON = 0.01;
|
||||
|
||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, 'value' | 'min' | 'max' | 'step'>;
|
||||
|
||||
/**
|
||||
* A control with its associated instance
|
||||
@@ -45,7 +50,7 @@ export interface Control extends ControlOnlyFields<ControlId> {
|
||||
/**
|
||||
* The reactive typography control instance
|
||||
*/
|
||||
instance: TypographyControl;
|
||||
instance: NumericControl;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +98,12 @@ export class TypographySettingsStore {
|
||||
* The underlying font size before responsive scaling is applied
|
||||
*/
|
||||
#baseSize = $state(DEFAULT_FONT_SIZE);
|
||||
/**
|
||||
* Disposes the $effect.root that backs the storage-sync effects.
|
||||
* $effect.root lives outside component lifecycle, so callers must invoke
|
||||
* destroy() to avoid leaking the subscriptions.
|
||||
*/
|
||||
#disposeEffects: () => void;
|
||||
|
||||
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
||||
this.#storage = storage;
|
||||
@@ -116,7 +127,7 @@ export class TypographySettingsStore {
|
||||
|
||||
// The Sync Effect (UI -> Storage)
|
||||
// We access .value explicitly to ensure Svelte 5 tracks the dependency
|
||||
$effect.root(() => {
|
||||
this.#disposeEffects = $effect.root(() => {
|
||||
$effect(() => {
|
||||
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
|
||||
const fontSize = this.#baseSize;
|
||||
@@ -154,6 +165,14 @@ export class TypographySettingsStore {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down the storage-sync effects. Call on unmount / store disposal.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.#disposeEffects();
|
||||
this.#storage.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets initial value for a control from storage or defaults
|
||||
*/
|
||||
@@ -288,9 +307,6 @@ export class TypographySettingsStore {
|
||||
if (c.id === 'font_size') {
|
||||
c.instance.value = defaults.fontSize * this.#multiplier;
|
||||
} else {
|
||||
// Map storage key to control id
|
||||
const key = c.id.replace('_', '') as keyof TypographySettings;
|
||||
// Simplified for brevity, you'd map these properly:
|
||||
if (c.id === 'font_weight') {
|
||||
c.instance.value = defaults.fontWeight;
|
||||
}
|
||||
@@ -335,10 +351,19 @@ export function createTypographySettingsStore(
|
||||
return new TypographySettingsStore(configs, storage);
|
||||
}
|
||||
|
||||
export type TypographySettingsStoreInstance = ReturnType<typeof createTypographySettingsStore>;
|
||||
|
||||
/**
|
||||
* App-wide typography settings singleton, keyed for the comparison view.
|
||||
* App-wide typography settings store, keyed for the comparison view.
|
||||
* Created on first access so its persistent-store sync effects aren't set up
|
||||
* at module load.
|
||||
*/
|
||||
export const typographySettingsStore = createTypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
COMPARISON_STORAGE_KEY,
|
||||
const typographySettingsStore = createSingleton(
|
||||
() => createTypographySettingsStore(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, COMPARISON_STORAGE_KEY),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getTypographySettingsStore = typographySettingsStore.get;
|
||||
|
||||
// test-only reset, so specs don't share persisted typography state or leak effects
|
||||
export const __resetTypographySettingsStore = typographySettingsStore.reset;
|
||||
|
||||
+5
-2
@@ -6,8 +6,7 @@ import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
} from '$entities/Font';
|
||||
} from '$entities/Font/model/const/const';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../../const/const';
|
||||
import {
|
||||
type TypographySettings,
|
||||
TypographySettingsStore,
|
||||
@@ -51,6 +51,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
||||
let mockPersistentStore: {
|
||||
value: TypographySettings;
|
||||
clear: () => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
const createMockPersistentStore = (initialValue: TypographySettings) => {
|
||||
@@ -70,6 +71,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||
};
|
||||
},
|
||||
destroy() {},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -535,6 +537,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
||||
mockStorage = v;
|
||||
},
|
||||
clear: clearSpy,
|
||||
destroy() {},
|
||||
};
|
||||
|
||||
const manager = new TypographySettingsStore(
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
ControlLabels,
|
||||
NumericControl,
|
||||
} from '$shared/ui';
|
||||
|
||||
/**
|
||||
* Identifiers for the adjustable typography axes
|
||||
*/
|
||||
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||
|
||||
/**
|
||||
* Static configuration for one typography control.
|
||||
*
|
||||
* Derived from the SSOT contract types — declares no fields of its own beyond
|
||||
* the domain `id`. Bounds come from NumericControl, labels from ControlLabels.
|
||||
*
|
||||
* @template T - Control identifier type
|
||||
*/
|
||||
export type ControlModel<T extends string = string> =
|
||||
& Pick<NumericControl, 'value' | 'min' | 'max' | 'step'>
|
||||
& ControlLabels
|
||||
& {
|
||||
/**
|
||||
* Unique identifier for the control
|
||||
*/
|
||||
id: T;
|
||||
};
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Bounded numeric control for typography settings.
|
||||
*
|
||||
* Produces a reactive control that clamps to [min, max] and rounds to step.
|
||||
* Implements the NumericControl contract that ComboControl renders.
|
||||
*/
|
||||
import {
|
||||
clampNumber,
|
||||
roundToStepPrecision,
|
||||
} from '$shared/lib/utils';
|
||||
import type { NumericControl } from '$shared/ui';
|
||||
|
||||
/**
|
||||
* Bounds + initial value seed for a control
|
||||
*/
|
||||
type ControlSeed = Pick<NumericControl, 'value' | 'min' | 'max' | 'step'>;
|
||||
|
||||
/**
|
||||
* Create a reactive bounded numeric control.
|
||||
*
|
||||
* @param initialState - Initial value and bounds
|
||||
* @returns A NumericControl whose value is always clamped and step-rounded
|
||||
*/
|
||||
export function createTypographyControl(initialState: ControlSeed): NumericControl {
|
||||
let value = $state(initialState.value);
|
||||
let max = $state(initialState.max);
|
||||
let min = $state(initialState.min);
|
||||
let step = $state(initialState.step);
|
||||
|
||||
const { isAtMax, isAtMin } = $derived({
|
||||
isAtMax: value >= max,
|
||||
isAtMin: value <= min,
|
||||
});
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(newValue) {
|
||||
const rounded = roundToStepPrecision(clampNumber(newValue, min, max), step);
|
||||
if (value !== rounded) {
|
||||
value = rounded;
|
||||
}
|
||||
},
|
||||
get max() {
|
||||
return max;
|
||||
},
|
||||
get min() {
|
||||
return min;
|
||||
},
|
||||
get step() {
|
||||
return step;
|
||||
},
|
||||
get isAtMax() {
|
||||
return isAtMax;
|
||||
},
|
||||
get isAtMin() {
|
||||
return isAtMin;
|
||||
},
|
||||
increase() {
|
||||
value = roundToStepPrecision(clampNumber(value + step, min, max), step);
|
||||
},
|
||||
decrease() {
|
||||
value = roundToStepPrecision(clampNumber(value - step, min, max), step);
|
||||
},
|
||||
};
|
||||
}
|
||||
+3
-5
@@ -1,12 +1,10 @@
|
||||
import {
|
||||
type TypographyControl,
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import type { NumericControl } from '$shared/ui';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { createTypographyControl } from './createTypographyControl.svelte';
|
||||
|
||||
/**
|
||||
* Test Strategy for createTypographyControl Helper
|
||||
@@ -34,7 +32,7 @@ describe('createTypographyControl - Unit Tests', () => {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}): TypographyControl {
|
||||
}): NumericControl {
|
||||
return createTypographyControl({
|
||||
value: initialValue,
|
||||
min: options?.min ?? 0,
|
||||
@@ -5,26 +5,26 @@
|
||||
Desktop: inline bar with combo controls.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
Button,
|
||||
ComboControl,
|
||||
ControlGroup,
|
||||
Popover,
|
||||
Slider,
|
||||
} from '$shared/ui';
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { Popover } from 'bits-ui';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { typographySettingsStore } from '../../model';
|
||||
import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
getTypographySettingsStore,
|
||||
} from '../../model';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -46,6 +46,7 @@ interface Props {
|
||||
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
const typographySettingsStore = getTypographySettingsStore();
|
||||
|
||||
/**
|
||||
* Sets the common font size multiplier based on the current responsive state.
|
||||
@@ -73,33 +74,21 @@ $effect(() => {
|
||||
{#if !hidden}
|
||||
{#if responsive.isMobileOrTablet}
|
||||
<div class={className}>
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Popover bind:open side="top" align="end" sideOffset={8}>
|
||||
{#snippet trigger(props)}
|
||||
<Button variant="primary" {...props}>
|
||||
{#snippet icon()}
|
||||
<Settings2Icon class="size-4" />
|
||||
{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
{#snippet children({ close })}
|
||||
<div
|
||||
class={cn(
|
||||
'z-50 w-72 p-4 rounded-none',
|
||||
'w-72 p-4 rounded-none',
|
||||
'surface-popover',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=top]:slide-in-from-bottom-2',
|
||||
'data-[side=bottom]:slide-in-from-top-2',
|
||||
)}
|
||||
interactOutsideBehavior="close"
|
||||
escapeKeydownBehavior="close"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
||||
@@ -111,17 +100,13 @@ $effect(() => {
|
||||
CONTROLS
|
||||
</span>
|
||||
</div>
|
||||
<Popover.Close>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
onclick={close}
|
||||
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
aria-label="Close controls"
|
||||
>
|
||||
<XIcon class="size-3.5 text-neutral-500" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Popover.Close>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
@@ -135,9 +120,9 @@ $effect(() => {
|
||||
/>
|
||||
</ControlGroup>
|
||||
{/each}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
|
||||
* import { onMount } from 'svelte';
|
||||
*
|
||||
* onMount(() => {
|
||||
@@ -26,8 +26,8 @@
|
||||
*/
|
||||
|
||||
export {
|
||||
getScrollBreadcrumbsStore,
|
||||
type NavigationAction,
|
||||
scrollBreadcrumbsStore,
|
||||
} from './model';
|
||||
export {
|
||||
BreadcrumbHeader,
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
__resetScrollBreadcrumbsStore,
|
||||
createScrollBreadcrumbsStore,
|
||||
getScrollBreadcrumbsStore,
|
||||
} from './store/scrollBreadcrumbsStore.svelte';
|
||||
export type { BreadcrumbItem } from './store/scrollBreadcrumbsStore.svelte';
|
||||
export type { NavigationAction } from './types/types.ts';
|
||||
+20
-3
@@ -1,3 +1,5 @@
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
|
||||
/**
|
||||
* Scroll-based breadcrumb tracking store
|
||||
*
|
||||
@@ -15,7 +17,7 @@
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
|
||||
*
|
||||
* onMount(() => {
|
||||
* scrollBreadcrumbsStore.add({
|
||||
@@ -167,6 +169,13 @@ class ScrollBreadcrumbsStore {
|
||||
this.#detachScrollListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down the observer and scroll listener. Call on store disposal.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.#disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* All tracked items sorted by index
|
||||
*/
|
||||
@@ -273,6 +282,14 @@ export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton scroll breadcrumbs store instance
|
||||
* App-wide scroll breadcrumbs store, created on first access.
|
||||
*/
|
||||
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
|
||||
const scrollBreadcrumbsStore = createSingleton(
|
||||
() => createScrollBreadcrumbsStore(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getScrollBreadcrumbsStore = scrollBreadcrumbsStore.get;
|
||||
|
||||
// test-only reset, so specs don't share observer/scroll state
|
||||
export const __resetScrollBreadcrumbsStore = scrollBreadcrumbsStore.reset;
|
||||
+2
-3
@@ -70,7 +70,6 @@ class MockIntersectionObserver implements IntersectionObserver {
|
||||
describe('ScrollBreadcrumbsStore', () => {
|
||||
let scrollListeners: Array<() => void> = [];
|
||||
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||
let scrollToSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
// Helper to create mock elements
|
||||
@@ -111,7 +110,7 @@ describe('ScrollBreadcrumbsStore', () => {
|
||||
|
||||
// Track scroll event listeners
|
||||
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
|
||||
(event: string, listener: EventListenerOrEventListenerObject, options?: any) => {
|
||||
(event: string, listener: EventListenerOrEventListenerObject, _options?: any) => {
|
||||
if (event === 'scroll') {
|
||||
scrollListeners.push(listener as () => void);
|
||||
}
|
||||
@@ -119,7 +118,7 @@ describe('ScrollBreadcrumbsStore', () => {
|
||||
},
|
||||
);
|
||||
|
||||
removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation(
|
||||
vi.spyOn(window, 'removeEventListener').mockImplementation(
|
||||
(event: string, listener: EventListenerOrEventListenerObject) => {
|
||||
if (event === 'scroll') {
|
||||
const index = scrollListeners.indexOf(listener as () => void);
|
||||
+2
-1
@@ -14,9 +14,10 @@ import { cubicOut } from 'svelte/easing';
|
||||
import { slide } from 'svelte/transition';
|
||||
import {
|
||||
type BreadcrumbItem,
|
||||
scrollBreadcrumbsStore,
|
||||
getScrollBreadcrumbsStore,
|
||||
} from '../../model';
|
||||
|
||||
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
|
||||
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
+9
-3
@@ -1,18 +1,24 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { scrollBreadcrumbsStore } from '../../model';
|
||||
import { getScrollBreadcrumbsStore } from '../../model';
|
||||
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
|
||||
|
||||
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
|
||||
|
||||
const sections = [
|
||||
{ index: 100, title: 'Introduction' },
|
||||
{ index: 101, title: 'Typography' },
|
||||
{ index: 102, title: 'Spacing' },
|
||||
];
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
let container;
|
||||
/** @type {HTMLDivElement | undefined} */
|
||||
let container = $state();
|
||||
|
||||
onMount(() => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const section of sections) {
|
||||
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
|
||||
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
|
||||
+3
-1
@@ -6,9 +6,11 @@
|
||||
import { type Snippet } from 'svelte';
|
||||
import {
|
||||
type NavigationAction,
|
||||
scrollBreadcrumbsStore,
|
||||
getScrollBreadcrumbsStore,
|
||||
} from '../../model';
|
||||
|
||||
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Navigation index
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
export { getThemeManager } from './model';
|
||||
export { ThemeSwitch } from './ui';
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { themeManager } from './store/ThemeManager/ThemeManager.svelte';
|
||||
export { getThemeManager } from './store/ThemeManager/ThemeManager.svelte';
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
import {
|
||||
createPersistentStore,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
|
||||
export const STORAGE_KEY = 'glyphdiff:theme';
|
||||
|
||||
@@ -125,6 +128,7 @@ class ThemeManager {
|
||||
destroy(): void {
|
||||
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
||||
this.#mediaQuery = null;
|
||||
this.#store.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,14 +199,20 @@ class ThemeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton theme manager instance
|
||||
* App-wide theme manager, created on first access.
|
||||
*
|
||||
* Use throughout the app for consistent theme state.
|
||||
* Lazy so its persistent-store subscription isn't set up at module load.
|
||||
* Call init() on mount and destroy() on unmount (see Layout).
|
||||
*/
|
||||
export const themeManager = new ThemeManager();
|
||||
const themeManager = createSingleton(() => new ThemeManager(), instance => instance.destroy());
|
||||
|
||||
export const getThemeManager = themeManager.get;
|
||||
|
||||
// test-only reset, so specs don't share persisted theme state
|
||||
export const __resetThemeManager = themeManager.reset;
|
||||
|
||||
/**
|
||||
* ThemeManager class exported for testing purposes
|
||||
* Use the singleton `themeManager` in application code.
|
||||
* Use the `getThemeManager()` accessor in application code.
|
||||
*/
|
||||
export { ThemeManager };
|
||||
|
||||
@@ -22,8 +22,9 @@ const { Story } = defineMeta({
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { themeManager } from '$features/ChangeAppTheme';
|
||||
import { getThemeManager } from '$features/ChangeAppTheme';
|
||||
|
||||
const themeManager = getThemeManager();
|
||||
// Current theme state for display
|
||||
const currentTheme = $derived(themeManager.value);
|
||||
const themeSource = $derived(themeManager.source);
|
||||
|
||||
@@ -8,10 +8,11 @@ import { IconButton } from '$shared/ui';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
import { getContext } from 'svelte';
|
||||
import { themeManager } from '../../model';
|
||||
import { getThemeManager } from '../../model';
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
const themeManager = getThemeManager();
|
||||
const theme = $derived(themeManager.value);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,16 +3,25 @@ import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/svelte';
|
||||
import { themeManager } from '../../model';
|
||||
import { afterEach } from 'vitest';
|
||||
import { getThemeManager } from '../../model';
|
||||
import { __resetThemeManager } from '../../model/store/ThemeManager/ThemeManager.svelte';
|
||||
import ThemeSwitch from './ThemeSwitch.svelte';
|
||||
|
||||
const context = new Map([['responsive', { isMobile: false }]]);
|
||||
|
||||
describe('ThemeSwitch', () => {
|
||||
let themeManager: ReturnType<typeof getThemeManager>;
|
||||
|
||||
beforeEach(() => {
|
||||
themeManager = getThemeManager();
|
||||
themeManager.setTheme('light');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetThemeManager();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders an icon button', () => {
|
||||
render(ThemeSwitch, { context });
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { FontSampler } from './ui';
|
||||
@@ -1,3 +0,0 @@
|
||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||
|
||||
export { FontSampler };
|
||||
@@ -1 +0,0 @@
|
||||
export { FontsByIdsStore } from './model';
|
||||
@@ -1 +0,0 @@
|
||||
export { FontsByIdsStore } from './store/fontsByIdsStore/fontsByIdsStore.svelte';
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { API_ENDPOINTS } from '$shared/api/endpoints';
|
||||
import { NonRetryableError } from '$shared/api/queryClient';
|
||||
import { NonRetryableError } from '$shared/api/nonRetryableError';
|
||||
|
||||
const PROXY_API_URL = API_ENDPOINTS.filters;
|
||||
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
export * from './filters/filters';
|
||||
export { fetchProxyFilters } from './filters/filters';
|
||||
export type {
|
||||
FilterMetadata,
|
||||
FilterOption,
|
||||
ProxyFiltersResponse,
|
||||
} from './filters/filters';
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
export { mapAppliedFiltersToParams } from './lib';
|
||||
|
||||
export {
|
||||
type AppliedFilterStore,
|
||||
appliedFilterStore,
|
||||
/**
|
||||
* Filter Store
|
||||
*/
|
||||
availableFilterStore,
|
||||
/**
|
||||
* Filter Manager
|
||||
*/
|
||||
createAppliedFilterStore,
|
||||
/**
|
||||
* Lazy store accessors
|
||||
*/
|
||||
getAppliedFilterStore,
|
||||
getAvailableFilterStore,
|
||||
getSortStore,
|
||||
/**
|
||||
* Sort Store
|
||||
*/
|
||||
SORT_MAP,
|
||||
SORT_OPTIONS,
|
||||
type SortApiValue,
|
||||
type SortOption,
|
||||
sortStore,
|
||||
startFilterBindings,
|
||||
} from './model';
|
||||
|
||||
export type {
|
||||
AppliedFilterStore,
|
||||
SortApiValue,
|
||||
SortOption,
|
||||
} from './model';
|
||||
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type {
|
||||
FilterMetadata,
|
||||
FilterOption,
|
||||
} from '../../api/filters/filters';
|
||||
import { mapFilterMetadataToGroups } from './mapFilterMetadataToGroups';
|
||||
|
||||
/**
|
||||
* Build a FilterOption with a known value and count.
|
||||
*/
|
||||
function option(value: string, count: number): FilterOption {
|
||||
return { id: value, name: value, value, count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build filter metadata for one group from (value, count) entries.
|
||||
*/
|
||||
function metadata(id: string, options: Array<[string, number]>): FilterMetadata {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: '',
|
||||
type: 'array',
|
||||
options: options.map(([value, count]) => option(value, count)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('mapFilterMetadataToGroups', () => {
|
||||
it('maps id and name onto group id and label', () => {
|
||||
const [group] = mapFilterMetadataToGroups([metadata('categories', [['serif', 1]])]);
|
||||
|
||||
expect(group.id).toBe('categories');
|
||||
expect(group.label).toBe('categories');
|
||||
});
|
||||
|
||||
it('projects each option to a property with selected: false', () => {
|
||||
const [group] = mapFilterMetadataToGroups([metadata('providers', [['google', 5]])]);
|
||||
|
||||
expect(group.properties).toEqual([
|
||||
{ id: 'google', name: 'google', value: 'google', selected: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders properties by descending count', () => {
|
||||
const [group] = mapFilterMetadataToGroups([
|
||||
metadata('subsets', [['latin', 2], ['cyrillic', 9], ['greek', 5]]),
|
||||
]);
|
||||
|
||||
expect(group.properties.map(p => p.value)).toEqual(['cyrillic', 'greek', 'latin']);
|
||||
});
|
||||
|
||||
it('does not mutate the source options array (TanStack cache safety)', () => {
|
||||
const source = metadata('subsets', [['latin', 2], ['cyrillic', 9]]);
|
||||
const originalOrder = source.options.map(o => o.value);
|
||||
|
||||
mapFilterMetadataToGroups([source]);
|
||||
|
||||
expect(source.options.map(o => o.value)).toEqual(originalOrder);
|
||||
});
|
||||
|
||||
it('maps every group, preserving group order', () => {
|
||||
const groups = mapFilterMetadataToGroups([
|
||||
metadata('providers', [['google', 1]]),
|
||||
metadata('categories', [['serif', 1]]),
|
||||
]);
|
||||
|
||||
expect(groups.map(g => g.id)).toEqual(['providers', 'categories']);
|
||||
});
|
||||
|
||||
it('returns an empty group list for empty metadata', () => {
|
||||
expect(mapFilterMetadataToGroups([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('yields an empty properties list when a group has no options', () => {
|
||||
const [group] = mapFilterMetadataToGroups([metadata('providers', [])]);
|
||||
|
||||
expect(group.properties).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FilterMetadata } from '../../api/filters/filters';
|
||||
import type { FilterGroupConfig } from '../../model';
|
||||
|
||||
/**
|
||||
* Map backend filter metadata into the group configs `appliedFilterStore.setGroups`
|
||||
* consumes.
|
||||
*
|
||||
* Inverse direction of `mapAppliedFiltersToParams`: that maps applied selections out
|
||||
* to API params; this maps the API's available-filter catalog in to the UI model.
|
||||
*
|
||||
* Options are ordered by descending font count so the most populated values surface
|
||||
* first. The source array is copied before sorting — `metadata` is TanStack-cached
|
||||
* query data, and `.sort()` mutates in place; sorting the live cache both corrupts it
|
||||
* and, when called from a reactive effect, writes into that effect's own read
|
||||
* dependency (triggering an update loop).
|
||||
*
|
||||
* Every property starts unselected; selection state is owned by the store, not the
|
||||
* backend catalog.
|
||||
*
|
||||
* @param metadata - Available-filter catalog from the filters endpoint
|
||||
* @returns Group configs ready for `setGroups`
|
||||
*/
|
||||
export function mapFilterMetadataToGroups(metadata: FilterMetadata[]): FilterGroupConfig<string>[] {
|
||||
return metadata.map(filter => ({
|
||||
id: filter.id,
|
||||
label: filter.name,
|
||||
properties: [...filter.options]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map(opt => ({
|
||||
id: opt.id,
|
||||
name: opt.name,
|
||||
value: opt.value,
|
||||
selected: false,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -14,9 +14,9 @@ export type {
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Low-level property selection store
|
||||
* Lazy accessor for the app-wide filter-metadata store
|
||||
*/
|
||||
availableFilterStore,
|
||||
getAvailableFilterStore,
|
||||
} from './store/availableFilterStore/availableFilterStore.svelte';
|
||||
|
||||
/**
|
||||
@@ -27,26 +27,30 @@ export {
|
||||
* Reactive interface returned by `createAppliedFilterStore`
|
||||
*/
|
||||
type AppliedFilterStore,
|
||||
/**
|
||||
* High-level manager for syncing search and filters
|
||||
*/
|
||||
appliedFilterStore,
|
||||
/**
|
||||
* Factory for constructing a filter manager instance
|
||||
*/
|
||||
createAppliedFilterStore,
|
||||
/**
|
||||
* Lazy accessor for the app-wide filter manager
|
||||
*/
|
||||
getAppliedFilterStore,
|
||||
} from './store/appliedFilterStore/appliedFilterStore.svelte';
|
||||
|
||||
/**
|
||||
* Side-effect import: installs the global appliedFilterStore+sortStore → fontCatalogStore
|
||||
* bridge on first import of this feature barrel. No exports.
|
||||
*/
|
||||
import './store/bindings.svelte';
|
||||
export { startFilterBindings } from './store/bindings.svelte';
|
||||
|
||||
/**
|
||||
* Sorting logic
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Lazy accessor for the app-wide sort store
|
||||
*/
|
||||
getSortStore,
|
||||
/**
|
||||
* Map of human-readable labels to API sort keys
|
||||
*/
|
||||
@@ -63,8 +67,4 @@ export {
|
||||
* UI model for a single sort option
|
||||
*/
|
||||
type SortOption,
|
||||
/**
|
||||
* Reactive store for the current sort selection
|
||||
*/
|
||||
sortStore,
|
||||
} from './store/sortStore/sortStore.svelte';
|
||||
|
||||
+23
-11
@@ -23,7 +23,10 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createFilter } from '$shared/lib';
|
||||
import {
|
||||
createFilter,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
import { createDebouncedState } from '$shared/lib/helpers';
|
||||
import type {
|
||||
FilterConfig,
|
||||
@@ -42,8 +45,13 @@ import type {
|
||||
export function createAppliedFilterStore<TValue extends string>(config: FilterConfig<TValue>) {
|
||||
const search = createDebouncedState(config.queryValue ?? '');
|
||||
|
||||
// Create filter instances upfront
|
||||
const groups = $state(
|
||||
// Create filter instances upfront.
|
||||
// `let` (not `const`) so setGroups can REASSIGN the whole array. In-place
|
||||
// `groups.length = 0; groups.push(...)` is forbidden here: push reads the
|
||||
// array's length signal, so a $effect that calls setGroups would both read
|
||||
// and write `groups.length` in one run and re-trigger itself forever
|
||||
// (effect_update_depth_exceeded).
|
||||
let groups = $state(
|
||||
config.groups.map(config => ({
|
||||
id: config.id,
|
||||
label: config.label,
|
||||
@@ -62,14 +70,11 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
|
||||
* Used when dynamic filter data loads from backend
|
||||
*/
|
||||
setGroups(newGroups: FilterGroupConfig<TValue>[]) {
|
||||
groups.length = 0;
|
||||
groups.push(
|
||||
...newGroups.map(g => ({
|
||||
groups = newGroups.map(g => ({
|
||||
id: g.id,
|
||||
label: g.label,
|
||||
instance: createFilter({ properties: g.properties }),
|
||||
})),
|
||||
);
|
||||
}));
|
||||
},
|
||||
/**
|
||||
* Current search query value (immediate, for UI binding)
|
||||
@@ -128,13 +133,20 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
|
||||
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
|
||||
|
||||
/**
|
||||
* App-wide filter manager singleton.
|
||||
* App-wide filter manager, created on first access.
|
||||
*
|
||||
* Constructed with empty groups; the availableFilterStore → appliedFilterStore wiring
|
||||
* lives in `./bindings.svelte` and populates groups once backend filter
|
||||
* metadata arrives.
|
||||
*/
|
||||
export const appliedFilterStore = createAppliedFilterStore({
|
||||
const appliedFilterStore = createSingleton(() =>
|
||||
createAppliedFilterStore<string>({
|
||||
queryValue: '',
|
||||
groups: [],
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export const getAppliedFilterStore = appliedFilterStore.get;
|
||||
|
||||
// test-only reset, so specs don't share filter/selection state
|
||||
export const __resetAppliedFilterStore = appliedFilterStore.reset;
|
||||
|
||||
-6
@@ -29,12 +29,6 @@ import { createAppliedFilterStore } from './appliedFilterStore.svelte';
|
||||
* testing Svelte 5 reactive code in Node.js.
|
||||
*/
|
||||
|
||||
// Helper to flush Svelte effects (they run in microtasks)
|
||||
async function flushEffects() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
// Helper to create test properties
|
||||
function createTestProperties(count: number, selectedIndices: number[] = []): Property<string>[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
|
||||
+14
-4
@@ -20,8 +20,9 @@ import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/fi
|
||||
import {
|
||||
DEFAULT_QUERY_GC_TIME_MS,
|
||||
DEFAULT_QUERY_STALE_TIME_MS,
|
||||
queryClient,
|
||||
getQueryClient,
|
||||
} from '$shared/api/queryClient';
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
import {
|
||||
type QueryKey,
|
||||
QueryObserver,
|
||||
@@ -49,7 +50,7 @@ export class AvailableFilterStore {
|
||||
/**
|
||||
* Shared query client
|
||||
*/
|
||||
protected qc = queryClient;
|
||||
protected qc = getQueryClient();
|
||||
|
||||
/**
|
||||
* Creates a new filters store
|
||||
@@ -127,6 +128,15 @@ export class AvailableFilterStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance
|
||||
* App-wide filter-metadata store, created on first access. Lazy so the
|
||||
* QueryObserver isn't constructed at module load.
|
||||
*/
|
||||
export const availableFilterStore = new AvailableFilterStore();
|
||||
const availableFilterStore = createSingleton(
|
||||
() => new AvailableFilterStore(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getAvailableFilterStore = availableFilterStore.get;
|
||||
|
||||
// test-only reset, so specs don't share a live observer
|
||||
export const __resetAvailableFilterStore = availableFilterStore.reset;
|
||||
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
|
||||
@@ -9,52 +9,34 @@
|
||||
* observer, so it lives at module scope, not in any individual widget.
|
||||
*/
|
||||
|
||||
import { fontCatalogStore } from '$entities/Font';
|
||||
import { getFontCatalog } from '$entities/Font';
|
||||
import { untrack } from 'svelte';
|
||||
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
|
||||
import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
|
||||
import { availableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
|
||||
import { sortStore } from './sortStore/sortStore.svelte';
|
||||
import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups';
|
||||
import { getAppliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
|
||||
import { getAvailableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
|
||||
import { getSortStore } from './sortStore/sortStore.svelte';
|
||||
|
||||
$effect.root(() => {
|
||||
/**
|
||||
* Populate appliedFilterStore groups when backend filter metadata resolves.
|
||||
* availableFilterStore is async; until it loads, appliedFilterStore has empty groups
|
||||
* and the UI renders nothing for them.
|
||||
*/
|
||||
export function startFilterBindings(): () => void {
|
||||
const appliedFilterStore = getAppliedFilterStore();
|
||||
const availableFilterStore = getAvailableFilterStore();
|
||||
const sortStore = getSortStore();
|
||||
|
||||
const stop = $effect.root(() => {
|
||||
$effect(() => {
|
||||
const dynamicFilters = availableFilterStore.filters;
|
||||
|
||||
if (dynamicFilters.length > 0) {
|
||||
appliedFilterStore.setGroups(
|
||||
dynamicFilters.map(filter => ({
|
||||
id: filter.id,
|
||||
label: filter.name,
|
||||
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
|
||||
id: opt.id,
|
||||
name: opt.name,
|
||||
value: opt.value,
|
||||
selected: false,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
appliedFilterStore.setGroups(mapFilterMetadataToGroups(dynamicFilters));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Mirror filter selections + debounced search query + sort into fontCatalogStore params.
|
||||
*
|
||||
* Filters and sort are merged into one setParams call to avoid a startup race:
|
||||
* two separate effects each issued setOptions with a different queryKey on the
|
||||
* first flush, producing an orphaned `?limit=50&offset=0` fetch immediately
|
||||
* followed by the real `?limit=50&sort=popularity&offset=0` fetch.
|
||||
*
|
||||
* untrack the write so fontCatalogStore's internal $state reads don't feed back
|
||||
* into this effect's dependency graph.
|
||||
*/
|
||||
$effect(() => {
|
||||
const params = mapAppliedFiltersToParams(appliedFilterStore);
|
||||
const sort = sortStore.apiValue;
|
||||
untrack(() => fontCatalogStore.setParams({ ...params, sort }));
|
||||
const catalog = getFontCatalog();
|
||||
untrack(() => catalog.setParams({ ...params, sort }));
|
||||
});
|
||||
});
|
||||
|
||||
return stop; // hand the caller the cleanup
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user