Compare commits

...

24 Commits

Author SHA1 Message Date
Ilia Mashkov b3bc40b76c refactor(font): replace fontLifecycleManager singleton with lazy accessor
Convert the eager fontLifecycleManager singleton to getFontLifecycleManager()
(+ __resetFontLifecycleManager for tests), so its AbortController/FontFace
bookkeeping is set up on first use rather than at module load. Update consumers
(FontVirtualList, FontList, SampleList) and resolve it once as a field in
comparisonStore; the comparisonStore mock now exposes getFontLifecycleManager.
2026-06-01 18:49:47 +03:00
Ilia Mashkov 839460726e refactor(breadcrumb): replace scrollBreadcrumbsStore singleton with lazy accessor
Convert the eager scrollBreadcrumbsStore singleton to getScrollBreadcrumbsStore()
(+ __resetScrollBreadcrumbsStore for tests) and add a public destroy() that
disconnects the IntersectionObserver and scroll listener. Update the feature
barrel and consumers (BreadcrumbHeader, BreadcrumbHeaderSeeded, NavigationWrapper).
2026-06-01 18:46:10 +03:00
Ilia Mashkov 6877807aaf refactor(typography): lazy getTypographySettingsStore + fix effect leak
Convert the eager typographySettingsStore singleton to getTypographySettingsStore()
(+ __resetTypographySettingsStore for tests) and update barrels and consumers
(TypographyMenu, FontSampler, SampleList, ComparisonView Line/SliderArea,
comparisonStore + its mock).

Fix a latent leak while here: the store ran $effect.root and discarded the
returned cleanup, so its storage-sync effects outlived every instance. Capture
the disposer and expose destroy(), which __reset now calls.
2026-06-01 18:44:17 +03:00
Ilia Mashkov 3dca11fea8 refactor(theme): replace themeManager singleton with lazy getThemeManager
Convert the eager themeManager singleton to a getThemeManager() lazy accessor
(+ __resetThemeManager for tests), so the persistent-store subscription is set
up on first access rather than at module load. Update the barrel and consumers
(Layout init/destroy, ThemeSwitch, story, test).
2026-06-01 18:39:17 +03:00
Ilia Mashkov 0b675635b3 refactor(filters): replace filter/sort store singletons with lazy accessors
Convert appliedFilterStore, availableFilterStore and sortStore from eager
module-level singletons to getAppliedFilterStore/getAvailableFilterStore/
getSortStore lazy accessors (+ __reset* helpers for tests), so the
availableFilterStore QueryObserver is built on first use rather than at import.

Update barrels, the startFilterBindings bridge, and all consumers. Reactive
reads in components are wrapped in $derived; two-way bind:value targets resolve
the accessor once and bind directly (a $derived is read-only).
2026-06-01 18:37:18 +03:00
Ilia Mashkov 9780ff9358 refactor(filters): mount-scope store bindings and fix effect-update loop
Replace the side-effect-on-import $effect.root in bindings with an explicit
startFilterBindings() started from an AppBindings provider in onMount, so the
filters/sort -> font-catalog bridge has a lifecycle tied to the app tree and a
returned cleanup. bindings now consumes getFontCatalog().

Fix the effect-update loop this surfaced: setGroups populated the reactive
groups array in place via `groups.length = 0; groups.push(...)`. push reads the
array's length signal, so the populating effect both read and wrote
groups.length each run and re-triggered itself forever
(effect_update_depth_exceeded). setGroups now reassigns the array (groups is
`let`), which does not read length.

