Compare commits
24 Commits
42bcc915c7
...
b3bc40b76c
| Author | SHA1 | Date | |
|---|---|---|---|
| b3bc40b76c | |||
| 839460726e | |||
| 6877807aaf | |||
| 3dca11fea8 | |||
| 0b675635b3 | |||
| 9780ff9358 | |||
| 1ad015aed6 | |||
| 10603d18bf | |||
| 39d1ce4c37 | |||
| fcd61be4fa | |||
| 28a8e49915 | |||
| 43e8507144 | |||
| 67af3d946a | |||
| c6d0270072 | |||
| a677dc6b0b | |||
| f7cd6b5081 | |||
| dda8ef6368 | |||
| d77b51736a | |||
| 1e16330097 | |||
| c41016ac5d | |||
| aa4189f6a8 | |||
| 17c022470e | |||
| a9f3b990ab | |||
| 36673597f7 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
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?.()}
|
||||
@@ -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,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`.
|
||||
|
||||
+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,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;
|
||||
}
|
||||
|
||||
@@ -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,9 +1,9 @@
|
||||
export {
|
||||
createTypographySettingsStore,
|
||||
getTypographySettingsStore,
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
type TypographySettingsStore,
|
||||
typographySettingsStore,
|
||||
} from './model';
|
||||
export { TypographyMenu } from './ui';
|
||||
|
||||
@@ -5,6 +5,6 @@ export {
|
||||
} from './const/const';
|
||||
export {
|
||||
createTypographySettingsStore,
|
||||
getTypographySettingsStore,
|
||||
type TypographySettingsStore,
|
||||
typographySettingsStore,
|
||||
} from './store/typographySettingsStore/typographySettingsStore.svelte';
|
||||
|
||||
+33
-6
@@ -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,
|
||||
+20
-3
@@ -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;
|
||||
}
|
||||
+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');
|
||||
|
||||
+3
-1
@@ -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' },
|
||||
+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 +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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
+26
-15
@@ -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;
|
||||
}
|
||||
|
||||
+13
-2
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user