Extract mapFilterMetadataToGroups to own the metadata -> group-config mapping,
including sorting a copy of options (the source is TanStack-cached data; an
in-place sort corrupts the cache and writes into the effect's read dependency).
2026-06-01 17:25:26 +03:00
Ilia Mashkov 1ad015aed6 refactor(comparison): replace comparisonStore singleton with lazy getComparisonStore
Mirror the font-catalog change in ComparisonView: expose getComparisonStore()
(plus __resetComparisonStore for tests) instead of an eager comparisonStore
singleton, and consume getFontCatalog() internally. Update the model barrel and
all UI consumers (Sidebar, FontList, Header, Line, SliderArea); Character no
longer needs the store and reads everything from props.

Update both specs to the accessor: comparisonStore.test mocks getFontCatalog
with a writable stub (the real store's fonts is getter-only) and resets the
catalog between cases; Sidebar.svelte.test resolves the store via the accessor.

Also document Character's props.
2026-06-01 17:25:05 +03:00
Ilia Mashkov 10603d18bf refactor(font): replace fontCatalogStore singleton with lazy getFontCatalog
Swap the eagerly-constructed fontCatalogStore singleton for a lazy
getFontCatalog() accessor (plus __resetFontCatalog for tests), so the
InfiniteQueryObserver is created on first use rather than at module load.
Update the model barrels and all consumers (FontVirtualList, SampleList,
SampleListSection) to the accessor.

Also extract createFontLoadRequestConfig from FontVirtualList: it resolves a
font's URL for a weight and returns a 0-or-1 element array, letting callers
flatMap over a list to build load requests and drop unresolvable fonts in one
pass.
2026-06-01 17:24:55 +03:00
Ilia Mashkov 39d1ce4c37 refactor(FontSampler): remove $derived, since props are already reactive 2026-06-01 16:49:59 +03:00
Ilia Mashkov fcd61be4fa refactor(font): inject font-load status as a prop, decoupling UI from the store
FontApplicator and FontSampler no longer read fontLifecycleManager. They take a
`status` prop (FontLoadStatus | undefined) supplied by the composing widget;
FontList and SampleList resolve status once per visible row and pass it down.

FSD+ dependency inversion: the entity/feature UI depends on a value, not the
lifecycle store. Removes FontApplicator's value-import of the store (one step
toward an inert ./ui barrel) and drops the duplicate getFontStatus read per row
in FontList. FontSampler is now status-decoupled and trivially relocatable to
entities/Font/ui.
2026-06-01 12:06:30 +03:00
Ilia Mashkov 28a8e49915 refactor(font): keep @tanstack out of the entity public API barrel
Extract NonRetryableError into its own tanstack-free module and drop the
./api re-export from the Font slice barrel. Importing $entities/Font no
longer transitively loads @tanstack/query-core or constructs the QueryClient
singleton via the ./api and ./lib (errors) chains — light consumers (domain,
types, consts) and unit specs stop paying for TanStack.

Note: ./ui still pulls the stores; addressed separately.
2026-06-01 11:49:53 +03:00
Ilia Mashkov 43e8507144 Merge branch 'main' into refactor/reacrhitecture-to-fsd+ 2026-06-01 10:43:29 +03:00
ilia 67af3d946a Merge pull request 'chore: introduce font files caching and compressing' (#46) from chore/font-files-caching into main
Workflow / build (push) Successful in 1m19s
Workflow / e2e (push) Successful in 1m8s
Workflow / publish (push) Successful in 14s
Reviewed-on: #46
2026-06-01 07:42:51 +00:00
Ilia Mashkov c6d0270072 chore: introduce font files caching and compressing
Workflow / build (pull_request) Successful in 1m27s
Workflow / e2e (pull_request) Successful in 1m19s
Workflow / publish (pull_request) Has been skipped
2026-06-01 10:37:50 +03:00
Ilia Mashkov a677dc6b0b Merge branch 'main' into refactor/reacrhitecture-to-fsd+ 2026-06-01 10:21:42 +03:00
ilia f7cd6b5081 Merge pull request 'Feature/local fonts' (#45) from feature/local-fonts into main
Workflow / build (push) Successful in 1m20s
Workflow / e2e (push) Successful in 1m7s
Workflow / publish (push) Successful in 28s
Reviewed-on: #45
2026-06-01 07:20:21 +00:00
Ilia Mashkov dda8ef6368 docs(styles): strip decorative comment banners from app.css
Workflow / build (pull_request) Successful in 1m48s
Workflow / e2e (pull_request) Successful in 1m17s
Workflow / publish (pull_request) Has been skipped
Replace ASCII-art separators (====, box-drawing rules, ---- dashes) with
plain section labels and rewrite the casual one-liners as terse, factual
comments.
2026-06-01 10:11:42 +03:00
Ilia Mashkov d77b51736a fix(styles): default body font to Inter, drop unloaded Karla
The body font-family referenced "Karla", which was never loaded, so
body text silently fell back to system-ui. Point it at the existing
--font-secondary token (Inter + system fallbacks).
2026-06-01 10:06:18 +03:00
Ilia Mashkov 1e16330097 refactor(fonts): drop Google Fonts CDN links, preload self-hosted faces
Remove the googleapis stylesheet, both google preconnects, and the two
dead fontshare preconnects from the document head. Preload the two
render-critical faces (Inter, Space Grotesk) via Vite ?url imports.
Eliminates two third-party origins and the IP leak to Google.
2026-06-01 10:06:12 +03:00
Ilia Mashkov c41016ac5d feat(fonts): self-host interface fonts as vendored latin woff2
Replace Google Fonts CDN delivery of the four UI typefaces (Inter,
Space Grotesk, Space Mono, Syne) with latin-subset woff2 vendored into
app/assets/fonts and wired via a hand-authored @font-face stylesheet.
Variable faces keep wght (Inter also opsz). Vite content-hashes the
binaries for immutable caching.
2026-06-01 10:06:07 +03:00
Ilia Mashkov aa4189f6a8 chore(app): declare *.woff2?url module type for asset imports 2026-06-01 10:05:33 +03:00
Ilia Mashkov 17c022470e refactor(font): expose stores via model segment, not the top barrel
Re-exporting the store singletons (fontCatalogStore, fontLifecycleManager,
FontsByIdsStore) through entities/Font/index.ts meant every consumer of the
barrel eager-instantiated stores and pulled @tanstack/query-core — in dev,
test, and as retained code. Drop the store re-export from the top barrel;
keep the pure surface (types, constants, domain, lib, ui) there for
convenience. Consumers that need stores import $entities/Font/model.
Aligns with the BaseQueryStore carve-out: barrel by default, segment path
when it would drag a heavy or side-effectful dependency.
2026-05-31 20:06:33 +03:00
Ilia Mashkov a9f3b990ab chore: declare sideEffects allowlist for tree-shaking
Without the field, Rollup treats every module as potentially side-effectful
and cannot drop unused re-exports pulled through barrels. Audited all
import-time side effects: only CSS and the two bare side-effect imports
(router.ts, bindings.svelte.ts) must be preserved; module-level store
singletons ride their export usage and need no listing. Trims the bundle
~8 KB raw / ~2.4 KB gzip.
2026-05-31 20:06:22 +03:00
Ilia Mashkov 36673597f7 refactor(breadcrumb): relocate Breadcrumb slice from entities to features
Breadcrumb is not a business aggregate — it is a scroll-tracking navigation
capability (NavigationWrapper registers page sections into a store), so it
belongs in the features layer, not entities. Move the whole slice and
repoint its three widget consumers. entities/ now holds only Font, a true
aggregate.
2026-05-31 19:30:56 +03:00
83 changed files with 1038 additions and 393 deletions
+24 -1
View File
@@ -1,5 +1,28 @@
:3000 {
root * /usr/share/caddy
file_server
# Compress text responses only. woff2/png and other binaries are already
# compressed, so they're excluded — re-compressing them burns CPU for ~0%.
encode {
zstd
gzip
match {
header Content-Type text/*
header Content-Type application/javascript*
header Content-Type application/json*
header Content-Type image/svg+xml*
}
}
# Vite emits all build output under /assets/ with content-hashed filenames,
# so those bytes never change for a given URL — cache them indefinitely.
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
# The HTML shell is the un-hashed entry point; it must revalidate so a new
# deploy is served immediately rather than from a stale cache.
header /index.html Cache-Control "no-cache"
try_files {path} /index.html
file_server
}
+5
View File
@@ -4,6 +4,11 @@
"version": "0.0.1",
"packageManager": "yarn@4.11.0",
"type": "module",
"sideEffects": [
"*.css",
"**/router.ts",
"**/bindings.svelte.ts"
],
"scripts": {
"dev": "vite",
"build": "vite build",
+9 -4
View File
@@ -16,12 +16,17 @@
*/
import '$routes/router';
import { Router } from 'sv-router';
import { QueryProvider } from './providers';
import {
AppBindingsProvider,
QueryProvider,
} from './providers';
import Layout from './ui/Layout.svelte';
</script>
<QueryProvider>
<Layout>
<Router />
</Layout>
<AppBindingsProvider>
<Layout>
<Router />
</Layout>
</AppBindingsProvider>
</QueryProvider>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
<!--
Component: AppBindings
Provider that starts app-wide store bindings (filters → sort → font catalog)
for its subtree. Mount-scoped so the bindings' lifetime tracks the app tree.
-->
<script lang="ts">
import { startFilterBindings } from '$features/FilterAndSortFonts';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
/**
* Content snippet
*/
children?: Snippet;
}
let { children }: Props = $props();
// startFilterBindings returns its $effect.root cleanup; onMount runs it on unmount.
onMount(() => startFilterBindings());
</script>
{@render children?.()}
+1
View File
@@ -1 +1,2 @@
export { default as AppBindingsProvider } from './AppBindings.svelte';
export { default as QueryProvider } from './QueryProvider.svelte';
+16 -23
View File
@@ -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 {
+78
View File
@@ -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;
}
+5
View File
@@ -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
View File
@@ -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"
+24 -2
View File
@@ -1,8 +1,30 @@
export * from './api';
export * from './domain';
export * from './lib';
export * from './model';
export * from './ui';
// Pure model surface (types + constants) is part of the convenient top-level
// API. Stateful stores are deliberately excluded — see below.
export * from './model/const/const';
export * from './model/types';
/*
* `./api` (proxy clients: `fetchProxyFonts`, `seedFontCache`, ) is intentionally
* NOT re-exported here. Those clients import `$shared/api/queryClient`, whose
* module eval runs `new QueryClient()` and loads `@tanstack/query-core`. Funneling
* them through this barrel made every consumer of `$entities/Font` including
* pure-domain and type-only importers eager-load TanStack and construct the
* client (notably in unit specs). Import API clients via the segment:
* import { fetchProxyFonts } from '$entities/Font/api';
*/
/*
* Stores (`fontCatalogStore`, `fontLifecycleManager`, `FontsByIdsStore`) are
* intentionally NOT re-exported here. They instantiate module-level singletons
* and pull `@tanstack/query-core`, so funneling them through this barrel would
* make every consumer of `$entities/Font` eager-instantiate stores (and break
* tree-shaking / test init-order). Import them via the model segment:
* import { fontCatalogStore } from '$entities/Font/model';
*/
// `./testing` is intentionally not re-exported: fixtures must not leak into the
// production public API. Import them via `$entities/Font/testing`.
@@ -0,0 +1,111 @@
import {
describe,
expect,
it,
} from 'vitest';
import type { UnifiedFont } from '../../model/types';
import { createFontLoadRequestContfig } from './createFontLoadRequestContfig';
/**
* Minimal UnifiedFont mock override only the fields a case exercises.
*/
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
const baseFont: UnifiedFont = {
id: 'test-font',
name: 'Test Font',
provider: 'google',
category: 'sans-serif',
subsets: ['latin'],
variants: [],
styles: {},
metadata: {
cachedAt: Date.now(),
},
features: {
isVariable: false,
tags: [],
},
};
return { ...baseFont, ...overrides };
}
describe('createFontLoadRequestContfig', () => {
it('builds a single-element config when a URL resolves', () => {
const font = createMockFont({
id: 'roboto',
name: 'Roboto',
styles: { variants: { '400': 'https://example.com/roboto-400.woff2' } },
});
const result = createFontLoadRequestContfig(font, 400);
expect(result).toEqual([
{
id: 'roboto',
name: 'Roboto',
weight: 400,
url: 'https://example.com/roboto-400.woff2',
isVariable: false,
},
]);
});
it('returns an empty array when no URL resolves (flatMap drops the font)', () => {
const font = createMockFont({ styles: {} });
expect(createFontLoadRequestContfig(font, 400)).toEqual([]);
});
it('forwards isVariable from font features', () => {
const font = createMockFont({
features: { isVariable: true, tags: [] },
styles: { variants: { '700': 'https://example.com/inter-vf.woff2' } },
});
const [config] = createFontLoadRequestContfig(font, 700);
expect(config.isVariable).toBe(true);
});
it('sets isVariable to undefined when features is absent', () => {
// features is non-optional on UnifiedFont, but upstream data can be partial —
// the optional chain must not throw, and isVariable stays undefined.
const font = createMockFont({
styles: { variants: { '400': 'https://example.com/font.woff2' } },
});
// @ts-expect-error — deliberately drop the guaranteed field to exercise the optional chain
font.features = undefined;
const [config] = createFontLoadRequestContfig(font, 400);
expect(config.isVariable).toBeUndefined();
});
it('uses the resolved fallback URL, not just exact matches', () => {
// getFontUrl falls back to styles.regular when the exact weight is missing;
// the config must carry whatever URL actually resolved.
const font = createMockFont({
styles: { regular: 'https://example.com/font-regular.woff2' },
});
const [config] = createFontLoadRequestContfig(font, 900);
expect(config.url).toBe('https://example.com/font-regular.woff2');
expect(config.weight).toBe(900);
});
it('carries the requested weight even when the URL is a shared fallback', () => {
const font = createMockFont({
styles: { variants: { '400': 'https://example.com/shared.woff2' } },
});
expect(createFontLoadRequestContfig(font, 700)[0].weight).toBe(700);
});
it('propagates the invalid-weight error from getFontUrl', () => {
const font = createMockFont();
expect(() => createFontLoadRequestContfig(font, 450)).toThrow('Invalid weight: 450');
});
});
@@ -0,0 +1,33 @@
import type {
FontLoadRequestConfig,
UnifiedFont,
} from '../../model';
import { getFontUrl } from '../getFontUrl/getFontUrl';
/**
* Build the font-lifecycle load request for a single font at a given weight.
*
* Returns a 0-or-1 element array rather than `FontLoadRequestConfig | undefined`
* so call sites can `flatMap` over a font list resolve the URL and drop fonts
* that have none in a single pass, with no separate filter step. An empty array
* means the font has no loadable asset for this weight (or its fallbacks) and is
* silently skipped.
*
* `isVariable` is forwarded from the font's features so the lifecycle manager can
* dedupe variable fonts per ID (they load once regardless of weight) while still
* loading static fonts per weight.
*
* @param font - Unified font to load
* @param weight - Numeric weight (100-900)
* @returns Single-element config array, or `[]` when no URL resolves
* @throws Error when weight is outside the valid 100-900 range (propagated from `getFontUrl`)
*/
export function createFontLoadRequestContfig(font: UnifiedFont, weight: number): FontLoadRequestConfig[] {
const url = getFontUrl(font, weight);
if (!url) {
return [];
}
return [{ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable }];
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { NonRetryableError } from '$shared/api/queryClient';
import { NonRetryableError } from '$shared/api/nonRetryableError';
/**
* Thrown when the network request to the proxy API fails.
+3
View File
@@ -1,3 +1,6 @@
export * from './const/const';
export { getFontCatalog } from './store';
export * from './store';
export * from './types';
@@ -483,8 +483,14 @@ export class FontCatalogStore {
}
}
export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
return new FontCatalogStore(params);
let _catalog: FontCatalogStore | undefined;
export function getFontCatalog(): FontCatalogStore {
return (_catalog ??= new FontCatalogStore({ limit: 50 }));
}
export const fontCatalogStore = new FontCatalogStore({ limit: 50 });
// test-only reset, so specs don't share a live observer
export function __resetFontCatalog() {
_catalog?.destroy();
_catalog = undefined;
}
@@ -419,7 +419,18 @@ export class FontLifecycleManager {
}
}
let _fontLifecycleManager: FontLifecycleManager | undefined;
/**
* 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();
export function getFontLifecycleManager(): FontLifecycleManager {
return (_fontLifecycleManager ??= new FontLifecycleManager());
}
// test-only reset, so specs don't share loaded-font/eviction state
export function __resetFontLifecycleManager() {
_fontLifecycleManager?.destroy();
_fontLifecycleManager = undefined;
}
+3 -5
View File
@@ -2,11 +2,9 @@
export * 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';
@@ -10,14 +10,14 @@ 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>
@@ -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>
@@ -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 -1
View File
@@ -1,9 +1,9 @@
export {
createTypographySettingsStore,
getTypographySettingsStore,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
type TypographySettingsStore,
typographySettingsStore,
} from './model';
export { TypographyMenu } from './ui';
+1 -1
View File
@@ -5,6 +5,6 @@ export {
} from './const/const';
export {
createTypographySettingsStore,
getTypographySettingsStore,
type TypographySettingsStore,
typographySettingsStore,
} from './store/typographySettingsStore/typographySettingsStore.svelte';
@@ -94,6 +94,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;
@@ -117,7 +123,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;
@@ -155,6 +161,13 @@ export class TypographySettingsStore {
});
}
/**
* Tears down the storage-sync effects. Call on unmount / store disposal.
*/
destroy(): void {
this.#disposeEffects();
}
/**
* Gets initial value for a control from storage or defaults
*/
@@ -336,10 +349,24 @@ export function createTypographySettingsStore(
return new TypographySettingsStore(configs, storage);
}
export type TypographySettingsStoreInstance = ReturnType<typeof createTypographySettingsStore>;
let _typographySettingsStore: TypographySettingsStoreInstance | undefined;
/**
* 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,
);
export function getTypographySettingsStore(): TypographySettingsStoreInstance {
return (_typographySettingsStore ??= createTypographySettingsStore(
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
COMPARISON_STORAGE_KEY,
));
}
// test-only reset, so specs don't share persisted typography state or leak effects
export function __resetTypographySettingsStore() {
_typographySettingsStore?.destroy();
_typographySettingsStore = undefined;
}
@@ -23,7 +23,7 @@ import {
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
typographySettingsStore,
getTypographySettingsStore,
} from '../../model';
interface Props {
@@ -46,6 +46,7 @@ interface Props {
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
const typographySettingsStore = getTypographySettingsStore();
/**
* Sets the common font size multiplier based on the current responsive state.
@@ -8,7 +8,7 @@
* @example
* ```svelte
* <script lang="ts">
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
* import { onMount } from 'svelte';
*
* onMount(() => {
@@ -26,8 +26,8 @@
*/
export {
getScrollBreadcrumbsStore,
type NavigationAction,
scrollBreadcrumbsStore,
} from './model';
export {
BreadcrumbHeader,
@@ -15,7 +15,7 @@
* @example
* ```svelte
* <script lang="ts">
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
* import { scrollBreadcrumbsStore } from '$features/Breadcrumb';
*
* onMount(() => {
* scrollBreadcrumbsStore.add({
@@ -167,6 +167,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
*/
@@ -272,7 +279,17 @@ export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
return new ScrollBreadcrumbsStore();
}
let _scrollBreadcrumbsStore: ScrollBreadcrumbsStore | undefined;
/**
* Singleton scroll breadcrumbs store instance
* App-wide scroll breadcrumbs store, created on first access.
*/
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
export function getScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
return (_scrollBreadcrumbsStore ??= createScrollBreadcrumbsStore());
}
// test-only reset, so specs don't share observer/scroll state
export function __resetScrollBreadcrumbsStore() {
_scrollBreadcrumbsStore?.destroy();
_scrollBreadcrumbsStore = undefined;
}
@@ -14,9 +14,10 @@ import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import {
type BreadcrumbItem,
scrollBreadcrumbsStore,
getScrollBreadcrumbsStore,
} from '../../model';
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
const responsive = getContext<ResponsiveManager>('responsive');
@@ -1,8 +1,10 @@
<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' },
@@ -6,9 +6,11 @@
import { type Snippet } from 'svelte';
import {
type NavigationAction,
scrollBreadcrumbsStore,
getScrollBreadcrumbsStore,
} from '../../model';
const scrollBreadcrumbsStore = getScrollBreadcrumbsStore();
interface Props {
/**
* Navigation index
+1 -1
View File
@@ -1 +1 @@
export { themeManager } from './store/ThemeManager/ThemeManager.svelte';
export { getThemeManager } from './store/ThemeManager/ThemeManager.svelte';
@@ -194,15 +194,26 @@ class ThemeManager {
}
}
let _themeManager: ThemeManager | undefined;
/**
* 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();
export function getThemeManager(): ThemeManager {
return (_themeManager ??= new ThemeManager());
}
// test-only reset, so specs don't share persisted theme state
export function __resetThemeManager() {
_themeManager?.destroy();
_themeManager = undefined;
}
/**
* 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 });
@@ -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)',
@@ -85,6 +90,7 @@ const mockGeorgia: UnifiedFont = {
name="Default"
args={{
font: mockArial,
status: 'loaded',
text: 'The quick brown fox jumps over the lazy dog',
index: 0,
}}
@@ -101,6 +107,7 @@ 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,
@@ -6,9 +6,10 @@
<script lang="ts">
import {
FontApplicator,
type FontLoadStatus,
type UnifiedFont,
} from '$entities/Font';
import { typographySettingsStore } from '$features/AdjustTypography/model';
import { getTypographySettingsStore } from '$features/AdjustTypography/model';
import {
Badge,
ContentEditable,
@@ -23,6 +24,12 @@ 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
*/
@@ -34,10 +41,9 @@ interface Props {
index?: number;
}
let { font, text = $bindable(), index = 0 }: Props = $props();
let { font, status, 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 ?? '');
const typographySettingsStore = getTypographySettingsStore();
// Extract provider badge with fallback
const providerBadge = $derived(
@@ -91,9 +97,9 @@ const stats = $derived([
{font.name}
</span>
{#if fontType}
{#if font?.category}
<Badge size="xs" variant="default" nowrap>
{fontType}
{font?.category}
</Badge>
{/if}
@@ -132,7 +138,7 @@ const stats = $derived([
<!-- ── 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}
@@ -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;
+13 -9
View File
@@ -1,24 +1,28 @@
export { mapAppliedFiltersToParams } from './lib';
export {
type AppliedFilterStore,
appliedFilterStore,
/**
* Filter Store
*/
availableFilterStore,
/**
* Filter Manager
*/
createAppliedFilterStore,
/**
* Lazy store accessors
*/
getAppliedFilterStore,
getAvailableFilterStore,
getSortStore,
/**
* Sort Store
*/
SORT_MAP,
SORT_OPTIONS,
type SortApiValue,
type SortOption,
sortStore,
startFilterBindings,
} from './model';
export type {
AppliedFilterStore,
SortApiValue,
SortOption,
} from './model';
export {
@@ -0,0 +1,83 @@
import {
describe,
expect,
it,
} from 'vitest';
import type {
FilterMetadata,
FilterOption,
} from '../../api/filters/filters';
import { mapFilterMetadataToGroups } from './mapFilterMetadataToGroups';
/**
* Build a FilterOption with a known value and count.
*/
function option(value: string, count: number): FilterOption {
return { id: value, name: value, value, count };
}
/**
* Build filter metadata for one group from (value, count) entries.
*/
function metadata(id: string, options: Array<[string, number]>): FilterMetadata {
return {
id,
name: id,
description: '',
type: 'array',
options: options.map(([value, count]) => option(value, count)),
};
}
describe('mapFilterMetadataToGroups', () => {
it('maps id and name onto group id and label', () => {
const [group] = mapFilterMetadataToGroups([metadata('categories', [['serif', 1]])]);
expect(group.id).toBe('categories');
expect(group.label).toBe('categories');
});
it('projects each option to a property with selected: false', () => {
const [group] = mapFilterMetadataToGroups([metadata('providers', [['google', 5]])]);
expect(group.properties).toEqual([
{ id: 'google', name: 'google', value: 'google', selected: false },
]);
});
it('orders properties by descending count', () => {
const [group] = mapFilterMetadataToGroups([
metadata('subsets', [['latin', 2], ['cyrillic', 9], ['greek', 5]]),
]);
expect(group.properties.map(p => p.value)).toEqual(['cyrillic', 'greek', 'latin']);
});
it('does not mutate the source options array (TanStack cache safety)', () => {
const source = metadata('subsets', [['latin', 2], ['cyrillic', 9]]);
const originalOrder = source.options.map(o => o.value);
mapFilterMetadataToGroups([source]);
expect(source.options.map(o => o.value)).toEqual(originalOrder);
});
it('maps every group, preserving group order', () => {
const groups = mapFilterMetadataToGroups([
metadata('providers', [['google', 1]]),
metadata('categories', [['serif', 1]]),
]);
expect(groups.map(g => g.id)).toEqual(['providers', 'categories']);
});
it('returns an empty group list for empty metadata', () => {
expect(mapFilterMetadataToGroups([])).toEqual([]);
});
it('yields an empty properties list when a group has no options', () => {
const [group] = mapFilterMetadataToGroups([metadata('providers', [])]);
expect(group.properties).toEqual([]);
});
});
@@ -0,0 +1,36 @@
import type { FilterMetadata } from '../../api/filters/filters';
import type { FilterGroupConfig } from '../../model';
/**
* Map backend filter metadata into the group configs `appliedFilterStore.setGroups`
* consumes.
*
* Inverse direction of `mapAppliedFiltersToParams`: that maps applied selections out
* to API params; this maps the API's available-filter catalog in to the UI model.
*
* Options are ordered by descending font count so the most populated values surface
* first. The source array is copied before sorting `metadata` is TanStack-cached
* query data, and `.sort()` mutates in place; sorting the live cache both corrupts it
* and, when called from a reactive effect, writes into that effect's own read
* dependency (triggering an update loop).
*
* Every property starts unselected; selection state is owned by the store, not the
* backend catalog.
*
* @param metadata - Available-filter catalog from the filters endpoint
* @returns Group configs ready for `setGroups`
*/
export function mapFilterMetadataToGroups(metadata: FilterMetadata[]): FilterGroupConfig<string>[] {
return metadata.map(filter => ({
id: filter.id,
label: filter.name,
properties: [...filter.options]
.sort((a, b) => b.count - a.count)
.map(opt => ({
id: opt.id,
name: opt.name,
value: opt.value,
selected: false,
})),
}));
}
+11 -11
View File
@@ -14,9 +14,9 @@ export type {
*/
export {
/**
* Low-level property selection store
* Lazy accessor for the app-wide filter-metadata store
*/
availableFilterStore,
getAvailableFilterStore,
} from './store/availableFilterStore/availableFilterStore.svelte';
/**
@@ -27,26 +27,30 @@ export {
* Reactive interface returned by `createAppliedFilterStore`
*/
type AppliedFilterStore,
/**
* High-level manager for syncing search and filters
*/
appliedFilterStore,
/**
* Factory for constructing a filter manager instance
*/
createAppliedFilterStore,
/**
* Lazy accessor for the app-wide filter manager
*/
getAppliedFilterStore,
} from './store/appliedFilterStore/appliedFilterStore.svelte';
/**
* Side-effect import: installs the global appliedFilterStore+sortStore fontCatalogStore
* bridge on first import of this feature barrel. No exports.
*/
import './store/bindings.svelte';
export { startFilterBindings } from './store/bindings.svelte';
/**
* Sorting logic
*/
export {
/**
* Lazy accessor for the app-wide sort store
*/
getSortStore,
/**
* Map of human-readable labels to API sort keys
*/
@@ -63,8 +67,4 @@ export {
* UI model for a single sort option
*/
type SortOption,
/**
* Reactive store for the current sort selection
*/
sortStore,
} from './store/sortStore/sortStore.svelte';
@@ -42,8 +42,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 +67,11 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
* Used when dynamic filter data loads from backend
*/
setGroups(newGroups: FilterGroupConfig<TValue>[]) {
groups.length = 0;
groups.push(
...newGroups.map(g => ({
id: g.id,
label: g.label,
instance: createFilter({ properties: g.properties }),
})),
);
groups = newGroups.map(g => ({
id: g.id,
label: g.label,
instance: createFilter({ properties: g.properties }),
}));
},
/**
* Current search query value (immediate, for UI binding)
@@ -127,14 +129,23 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
let _appliedFilterStore: AppliedFilterStore | undefined;
/**
* App-wide filter manager singleton.
* App-wide filter manager, created on first access.
*
* Constructed with empty groups; the availableFilterStore appliedFilterStore wiring
* lives in `./bindings.svelte` and populates groups once backend filter
* metadata arrives.
*/
export const appliedFilterStore = createAppliedFilterStore({
queryValue: '',
groups: [],
});
export function getAppliedFilterStore(): AppliedFilterStore {
return (_appliedFilterStore ??= createAppliedFilterStore<string>({
queryValue: '',
groups: [],
}));
}
// test-only reset, so specs don't share filter/selection state
export function __resetAppliedFilterStore() {
_appliedFilterStore = undefined;
}
@@ -126,7 +126,18 @@ export class AvailableFilterStore {
}
}
let _availableFilterStore: AvailableFilterStore | undefined;
/**
* 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();
export function getAvailableFilterStore(): AvailableFilterStore {
return (_availableFilterStore ??= new AvailableFilterStore());
}
// test-only reset, so specs don't share a live observer
export function __resetAvailableFilterStore() {
_availableFilterStore?.destroy();
_availableFilterStore = undefined;
}
@@ -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/model';
import { untrack } from 'svelte';
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
import { availableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
import { sortStore } from './sortStore/sortStore.svelte';
import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups';
import { getAppliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
import { getAvailableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
import { getSortStore } from './sortStore/sortStore.svelte';
$effect.root(() => {
/**
* Populate appliedFilterStore groups when backend filter metadata resolves.
* availableFilterStore is async; until it loads, appliedFilterStore has empty groups
* and the UI renders nothing for them.
*/
$effect(() => {
const dynamicFilters = availableFilterStore.filters;
export function startFilterBindings(): () => void {
const appliedFilterStore = getAppliedFilterStore();
const availableFilterStore = getAvailableFilterStore();
const sortStore = getSortStore();
if (dynamicFilters.length > 0) {
appliedFilterStore.setGroups(
dynamicFilters.map(filter => ({
id: filter.id,
label: filter.name,
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
id: opt.id,
name: opt.name,
value: opt.value,
selected: false,
})),
})),
);
}
const stop = $effect.root(() => {
$effect(() => {
const dynamicFilters = availableFilterStore.filters;
if (dynamicFilters.length > 0) {
appliedFilterStore.setGroups(mapFilterMetadataToGroups(dynamicFilters));
}
});
$effect(() => {
const params = mapAppliedFiltersToParams(appliedFilterStore);
const sort = sortStore.apiValue;
const catalog = getFontCatalog();
untrack(() => catalog.setParams({ ...params, sort }));
});
});
/**
* Mirror filter selections + debounced search query + sort into fontCatalogStore params.
*
* Filters and sort are merged into one setParams call to avoid a startup race:
* two separate effects each issued setOptions with a different queryKey on the
* first flush, producing an orphaned `?limit=50&offset=0` fetch immediately
* followed by the real `?limit=50&sort=popularity&offset=0` fetch.
*
* untrack the write so fontCatalogStore's internal $state reads don't feed back
* into this effect's dependency graph.
*/
$effect(() => {
const params = mapAppliedFiltersToParams(appliedFilterStore);
const sort = sortStore.apiValue;
untrack(() => fontCatalogStore.setParams({ ...params, sort }));
});
});
return stop; // hand the caller the cleanup
}
@@ -44,4 +44,18 @@ export function createSortStore(initial: SortOption = 'Popularity') {
};
}
export const sortStore = createSortStore();
export type SortStore = ReturnType<typeof createSortStore>;
let _sortStore: SortStore | undefined;
/**
* App-wide sort store, created on first access.
*/
export function getSortStore(): SortStore {
return (_sortStore ??= createSortStore());
}
// test-only reset, so specs don't share selection state
export function __resetSortStore() {
_sortStore = undefined;
}
@@ -1,4 +1,5 @@
import {
afterEach,
describe,
expect,
it,
@@ -7,8 +8,9 @@ import {
SORT_MAP,
SORT_OPTIONS,
type SortOption,
__resetSortStore,
createSortStore,
sortStore,
getSortStore,
} from './sortStore.svelte';
describe('createSortStore', () => {
@@ -51,14 +53,24 @@ describe('createSortStore', () => {
});
});
describe('sortStore singleton', () => {
describe('getSortStore singleton', () => {
afterEach(() => {
__resetSortStore();
});
it('returns the same instance across calls', () => {
expect(getSortStore()).toBe(getSortStore());
});
it('exposes the same shape as a factory instance', () => {
const sortStore = getSortStore();
expect(typeof sortStore.value).toBe('string');
expect(typeof sortStore.apiValue).toBe('string');
expect(typeof sortStore.set).toBe('function');
});
it('accepts all SORT_OPTIONS as valid set() inputs', () => {
const sortStore = getSortStore();
for (const option of SORT_OPTIONS) {
sortStore.set(option);
expect(sortStore.value).toBe(option);
@@ -4,10 +4,13 @@
-->
<script lang="ts">
import { FilterGroup } from '$shared/ui';
import { appliedFilterStore } from '../../model';
import { getAppliedFilterStore } from '../../model';
const appliedFilterStore = getAppliedFilterStore();
const groups = $derived(appliedFilterStore.groups);
</script>
{#each appliedFilterStore.groups as group (group.id)}
{#each groups as group (group.id)}
<FilterGroup
displayedLabel={group.label}
filter={group.instance}
@@ -1,6 +1,6 @@
import {
appliedFilterStore,
availableFilterStore,
getAppliedFilterStore,
getAvailableFilterStore,
} from '$features/FilterAndSortFonts';
import {
render,
@@ -12,8 +12,8 @@ import Filters from './Filters.svelte';
describe('Filters', () => {
beforeEach(() => {
// Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us
appliedFilterStore.setGroups([]);
vi.spyOn(availableFilterStore, 'filters', 'get').mockReturnValue([]);
getAppliedFilterStore().setGroups([]);
vi.spyOn(getAvailableFilterStore(), 'filters', 'get').mockReturnValue([]);
});
afterEach(() => {
@@ -28,7 +28,7 @@ describe('Filters', () => {
});
it('renders a label for each filter group', () => {
appliedFilterStore.setGroups([
getAppliedFilterStore().setGroups([
{ id: 'cat', label: 'Categories', properties: [] },
{ id: 'prov', label: 'Font Providers', properties: [] },
]);
@@ -38,7 +38,7 @@ describe('Filters', () => {
});
it('renders filter properties within groups', () => {
appliedFilterStore.setGroups([
getAppliedFilterStore().setGroups([
{
id: 'cat',
label: 'Category',
@@ -54,7 +54,7 @@ describe('Filters', () => {
});
it('renders multiple groups with their properties', () => {
appliedFilterStore.setGroups([
getAppliedFilterStore().setGroups([
{
id: 'cat',
label: 'Category',
@@ -12,8 +12,8 @@ import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { getContext } from 'svelte';
import {
SORT_OPTIONS,
appliedFilterStore,
sortStore,
getAppliedFilterStore,
getSortStore,
} from '../../model';
interface Props {
@@ -30,6 +30,10 @@ const {
const responsive = getContext<ResponsiveManager>('responsive');
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
const appliedFilterStore = getAppliedFilterStore();
const sortStore = getSortStore();
const sortValue = $derived(sortStore.value);
function handleReset() {
appliedFilterStore.deselectAllGlobal();
}
@@ -53,7 +57,7 @@ function handleReset() {
<Button
variant="ghost"
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
active={sortStore.value === option}
active={sortValue === option}
onclick={() => sortStore.set(option)}
class="tracking-wide px-0"
>
+14
View File
@@ -0,0 +1,14 @@
/**
* Marker base class for errors that retrying will never fix schema-validation
* failures, unauthorized responses, contract violations, etc.
*
* The queryClient retry handler short-circuits when it sees this; without it,
* a non-transient backend bug pins the UI through the full retry budget
* (default 3× exponential backoff 7s).
*
* Lives in its own module free of `@tanstack/query-core` so error types that
* extend it (e.g. `FontResponseError`) can be imported without dragging the
* TanStack client and its eager `new QueryClient()` instantiation into the
* importer's module graph. See the barrel-coupling notes in the FSD audit.
*/
export class NonRetryableError extends Error {}
+1 -10
View File
@@ -1,14 +1,5 @@
import { QueryClient } from '@tanstack/query-core';
/**
* Marker base class for errors that retrying will never fix schema-validation
* failures, unauthorized responses, contract violations, etc.
*
* The queryClient retry handler short-circuits when it sees this; without it,
* a non-transient backend bug pins the UI through the full retry budget
* (default 3× exponential backoff 7s).
*/
export class NonRetryableError extends Error {}
import { NonRetryableError } from './nonRetryableError';
/**
* Data remains fresh for this long after fetch. Stores that override
+3 -4
View File
@@ -1,4 +1,3 @@
export {
comparisonStore,
type Side,
} from './stores/comparisonStore.svelte';
export { getComparisonStore } from './stores/comparisonStore.svelte';
export type { Side } from './stores/comparisonStore.svelte';
@@ -15,13 +15,20 @@
import {
type FontLoadRequestConfig,
FontsByIdsStore,
type UnifiedFont,
fontCatalogStore,
fontLifecycleManager,
getFontUrl,
} from '$entities/Font';
import { typographySettingsStore } from '$features/AdjustTypography/model';
import {
type FontCatalogStore,
type FontLifecycleManager,
FontsByIdsStore,
getFontCatalog,
getFontLifecycleManager,
} from '$entities/Font/model';
import {
type TypographySettingsStore,
getTypographySettingsStore,
} from '$features/AdjustTypography/model';
import { createPersistentStore } from '$shared/lib';
import { untrack } from 'svelte';
import { getPretextFontString } from '../../lib';
@@ -96,10 +103,19 @@ export class ComparisonStore {
*/
#fontsByIdsStore: FontsByIdsStore;
#fontCatalog: FontCatalogStore;
#typography: TypographySettingsStore;
#lifecycle: FontLifecycleManager;
constructor() {
// Synchronously seed the batch store with any IDs already in storage
const { fontAId, fontBId } = storage.value;
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
this.#fontCatalog = getFontCatalog();
this.#typography = getTypographySettingsStore();
this.#lifecycle = getFontLifecycleManager();
$effect.root(() => {
// Sync batch results → fontA / fontB
@@ -128,7 +144,7 @@ export class ComparisonStore {
$effect(() => {
const fa = this.#fontA;
const fb = this.#fontB;
const weight = typographySettingsStore.weight;
const weight = this.#typography.weight;
if (!fa || !fb) {
return;
@@ -149,7 +165,7 @@ export class ComparisonStore {
});
if (configs.length > 0) {
fontLifecycleManager.touch(configs);
this.#lifecycle.touch(configs);
this.#checkFontsLoaded();
}
});
@@ -171,7 +187,7 @@ export class ComparisonStore {
return;
}
const fonts = fontCatalogStore.fonts;
const fonts = this.#fontCatalog.fonts;
if (fonts.length < 2) {
return;
@@ -189,19 +205,19 @@ export class ComparisonStore {
$effect(() => {
const fa = this.#fontA;
const fb = this.#fontB;
const w = typographySettingsStore.weight;
const w = this.#typography.weight;
if (fa) {
fontLifecycleManager.pin(fa.id, w, fa.features?.isVariable);
this.#lifecycle.pin(fa.id, w, fa.features?.isVariable);
}
if (fb) {
fontLifecycleManager.pin(fb.id, w, fb.features?.isVariable);
this.#lifecycle.pin(fb.id, w, fb.features?.isVariable);
}
return () => {
if (fa) {
fontLifecycleManager.unpin(fa.id, w, fa.features?.isVariable);
this.#lifecycle.unpin(fa.id, w, fa.features?.isVariable);
}
if (fb) {
fontLifecycleManager.unpin(fb.id, w, fb.features?.isVariable);
this.#lifecycle.unpin(fb.id, w, fb.features?.isVariable);
}
};
});
@@ -220,8 +236,8 @@ export class ComparisonStore {
return;
}
const weight = typographySettingsStore.weight;
const size = typographySettingsStore.renderedSize;
const weight = this.#typography.weight;
const size = this.#typography.renderedSize;
const fontAName = this.#fontA?.name;
const fontBName = this.#fontB?.name;
@@ -350,11 +366,18 @@ export class ComparisonStore {
this.#fontB = undefined;
this.#fontsByIdsStore.setIds([]);
storage.clear();
typographySettingsStore.reset();
this.#typography.reset();
}
}
/**
* Singleton comparison store instance
*/
export const comparisonStore = new ComparisonStore();
let _comparisonStore: ComparisonStore | undefined;
export function getComparisonStore(): ComparisonStore {
return (_comparisonStore ??= new ComparisonStore());
}
// test-only reset, so specs don't share a live observer
export function __resetComparisonStore() {
_comparisonStore?.resetAll();
_comparisonStore = undefined;
}
@@ -47,6 +47,10 @@ const mockStorage = vi.hoisted(() => {
return storage;
});
// Writable catalog stub — tests assign `.fonts` directly, which the real
// FontCatalogStore forbids (getter-only). getFontCatalog returns this singleton.
const mockFontCatalog = vi.hoisted(() => ({ fonts: [] as unknown[] }));
vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte', () => ({
createPersistentStore: vi.fn(() => mockStorage),
}));
@@ -55,18 +59,29 @@ vi.mock('$entities/Font', async importOriginal => {
const actual = await importOriginal<typeof import('$entities/Font')>();
return {
...actual,
fontCatalogStore: { fonts: [] },
fontLifecycleManager: {
touch: vi.fn(),
pin: vi.fn(),
unpin: vi.fn(),
getFontStatus: vi.fn(),
ready: vi.fn(() => Promise.resolve()),
},
getFontUrl: vi.fn(() => 'https://example.com/font.woff2'),
};
});
const mockLifecycle = vi.hoisted(() => ({
touch: vi.fn(),
pin: vi.fn(),
unpin: vi.fn(),
getFontStatus: vi.fn(),
ready: vi.fn(() => Promise.resolve()),
}));
// Stores moved behind the model segment; mock them there. FontsByIdsStore is
// intentionally left real (spread from actual) so $state reactivity works.
vi.mock('$entities/Font/model', async importOriginal => {
const actual = await importOriginal<typeof import('$entities/Font/model')>();
return {
...actual,
getFontCatalog: () => mockFontCatalog,
getFontLifecycleManager: () => mockLifecycle,
};
});
vi.mock('$features/AdjustTypography', () => ({
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
createTypographyControlManager: vi.fn(() => ({
@@ -76,31 +91,32 @@ vi.mock('$features/AdjustTypography', () => ({
})),
}));
vi.mock('$features/AdjustTypography/model', () => ({
typographySettingsStore: {
weight: 400,
renderedSize: 48,
reset: vi.fn(),
},
const mockTypography = vi.hoisted(() => ({
weight: 400,
renderedSize: 48,
reset: vi.fn(),
}));
vi.mock('$features/AdjustTypography/model', () => ({
getTypographySettingsStore: () => mockTypography,
}));
import {
fontCatalogStore,
fontLifecycleManager,
} from '$entities/Font';
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
import { getFontCatalog } from '$entities/Font/model';
import { __resetFontCatalog } from '$entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte';
import { ComparisonStore } from './comparisonStore.svelte';
describe('ComparisonStore', () => {
const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; // id: 'roboto'
const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; // id: 'open-sans'
let fontCatalog = getFontCatalog();
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
mockStorage._value = { fontAId: null, fontBId: null };
mockStorage._clear.mockClear();
(fontCatalogStore as any).fonts = [];
(fontCatalog as any).fonts = [];
// Default: fetchFontsByIds returns empty so tests that don't care don't hang
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([]);
@@ -117,6 +133,10 @@ describe('ComparisonStore', () => {
});
});
afterEach(() => {
__resetFontCatalog();
});
describe('Initialization', () => {
it('should create store with initial empty state', () => {
const store = new ComparisonStore();
@@ -155,7 +175,7 @@ describe('ComparisonStore', () => {
describe('Default Fallbacks', () => {
it('should update storage with default IDs when storage is empty', async () => {
(fontCatalogStore as any).fonts = [mockFontA, mockFontB];
(fontCatalog as any).fonts = [mockFontA, mockFontB];
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
new ComparisonStore();
@@ -183,7 +203,7 @@ describe('ComparisonStore', () => {
// Catalog defaults differ from the stored selection — if the
// effect mis-seeds, storage will flip to roboto / open-sans.
(fontCatalogStore as any).fonts = [mockFontA, mockFontB];
(fontCatalog as any).fonts = [mockFontA, mockFontB];
// Delay the batch so the catalog-driven effect runs first.
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockImplementation(
@@ -252,12 +272,12 @@ describe('ComparisonStore', () => {
new ComparisonStore();
await vi.waitFor(() => {
expect(fontLifecycleManager.pin).toHaveBeenCalledWith(
expect(mockLifecycle.pin).toHaveBeenCalledWith(
mockFontA.id,
400,
mockFontA.features?.isVariable,
);
expect(fontLifecycleManager.pin).toHaveBeenCalledWith(
expect(mockLifecycle.pin).toHaveBeenCalledWith(
mockFontB.id,
400,
mockFontB.features?.isVariable,
@@ -278,12 +298,12 @@ describe('ComparisonStore', () => {
store.fontA = mockFontC;
await vi.waitFor(() => {
expect(fontLifecycleManager.unpin).toHaveBeenCalledWith(
expect(mockLifecycle.unpin).toHaveBeenCalledWith(
mockFontA.id,
400,
mockFontA.features?.isVariable,
);
expect(fontLifecycleManager.pin).toHaveBeenCalledWith(
expect(mockLifecycle.pin).toHaveBeenCalledWith(
mockFontC.id,
400,
mockFontC.features?.isVariable,
@@ -7,16 +7,25 @@
the Line container zeroes font-size to collapse inter-element whitespace.
-->
<script lang="ts">
import type { UnifiedFont } from '$entities/Font';
import { cn } from '$shared/lib';
import { comparisonStore } from '../../model';
interface Props {
/**
* Character
* The single character to render.
*/
char: string;
/**
* Past state
* Font shown when `isPast` is true (the "before" side of the comparison).
*/
fontA: UnifiedFont;
/**
* Font shown when `isPast` is false (the "after" side of the comparison).
*/
fontB: UnifiedFont;
/**
* Selects which font this character morphs toward — `fontA` when true,
* `fontB` when false — and applies the muted "past" color.
*/
isPast: boolean;
/**
@@ -26,10 +35,7 @@ interface Props {
fontSize: number;
}
let { char, isPast, fontSize }: Props = $props();
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
let { char, fontA, fontB, isPast, fontSize }: Props = $props();
let slot = $state<0 | 1>(0);
let slotFonts = $state<[string, string]>(['', '']);
@@ -4,7 +4,7 @@
Owns all shared state and wires the pieces together.
-->
<script lang="ts">
import { NavigationWrapper } from '$entities/Breadcrumb';
import { NavigationWrapper } from '$features/Breadcrumb';
import type { ResponsiveManager } from '$shared/lib';
import { SidebarContainer } from '$shared/ui';
import {
@@ -9,9 +9,11 @@ import {
FontVirtualList,
type UnifiedFont,
VIRTUAL_INDEX_NOT_LOADED,
fontCatalogStore,
fontLifecycleManager,
} from '$entities/Font';
import {
getFontCatalog,
getFontLifecycleManager,
} from '$entities/Font/model';
import { getSkeletonWidth } from '$shared/lib/utils';
import {
Button,
@@ -26,17 +28,22 @@ import {
} from '../../lib';
import {
type Side,
comparisonStore,
getComparisonStore,
} from '../../model';
const side = $derived(comparisonStore.side);
const fontCatalog = getFontCatalog();
const comparisonStore = getComparisonStore();
const fontLifecycleManager = getFontLifecycleManager();
const fonts = $derived<UnifiedFont[]>(fontCatalog.fonts);
const side = $derived<Side>(comparisonStore.side);
// Treat -1 (not loaded) as being at the very bottom of the infinite list
function getVirtualIndex(fontId: string | undefined): number {
if (!fontId) {
return -1;
}
const idx = fontCatalogStore.fonts.findIndex(f => f.id === fontId);
const idx = fonts.findIndex(f => f.id === fontId);
if (idx === -1) {
return VIRTUAL_INDEX_NOT_LOADED;
}
@@ -73,21 +80,6 @@ function handleSelect(font: UnifiedFont) {
comparisonStore.fontB = font;
}
}
/**
* Returns true once the font file is loaded (or errored) and safe to render.
* Called inside the template — Svelte 5 tracks the $state reads inside
* fontLifecycleManager.getFontStatus(), so each row re-renders reactively
* when its file arrives.
*/
function isFontReady(font: UnifiedFont): boolean {
const status = fontLifecycleManager.getFontStatus(
font.id,
DEFAULT_FONT_WEIGHT,
font.features?.isVariable,
);
return status === 'loaded' || status === 'error';
}
</script>
<div class="flex-1 min-h-0 h-full">
@@ -127,8 +119,15 @@ function isFontReady(font: UnifiedFont): boolean {
{/snippet}
{#snippet children({ item: font, index })}
<!--
Read load status once per row. Svelte 5 tracks the $state reads inside
fontLifecycleManager.getFontStatus(), so the row re-renders reactively
when its file arrives — and the same value drives both the skeleton gate
and FontApplicator below.
-->
{@const status = fontLifecycleManager.getFontStatus(font.id, DEFAULT_FONT_WEIGHT, font.features?.isVariable)}
<div class="relative h-11 w-full">
{#if !isFontReady(font)}
{#if status !== 'loaded' && status !== 'error'}
<div
class="absolute inset-0 px-3 md:px-4 flex items-center justify-between border border-transparent"
transition:fade={{ duration: 300 }}
@@ -153,7 +152,7 @@ function isFontReady(font: UnifiedFont): boolean {
class="h-full"
iconPosition="right"
>
<FontApplicator {font}>
<FontApplicator {font} {status}>
{font.name}
</FontApplicator>
@@ -17,7 +17,7 @@ import {
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
import { getContext } from 'svelte';
import { comparisonStore } from '../../model';
import { getComparisonStore } from '../../model';
interface Props {
/**
@@ -40,6 +40,8 @@ let {
class: className,
}: Props = $props();
const comparisonStore = getComparisonStore();
const responsive = getContext<ResponsiveManager>('responsive');
const position = $derived(comparisonStore.sliderPosition.toFixed(0));
+26 -22
View File
@@ -11,8 +11,8 @@ import {
type ComparisonLine,
computeLineRenderModel,
} from '$entities/Font';
import { typographySettingsStore } from '$features/AdjustTypography';
import { comparisonStore } from '../../model';
import { getTypographySettingsStore } from '$features/AdjustTypography';
import { getComparisonStore } from '../../model';
import Character from '../Character/Character.svelte';
interface Props {
@@ -32,9 +32,11 @@ interface Props {
let { line, split, windowSize }: Props = $props();
const comparisonStore = getComparisonStore();
const model = $derived(computeLineRenderModel(line, split, windowSize));
const typography = $derived(typographySettingsStore);
const typography = getTypographySettingsStore();
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
@@ -80,22 +82,24 @@ const strutStyle = $derived(
would show as gaps under `white-space: pre`; children restore their size.
Letter-spacing is px because em would resolve against that zero.
-->
<div
class="relative block w-full text-center whitespace-pre"
style:height="{lineHeightPx}px"
style:line-height="{lineHeightPx}px"
style:font-size="0"
style:letter-spacing="{letterSpacingPx}px"
style:font-weight={typography.weight}
>
<span style={strutStyle} aria-hidden="true"></span>
{#if model.leftText}
<span class={BULK_LEFT_CLASS} style={leftStyle}>{model.leftText}</span>
{/if}
{#each model.windowChars as wc (wc.key)}
<Character char={wc.char} isPast={wc.isPast} fontSize={fontSizePx} />
{/each}
{#if model.rightText}
<span class={BULK_RIGHT_CLASS} style={rightStyle}>{model.rightText}</span>
{/if}
</div>
{#if fontA && fontB}
<div
class="relative block w-full text-center whitespace-pre"
style:height="{lineHeightPx}px"
style:line-height="{lineHeightPx}px"
style:font-size="0"
style:letter-spacing="{letterSpacingPx}px"
style:font-weight={typography.weight}
>
<span style={strutStyle} aria-hidden="true"></span>
{#if model.leftText}
<span class={BULK_LEFT_CLASS} style={leftStyle}>{model.leftText}</span>
{/if}
{#each model.windowChars as wc (wc.key)}
<Character char={wc.char} {fontA} {fontB} isPast={wc.isPast} fontSize={fontSizePx} />
{/each}
{#if model.rightText}
<span class={BULK_RIGHT_CLASS} style={rightStyle}>{model.rightText}</span>
{/if}
</div>
{/if}
@@ -5,8 +5,10 @@
propagates the value into fontCatalogStore.
-->
<script lang="ts">
import { appliedFilterStore } from '$features/FilterAndSortFonts';
import { getAppliedFilterStore } from '$features/FilterAndSortFonts';
import { SearchBar } from '$shared/ui';
const appliedFilterStore = getAppliedFilterStore();
</script>
<div class="p-6 border-b border-subtle">
@@ -1,4 +1,4 @@
import { appliedFilterStore } from '$features/FilterAndSortFonts';
import { getAppliedFilterStore } from '$features/FilterAndSortFonts';
import {
render,
screen,
@@ -7,7 +7,7 @@ import Search from './Search.svelte';
describe('Search', () => {
beforeEach(() => {
appliedFilterStore.queryValue = '';
getAppliedFilterStore().queryValue = '';
});
describe('Rendering', () => {
@@ -24,7 +24,7 @@ describe('Search', () => {
describe('Value binding', () => {
it('reflects appliedFilterStore.queryValue as initial value', () => {
appliedFilterStore.queryValue = 'Inter';
getAppliedFilterStore().queryValue = 'Inter';
render(Search);
expect(screen.getByRole('textbox')).toHaveValue('Inter');
});
@@ -14,7 +14,7 @@ import {
import type { Snippet } from 'svelte';
import {
type Side,
comparisonStore,
getComparisonStore,
} from '../../model';
interface Props {
@@ -37,6 +37,9 @@ let {
controls,
class: className,
}: Props = $props();
const comparisonStore = getComparisonStore();
const side = $derived<Side>(comparisonStore.side);
</script>
<div
@@ -71,9 +74,9 @@ let {
<ButtonGroup>
<ToggleButton
size="sm"
active={comparisonStore.side === 'A'}
active={side === 'A'}
aria-controls="primary-font"
aria-pressed={comparisonStore.side === 'A'}
aria-pressed={side === 'A'}
onclick={() => comparisonStore.side = 'A'}
class="flex-1 tracking-wide font-bold uppercase"
>
@@ -83,9 +86,9 @@ let {
<ToggleButton
size="sm"
class="flex-1 tracking-wide font-bold uppercase"
active={comparisonStore.side === 'B'}
active={side === 'B'}
aria-controls="secondary-font"
aria-pressed={comparisonStore.side === 'B'}
aria-pressed={side === 'B'}
onclick={() => comparisonStore.side = 'B'}
>
<span>Right Font</span>
@@ -5,7 +5,11 @@ import {
waitFor,
} from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import { comparisonStore } from '../../model';
import { getComparisonStore } from '../../model';
import {
ComparisonStore,
__resetComparisonStore,
} from '../../model/stores/comparisonStore.svelte';
import Sidebar from './Sidebar.svelte';
function textSnippet(text: string) {
@@ -13,10 +17,17 @@ function textSnippet(text: string) {
}
describe('Sidebar', () => {
afterEach(() => {
let comparisonStore!: ComparisonStore;
beforeEach(() => {
comparisonStore = getComparisonStore();
comparisonStore.side = 'A';
});
afterEach(() => {
__resetComparisonStore();
});
describe('Rendering', () => {
it('renders the "Configuration" title', () => {
render(Sidebar);
@@ -18,7 +18,7 @@ import {
MULTIPLIER_M,
MULTIPLIER_S,
TypographyMenu,
typographySettingsStore,
getTypographySettingsStore,
} from '$features/AdjustTypography';
import {
type ResponsiveManager,
@@ -33,7 +33,7 @@ import {
ensureCanvasFonts,
getPretextFontString,
} from '../../lib';
import { comparisonStore } from '../../model';
import { getComparisonStore } from '../../model';
import Line from '../Line/Line.svelte';
import Thumb from '../Thumb/Thumb.svelte';
@@ -51,6 +51,8 @@ interface Props {
let { isSidebarOpen = false, class: className }: Props = $props();
const comparisonStore = getComparisonStore();
/**
* Spring tuning for the comparison slider thumb. Lower stiffness = slower
* follow; higher damping = less overshoot.
@@ -79,7 +81,7 @@ const SLIDER_STEP_COARSE = 10;
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
const typography = $derived(typographySettingsStore);
const typography = getTypographySettingsStore();
let container = $state<HTMLElement>();
@@ -6,7 +6,7 @@
import {
FilterControls,
Filters,
appliedFilterStore,
getAppliedFilterStore,
} from '$features/FilterAndSortFonts';
import { springySlideFade } from '$shared/lib';
import {
@@ -31,6 +31,8 @@ interface Props {
let { showFilters = $bindable(true) }: Props = $props();
const appliedFilterStore = getAppliedFilterStore();
const transform = new Tween(
{ scale: 1, rotate: 0 },
{ duration: 250, easing: cubicOut },
@@ -3,7 +3,7 @@
Wraps FontSearch with a Section component
-->
<script lang="ts">
import { NavigationWrapper } from '$entities/Breadcrumb';
import { NavigationWrapper } from '$features/Breadcrumb';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import { Section } from '$shared/ui';
@@ -8,18 +8,24 @@
import {
FontVirtualList,
createFontRowSizeResolver,
fontCatalogStore,
fontLifecycleManager,
} from '$entities/Font';
import {
getFontCatalog,
getFontLifecycleManager,
} from '$entities/Font/model';
import {
TypographyMenu,
typographySettingsStore,
getTypographySettingsStore,
} from '$features/AdjustTypography';
import { FontSampler } from '$features/DisplayFont';
import { throttle } from '$shared/lib/utils';
import { Skeleton } from '$shared/ui';
import { layoutManager } from '../../model';
const fontCatalog = getFontCatalog();
const typographySettingsStore = getTypographySettingsStore();
const fontLifecycleManager = getFontLifecycleManager();
// FontSampler chrome heights — derived from Tailwind classes in FontSampler.svelte.
// Header: py-3 (12+12px padding) + ~32px content row ≈ 56px.
// Only the header is counted; the mobile footer (md:hidden) is excluded because
@@ -42,14 +48,16 @@ const SAMPLER_FALLBACK_HEIGHT = 220;
*/
const CHECK_POSITION_THROTTLE_MS = 100;
let text = $state('The quick brown fox jumps over the lazy dog...');
const fonts = $derived(fontCatalog?.fonts);
let text = $state<string>('The quick brown fox jumps over the lazy dog...');
let wrapper = $state<HTMLDivElement | null>(null);
// Binds to the actual window height
let innerHeight = $state(0);
let innerHeight = $state<number>(0);
// Is the component above the middle of the viewport?
let isAboveMiddle = $state(false);
let isAboveMiddle = $state<boolean>(false);
// Inner width of the wrapper div — updated by bind:clientWidth on mount and resize.
let containerWidth = $state(0);
let containerWidth = $state<number>(0);
const checkPosition = throttle(() => {
if (!wrapper) {
@@ -67,7 +75,7 @@ const checkPosition = throttle(() => {
// change triggers a full offsets recompute in createVirtualizer — no DOM snap.
const fontRowHeight = $derived.by(() =>
createFontRowSizeResolver({
getFonts: () => fontCatalogStore.fonts,
getFonts: () => fonts,
getWeight: () => typographySettingsStore.weight,
getPreviewText: () => text,
getContainerWidth: () => containerWidth,
@@ -111,7 +119,13 @@ const fontRowHeight = $derived.by(() =>
{skeleton}
>
{#snippet children({ item: font, index })}
<FontSampler bind:text {font} {index} />
<!--
Resolve load status here (the widget owns the lifecycle store) and
pass it down — FontSampler and FontApplicator stay store-decoupled.
getFontStatus reads a $state SvelteMap, so the row stays reactive.
-->
{@const status = fontLifecycleManager.getFontStatus(font.id, typographySettingsStore.weight, font.features?.isVariable)}
<FontSampler bind:text {font} {index} {status} />
{/snippet}
</FontVirtualList>
@@ -3,8 +3,8 @@
Wraps SampleList with a Section component
-->
<script lang="ts">
import { NavigationWrapper } from '$entities/Breadcrumb';
import { fontCatalogStore } from '$entities/Font';
import { getFontCatalog } from '$entities/Font/model';
import { NavigationWrapper } from '$features/Breadcrumb';
import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/lib';
import {
@@ -26,6 +26,9 @@ interface Props {
const { index }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive');
const fontCatalog = getFontCatalog();
const total = $derived<number>(fontCatalog?.pagination?.total);
</script>
<NavigationWrapper index={2} title="Samples">
@@ -36,7 +39,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
id="sample_set"
title="Sample Set"
headerTitle="visual_output"
headerSubtitle="items_total: {fontCatalogStore.pagination.total ?? 0}"
headerSubtitle="items_total: {total ?? 0}"
headerAction={registerAction}
>
{#snippet headerContent()}